fix: SSH update modal polls locally, collapsed log by default (v0.5.9)
Modal owns job poll and completion UI with countdown; log hidden until user clicks Показать лог; db.refresh on remote-job; cache-bust poll requests.
This commit is contained in:
@@ -653,6 +653,7 @@ def get_host_remote_job(
|
|||||||
host = db.get(Host, host_id)
|
host = db.get(Host, host_id)
|
||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
db.refresh(host)
|
||||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.5.7"
|
APP_VERSION = "0.5.9"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
+3
-1
@@ -613,7 +613,9 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||||
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
return apiFetch<HostRemoteActionJobStatus>(
|
||||||
|
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostSessionItem {
|
export interface HostSessionItem {
|
||||||
|
|||||||
@@ -1,49 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
||||||
<div class="host-action-log-panel" role="dialog" :aria-label="title">
|
<div
|
||||||
|
class="host-action-log-panel"
|
||||||
|
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="displayTitle"
|
||||||
|
>
|
||||||
<div class="host-action-log-header">
|
<div class="host-action-log-header">
|
||||||
<h3>
|
<h3>
|
||||||
<span v-if="!loading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true">✓</span>
|
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true">✓</span>
|
||||||
<span v-else-if="!loading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true">✗</span>
|
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true">✗</span>
|
||||||
{{ title }}
|
{{ displayTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="host-action-log-icon-btn"
|
class="host-action-log-icon-btn"
|
||||||
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
|
:title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
|
||||||
:aria-label="loading ? 'Свернуть' : 'Закрыть'"
|
:aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="loading" class="host-action-log-status">
|
<p v-if="isLoading" class="host-action-log-status">
|
||||||
<span class="host-action-log-spinner" aria-hidden="true" />
|
<span class="host-action-log-spinner" aria-hidden="true" />
|
||||||
Выполняется на удалённом хосте… Можно запустить обновление других хостов.
|
Выполняется на удалённом хосте… Можно запустить обновление других хостов.
|
||||||
</p>
|
</p>
|
||||||
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
|
<p
|
||||||
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
|
v-else-if="ok === true"
|
||||||
<p v-if="!loading && ok" class="muted host-action-log-sub">Окно закроется автоматически через ~30 с</p>
|
class="host-action-log-message success host-action-log-result"
|
||||||
|
>
|
||||||
|
{{ displayMessage }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="ok === false"
|
||||||
|
class="host-action-log-message error host-action-log-result"
|
||||||
|
>
|
||||||
|
{{ displayMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
|
||||||
|
{{ displayMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
|
||||||
|
Окно закроется автоматически через {{ autoCloseSec }} с
|
||||||
|
</p>
|
||||||
<pre
|
<pre
|
||||||
v-show="logVisible"
|
v-show="logVisible"
|
||||||
ref="logPreRef"
|
ref="logPreRef"
|
||||||
:key="`${output.length}-${loading}`"
|
|
||||||
class="host-action-log-output"
|
class="host-action-log-output"
|
||||||
>{{ displayOutput }}</pre>
|
>{{ displayOutput }}</pre>
|
||||||
<div class="host-action-log-actions">
|
<div class="host-action-log-actions">
|
||||||
<button
|
<button type="button" class="secondary" @click="emit('toggle-log')">
|
||||||
v-if="loading || output"
|
|
||||||
type="button"
|
|
||||||
class="secondary"
|
|
||||||
@click="emit('toggle-log')"
|
|
||||||
>
|
|
||||||
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
|
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="loading" type="button" class="secondary" @click="emit('close')">
|
<button type="button" class="secondary" @click="emit('close')">
|
||||||
Свернуть
|
{{ isLoading ? "Свернуть" : "Закрыть" }}
|
||||||
</button>
|
|
||||||
<button v-else type="button" class="secondary" @click="emit('close')">
|
|
||||||
Закрыть
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,9 +62,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from "vue";
|
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||||
|
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
|
||||||
|
|
||||||
|
const LOG_POLL_MS = 1500;
|
||||||
|
const AUTO_CLOSE_MS = 30_000;
|
||||||
|
const PLACEHOLDER =
|
||||||
|
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
hostId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -66,31 +84,159 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
"toggle-log": [];
|
"toggle-log": [];
|
||||||
|
finished: [job: HostRemoteActionJobStatus];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const logPreRef = ref<HTMLElement | null>(null);
|
const logPreRef = ref<HTMLElement | null>(null);
|
||||||
|
const polledOutput = ref("");
|
||||||
|
const polledMessage = ref("");
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const ok = ref<boolean | null>(null);
|
||||||
|
const localTitle = ref("");
|
||||||
|
const localMessage = ref("");
|
||||||
|
const autoCloseSec = ref(0);
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let finishedEmitted = false;
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
if (localTitle.value) return localTitle.value;
|
||||||
|
return props.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayMessage = computed(() => {
|
||||||
|
if (localMessage.value) return localMessage.value;
|
||||||
|
if (polledMessage.value) return polledMessage.value;
|
||||||
|
return props.message;
|
||||||
|
});
|
||||||
|
|
||||||
const displayOutput = computed(() => {
|
const displayOutput = computed(() => {
|
||||||
const text = props.output.trim();
|
const text = (polledOutput.value || props.output).trim();
|
||||||
return text || "Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
|
return text || PLACEHOLDER;
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageClass = computed(() => {
|
function stopPoll() {
|
||||||
if (props.loading || props.ok == null) return "muted";
|
if (pollTimer != null) {
|
||||||
return props.ok ? "success" : "error";
|
clearInterval(pollTimer);
|
||||||
});
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCountdown() {
|
||||||
|
if (countdownTimer != null) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
}
|
||||||
|
autoCloseSec.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
stopCountdown();
|
||||||
|
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
if (autoCloseSec.value <= 1) {
|
||||||
|
stopCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoCloseSec.value -= 1;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFinishedJob(job: HostRemoteActionJobStatus) {
|
||||||
|
isLoading.value = false;
|
||||||
|
ok.value = job.ok ?? job.status === "success";
|
||||||
|
if (job.title) localTitle.value = job.title;
|
||||||
|
if (job.message) localMessage.value = job.message;
|
||||||
|
const out = (job.output || job.stdout || "").trim();
|
||||||
|
if (out) polledOutput.value = out;
|
||||||
|
if (ok.value) startCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollJobOnce() {
|
||||||
|
try {
|
||||||
|
const job = await fetchHostRemoteJob(props.hostId);
|
||||||
|
const out = (job.output || job.stdout || "").trim();
|
||||||
|
if (out) polledOutput.value = out;
|
||||||
|
if (job.message && isLoading.value) polledMessage.value = job.message;
|
||||||
|
|
||||||
|
if (!job.active && job.status !== "running") {
|
||||||
|
if (!finishedEmitted) {
|
||||||
|
finishedEmitted = true;
|
||||||
|
applyFinishedJob(job);
|
||||||
|
emit("finished", job);
|
||||||
|
}
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* следующий poll */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll() {
|
||||||
|
stopPoll();
|
||||||
|
void pollJobOnce();
|
||||||
|
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLocalState() {
|
||||||
|
finishedEmitted = false;
|
||||||
|
isLoading.value = true;
|
||||||
|
ok.value = null;
|
||||||
|
localTitle.value = "";
|
||||||
|
localMessage.value = "";
|
||||||
|
polledOutput.value = props.output.trim();
|
||||||
|
polledMessage.value = "";
|
||||||
|
stopCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.output, props.loading, props.logVisible] as const,
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open && isLoading.value) {
|
||||||
|
startPoll();
|
||||||
|
} else {
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (loading) {
|
||||||
|
resetLocalState();
|
||||||
|
if (props.open) startPoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!finishedEmitted && props.ok != null) {
|
||||||
|
finishedEmitted = true;
|
||||||
|
isLoading.value = false;
|
||||||
|
ok.value = props.ok;
|
||||||
|
localTitle.value = props.title;
|
||||||
|
localMessage.value = props.message;
|
||||||
|
if (props.output.trim()) polledOutput.value = props.output.trim();
|
||||||
|
if (props.ok) startCountdown();
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [displayOutput.value, props.logVisible] as const,
|
||||||
async () => {
|
async () => {
|
||||||
if (!props.logVisible) return;
|
if (!props.logVisible) return;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const el = logPreRef.value;
|
const el = logPreRef.value;
|
||||||
if (el) {
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPoll();
|
||||||
|
stopCountdown();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -110,6 +256,14 @@ watch(
|
|||||||
padding: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-action-log-panel-done {
|
||||||
|
border-color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-panel-fail {
|
||||||
|
border-color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
.host-action-log-header {
|
.host-action-log-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -177,6 +331,20 @@ watch(
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-action-log-result {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-message.success {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-message.error {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
.host-action-log-spinner {
|
.host-action-log-spinner {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
|
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
|
||||||
<HostActionLogModal
|
<HostActionLogModal
|
||||||
v-if="entry.open"
|
v-if="entry.open"
|
||||||
|
:host-id="entry.hostId"
|
||||||
:open="true"
|
:open="true"
|
||||||
:title="entry.title"
|
:title="entry.title"
|
||||||
:loading="entry.loading"
|
:loading="entry.loading"
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
:log-visible="entry.logVisible"
|
:log-visible="entry.logVisible"
|
||||||
@close="closeHostRemoteActionLog(entry.id)"
|
@close="closeHostRemoteActionLog(entry.id)"
|
||||||
@toggle-log="toggleHostRemoteActionLog(entry.id)"
|
@toggle-log="toggleHostRemoteActionLog(entry.id)"
|
||||||
|
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,6 +25,7 @@ import HostActionLogModal from "./HostActionLogModal.vue";
|
|||||||
import {
|
import {
|
||||||
closeHostRemoteActionLog,
|
closeHostRemoteActionLog,
|
||||||
hostRemoteActionLogs,
|
hostRemoteActionLogs,
|
||||||
|
syncHostRemoteActionJobFinished,
|
||||||
toggleHostRemoteActionLog,
|
toggleHostRemoteActionLog,
|
||||||
} from "../composables/useHostRemoteAction";
|
} from "../composables/useHostRemoteAction";
|
||||||
|
|
||||||
|
|||||||
@@ -95,16 +95,28 @@ function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJ
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
||||||
|
if (!entry.loading && entry.ok != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
entry.loading = false;
|
entry.loading = false;
|
||||||
entry.ok = job.ok ?? job.status === "success";
|
entry.ok = job.ok ?? job.status === "success";
|
||||||
applyJobToEntry(entry, job);
|
applyJobToEntry(entry, job);
|
||||||
entry.logVisible = true;
|
|
||||||
entry.open = true;
|
entry.open = true;
|
||||||
if (entry.ok) {
|
if (entry.ok) {
|
||||||
scheduleSuccessAutoClose(entry.id);
|
scheduleSuccessAutoClose(entry.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncHostRemoteActionJobFinished(
|
||||||
|
entryId: string,
|
||||||
|
job: HostRemoteActionJobStatus,
|
||||||
|
): void {
|
||||||
|
const entry = findLogById(entryId);
|
||||||
|
if (!entry) return;
|
||||||
|
finishEntry(entry, job);
|
||||||
|
void patchHostFromJob(entry.hostId, job);
|
||||||
|
}
|
||||||
|
|
||||||
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
|
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const job = await fetchHostRemoteJob(hostId);
|
const job = await fetchHostRemoteJob(hostId);
|
||||||
@@ -189,7 +201,7 @@ export async function pollHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: true,
|
logVisible: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
@@ -239,7 +251,7 @@ export async function runHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: true,
|
logVisible: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
|
|||||||
Reference in New Issue
Block a user