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:
+3
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user