it is risen - add restore progress and backup/restore history
The watcher scripts now write live status and append-only run history onto the shared volume; the api exposes them via /shopdbbackups/status and /history. The restore modal shows an indeterminate spinner with phase labels, and two cards list backup and restore run records. Timestamps render in UTC to match the Zulu-stamped set ids, the history directory is excluded from the set list, and a stale-status guard stops a previous run's done phase from collapsing the spinner on the next restore.
This commit is contained in:
@@ -185,6 +185,38 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Operational run records, written by the backup container onto the
|
||||
shared volume. Two cards: what was backed up, and what was restored. -->
|
||||
<div class="history-cards">
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Backup history</h3>
|
||||
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Restore history</h3>
|
||||
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
|
||||
UI budget on the compatibility verdict before allowing the click. -->
|
||||
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
|
||||
@@ -192,6 +224,34 @@
|
||||
<h3>Restore this backup?</h3>
|
||||
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
|
||||
|
||||
<!-- Progress mode: once a restore is queued we swap the compatibility
|
||||
body for an indeterminate spinner and the current phase label. -->
|
||||
<template v-if="restorePhase">
|
||||
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
|
||||
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
|
||||
<component
|
||||
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
|
||||
v-else
|
||||
:size="18"
|
||||
/>
|
||||
<span class="restore-phase">{{ restorePhaseLabel }}</span>
|
||||
</div>
|
||||
<p class="restore-hint">
|
||||
Keep this open until the restore finishes. This can take a moment.
|
||||
</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:disabled="isRestoreRunning"
|
||||
@click="closeRestore"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
|
||||
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
|
||||
<span v-if="restoreTarget.iscompatible">
|
||||
@@ -224,13 +284,14 @@
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
|
||||
import api from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
@@ -243,6 +304,37 @@ const isBusy = ref(false)
|
||||
const lastGoodAt = ref(null)
|
||||
const restoreTarget = ref(null)
|
||||
|
||||
// Live restore progress. restoreStatus mirrors the api /status.restore payload;
|
||||
// restorePhase drives the indeterminate spinner and label in the modal.
|
||||
const restoreStatus = ref(null)
|
||||
let statusTimer = null
|
||||
let restoreStartedAt = 0
|
||||
|
||||
const backups = ref([])
|
||||
const restores = ref([])
|
||||
|
||||
// Human labels for the phase names the watcher scripts write. Anything we do
|
||||
// not recognize falls back to the raw phase so a new phase still shows.
|
||||
const RESTORE_PHASE_LABELS = {
|
||||
queued: 'Queued...',
|
||||
checking: 'Checking compatibility...',
|
||||
files: 'Restoring files...',
|
||||
database: 'Loading database...',
|
||||
done: 'Restore complete',
|
||||
failed: 'Restore failed',
|
||||
}
|
||||
|
||||
const restorePhase = computed(() => restoreStatus.value?.phase || null)
|
||||
const isRestoreRunning = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
return !!phase && phase !== 'done' && phase !== 'failed'
|
||||
})
|
||||
const restorePhaseLabel = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
if (!phase) return ''
|
||||
return RESTORE_PHASE_LABELS[phase] || phase
|
||||
})
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const settingsLoading = ref(true)
|
||||
const savingSettings = ref(false)
|
||||
@@ -332,6 +424,8 @@ function openRestore(set) {
|
||||
}
|
||||
|
||||
function closeRestore() {
|
||||
stopStatusPolling()
|
||||
restoreStatus.value = null
|
||||
restoreTarget.value = null
|
||||
}
|
||||
|
||||
@@ -341,13 +435,73 @@ async function confirmRestore() {
|
||||
try {
|
||||
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
|
||||
toast.success('Restore queued and validated against the live schema.')
|
||||
closeRestore()
|
||||
// Keep the modal open and switch it to progress mode; polling drives the
|
||||
// spinner through each phase until the watcher reports done or failed.
|
||||
startStatusPolling()
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not queue the restore.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/history')
|
||||
const data = response.data.data || {}
|
||||
backups.value = data.backups || []
|
||||
restores.value = data.restores || []
|
||||
} catch (error) {
|
||||
// History is non-critical; a failure here should not disrupt the page.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
let status
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/status')
|
||||
status = response.data.data?.restore || null
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
// Ignore a terminal status left over from a previous restore: if it finished
|
||||
// before we started this one, it is stale and would close the modal instantly
|
||||
// before the spinner ever paints. Wait for a fresh status from this run.
|
||||
if (status && (status.phase === 'done' || status.phase === 'failed')) {
|
||||
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
|
||||
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreStatus.value = status
|
||||
const phase = status?.phase
|
||||
if (phase === 'done' || phase === 'failed') {
|
||||
stopStatusPolling()
|
||||
if (phase === 'done') {
|
||||
toast.success('Restore complete.')
|
||||
closeRestore()
|
||||
} else {
|
||||
toast.error(status?.message || 'Restore failed.')
|
||||
}
|
||||
load()
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling()
|
||||
restoreStartedAt = Date.now()
|
||||
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
|
||||
// Poll on an interval only; skip the immediate call so the seeded "queued"
|
||||
// phase is guaranteed to paint before the first real status can replace it.
|
||||
statusTimer = setInterval(pollStatus, 1000)
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +539,17 @@ function formatTaken(value) {
|
||||
if (!value) return 'unknown'
|
||||
const date = new Date(normalizeStamp(value))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
// Stamps are UTC; render them in UTC so the displayed time matches the
|
||||
// Zulu-stamped set id rather than shifting to the operator's local zone.
|
||||
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
|
||||
}
|
||||
|
||||
function outcomeClass(outcome) {
|
||||
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
|
||||
}
|
||||
|
||||
function outcomeLabel(outcome) {
|
||||
return outcome === 'success' ? 'Success' : 'Failed'
|
||||
}
|
||||
|
||||
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
|
||||
@@ -425,6 +589,11 @@ function statusLabel(set) {
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadSettings()
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStatusPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -570,6 +739,85 @@ onMounted(() => {
|
||||
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
|
||||
|
||||
.restore-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(56, 161, 105, 0.15);
|
||||
color: #9ae6b4;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.restore-phase { font-weight: 600; }
|
||||
.restore-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(154, 230, 180, 0.35);
|
||||
border-top-color: #9ae6b4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.history-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.history-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.history-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.history-row:first-child { border-top: none; }
|
||||
.history-outcome {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.history-set {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.history-when { color: var(--text-light); flex-shrink: 0; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user