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:
2026-07-08 14:48:31 +10:00
parent 69fac2f26e
commit e7d5b1c60b
6 changed files with 224 additions and 38 deletions
+3 -1
View File
@@ -613,7 +613,9 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
}
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 {
+201 -33
View File
@@ -1,49 +1,60 @@
<template>
<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">
<h3>
<span v-if="!loading && 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>
{{ title }}
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" 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>
{{ displayTitle }}
</h3>
<button
type="button"
class="host-action-log-icon-btn"
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="loading ? 'Свернуть' : 'Закрыть'"
:title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
@click="emit('close')"
>
×
</button>
</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" />
Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
<p v-if="!loading && ok" class="muted host-action-log-sub">Окно закроется автоматически через ~30 с</p>
<p
v-else-if="ok === true"
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
v-show="logVisible"
ref="logPreRef"
:key="`${output.length}-${loading}`"
class="host-action-log-output"
>{{ displayOutput }}</pre>
<div class="host-action-log-actions">
<button
v-if="loading || output"
type="button"
class="secondary"
@click="emit('toggle-log')"
>
<button type="button" class="secondary" @click="emit('toggle-log')">
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
</button>
<button v-if="loading" type="button" class="secondary" @click="emit('close')">
Свернуть
</button>
<button v-else type="button" class="secondary" @click="emit('close')">
Закрыть
<button type="button" class="secondary" @click="emit('close')">
{{ isLoading ? "Свернуть" : "Закрыть" }}
</button>
</div>
</div>
@@ -51,9 +62,16 @@
</template>
<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<{
hostId: number;
open: boolean;
title: string;
loading: boolean;
@@ -66,31 +84,159 @@ const props = defineProps<{
const emit = defineEmits<{
close: [];
"toggle-log": [];
finished: [job: HostRemoteActionJobStatus];
}>();
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 text = props.output.trim();
return text || "Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
const text = (polledOutput.value || props.output).trim();
return text || PLACEHOLDER;
});
const messageClass = computed(() => {
if (props.loading || props.ok == null) return "muted";
return props.ok ? "success" : "error";
});
function stopPoll() {
if (pollTimer != null) {
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(
() => [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 () => {
if (!props.logVisible) return;
await nextTick();
const el = logPreRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
if (el) el.scrollTop = el.scrollHeight;
},
);
onUnmounted(() => {
stopPoll();
stopCountdown();
});
</script>
<style scoped>
@@ -110,6 +256,14 @@ watch(
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 {
display: flex;
align-items: flex-start;
@@ -177,6 +331,20 @@ watch(
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 {
width: 1rem;
height: 1rem;
@@ -3,6 +3,7 @@
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
<HostActionLogModal
v-if="entry.open"
:host-id="entry.hostId"
:open="true"
:title="entry.title"
:loading="entry.loading"
@@ -12,6 +13,7 @@
:log-visible="entry.logVisible"
@close="closeHostRemoteActionLog(entry.id)"
@toggle-log="toggleHostRemoteActionLog(entry.id)"
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
/>
</template>
</div>
@@ -23,6 +25,7 @@ import HostActionLogModal from "./HostActionLogModal.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLogs,
syncHostRemoteActionJobFinished,
toggleHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
@@ -95,16 +95,28 @@ function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJ
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (!entry.loading && entry.ok != null) {
return;
}
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
entry.logVisible = true;
entry.open = true;
if (entry.ok) {
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> {
for (;;) {
const job = await fetchHostRemoteJob(hostId);
@@ -189,7 +201,7 @@ export async function pollHostRemoteAction(
id: newLogId(),
hostId,
open: true,
logVisible: true,
logVisible: false,
loading: true,
ok: null,
title,
@@ -239,7 +251,7 @@ export async function runHostRemoteAction(
id: newLogId(),
hostId,
open: true,
logVisible: true,
logVisible: false,
loading: true,
ok: null,
title,