Import the wilm endpoint telemetry, PC Detail rework and backup overlay
Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.
What comes across:
* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
state, boot type, Defender health, processor/memory/chassis inventory, the
listening TCP set, mapped network drives and Windows Update history. Five
additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
session, because they do not exist in the agent's session 0. Two Windows
sources can disagree about one drive and the worse status wins, so a
half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
to a full address history, Storage and disk health merge into one card with
two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
which closes the scoped-token elevation on run_script, update_app and
system_restart.
Held back deliberately:
* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
The installer that reaches a bay is generated by _install_script() in
plugins/endpoints/api/siteagent.py; the standalone script had stopped being
shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
where docs/ becomes the wiki and its absence is how the gates detect a
published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
the author's own machine, and docs/notes/, which is working scratch that says
so in its own header and belongs in devnotes/ on this side.
Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.
Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.
Scope: product
This commit is contained in:
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
@@ -0,0 +1,864 @@
|
||||
<template>
|
||||
<div class="backups-page">
|
||||
<div class="page-header">
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
|
||||
<!-- Confidence banner: the single most reassuring thing an admin wants to
|
||||
see - is there a recent, verified backup? Everything else is secondary. -->
|
||||
<div class="confidence-banner" :class="bannerClass">
|
||||
<span class="banner-icon">
|
||||
<component :is="bannerIcon" :size="22" />
|
||||
</span>
|
||||
<div class="banner-text">
|
||||
<strong>{{ bannerHeadline }}</strong>
|
||||
<span class="banner-sub">{{ bannerSub }}</span>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
|
||||
{{ isBusy ? 'Working...' : 'Back up now' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Light explanation, details tucked away. Leads the UI-simplification
|
||||
charge: one plain sentence up top, the depth behind a disclosure. -->
|
||||
<details class="explain">
|
||||
<summary>What is backed up, and how restore works</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Each backup is one recoverable set: the full database (users,
|
||||
settings, and every asset), the uploaded files, and a manifest that
|
||||
records the exact code version so a restore can never land on the
|
||||
wrong schema.
|
||||
</p>
|
||||
<p>
|
||||
Backups keep <strong>any day in the last 5</strong>, plus weekly and
|
||||
monthly copies. Restoring your <em>data</em> is separate from rolling
|
||||
back <em>code</em>: code is handled in git. If a restore needs a newer
|
||||
code version than you are running, this screen tells you before it
|
||||
lets you proceed.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Destination configuration: where sets ship and the credentials to get
|
||||
there. SMB and AWS are wired but not yet armed (banner says so)
|
||||
because their client libraries wait on the security review. -->
|
||||
<details class="explain destination" :open="settingsOpen">
|
||||
<summary @click.prevent="settingsOpen = !settingsOpen">
|
||||
Backup destination
|
||||
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
|
||||
</summary>
|
||||
<div class="explain-body">
|
||||
<div v-if="settingsLoading" class="muted">Loading destination...</div>
|
||||
<template v-else>
|
||||
<label class="field">
|
||||
<span class="field-label">Ship backups to</span>
|
||||
<select v-model="settings.destinationkind" class="input">
|
||||
<option
|
||||
v-for="kind in settings.destinationkinds"
|
||||
:key="kind"
|
||||
:value="kind"
|
||||
>
|
||||
{{ destinationLabel(kind) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p v-if="settings.destinationkind === 'local'" class="muted small">
|
||||
Sets stay on the mounted backups volume. No credentials needed.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="not-armed"
|
||||
>
|
||||
<component :is="ShieldAlert" :size="16" />
|
||||
<span>
|
||||
This destination is configured but not yet armed. Its client
|
||||
library is held back until the security review clears it against
|
||||
external exploit; until then sets keep writing locally.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.destinationkind === 'smb'">
|
||||
<label class="field">
|
||||
<span class="field-label">Server host</span>
|
||||
<input v-model="settings.fields.backup_smb_host" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Share</span>
|
||||
<input v-model="settings.fields.backup_smb_share" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Path within share</span>
|
||||
<input v-model="settings.fields.backup_smb_path" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Username</span>
|
||||
<input v-model="settings.fields.backup_smb_username" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_smb_password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="settings.destinationkind === 'aws'">
|
||||
<label class="field">
|
||||
<span class="field-label">Region</span>
|
||||
<input v-model="settings.fields.backup_aws_region" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Bucket</span>
|
||||
<input v-model="settings.fields.backup_aws_bucket" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Key prefix</span>
|
||||
<input v-model="settings.fields.backup_aws_prefix" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Access key id</span>
|
||||
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Secret access key</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_aws_secretaccesskey"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
|
||||
{{ savingSettings ? 'Saving...' : 'Save destination' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="loading" class="muted">Loading backups...</div>
|
||||
<div v-else-if="!sets.length" class="empty-state">
|
||||
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="backups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Taken</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
<th>Restore</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="set in sets" :key="set.snapshotid">
|
||||
<td>
|
||||
<span class="taken">{{ formatTaken(set.takenat) }}</span>
|
||||
<span class="setid">{{ set.snapshotid }}</span>
|
||||
</td>
|
||||
<td>{{ formatSize(set.sizebytes) }}</td>
|
||||
<td>
|
||||
<span class="chip" :class="statusClass(set)">
|
||||
{{ statusLabel(set) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-restore"
|
||||
:class="{ blocked: !set.iscompatible }"
|
||||
:disabled="isBusy"
|
||||
@click="openRestore(set)"
|
||||
>
|
||||
Restore...
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</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">
|
||||
<div class="modal">
|
||||
<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">
|
||||
Compatible with the code you are running. Safe to restore.
|
||||
</span>
|
||||
<span v-else>
|
||||
This backup was taken on a newer schema than the running code.
|
||||
Upgrade the code first, then retry - restore is blocked until then.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="explain small">
|
||||
<summary>What restoring does</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Restoring replaces the current database and uploaded files with the
|
||||
contents of this backup. Code is not changed. This cannot be undone,
|
||||
so it is gated on the compatibility check above.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-ghost" @click="closeRestore">Cancel</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
:disabled="!restoreTarget.iscompatible || isBusy"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const sets = ref([])
|
||||
const loading = ref(true)
|
||||
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
|
||||
// The Flask-less backup sidecar loads the dump and parks the restore in
|
||||
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
|
||||
// the browser pokes the finalize endpoint once to roll the restored data up to
|
||||
// the running code. Guarded so we fire it exactly once per restore run.
|
||||
let finalizeRequested = false
|
||||
|
||||
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...',
|
||||
upgrading: 'Upgrading schema to code...',
|
||||
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)
|
||||
const settings = ref({
|
||||
destinationkind: 'local',
|
||||
destinationkinds: ['local'],
|
||||
fields: {},
|
||||
})
|
||||
|
||||
const DESTINATION_LABELS = {
|
||||
local: 'Local volume',
|
||||
smb: 'SMB / CIFS share',
|
||||
aws: 'AWS S3 bucket',
|
||||
}
|
||||
|
||||
function destinationLabel(kind) {
|
||||
return DESTINATION_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/settings')
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Could not load backup destination.')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
savingSettings.value = true
|
||||
try {
|
||||
const response = await api.put('/shopdbbackups/settings', {
|
||||
destinationkind: settings.value.destinationkind,
|
||||
fields: settings.value.fields,
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
toast.success('Backup destination saved.')
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not save the backup destination.')
|
||||
} finally {
|
||||
savingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups')
|
||||
sets.value = response.data.data || []
|
||||
lastGoodAt.value = response.data.meta?.lastgoodat || null
|
||||
} catch (error) {
|
||||
toast.error('Could not load backups.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
isBusy.value = true
|
||||
try {
|
||||
const response = await api.post('/shopdbbackups')
|
||||
toast.success(response.data.message || 'Backup queued.')
|
||||
} catch (error) {
|
||||
toast.error('Could not queue a backup.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
function openRestore(set) {
|
||||
restoreTarget.value = set
|
||||
}
|
||||
|
||||
function closeRestore() {
|
||||
stopStatusPolling()
|
||||
restoreStatus.value = null
|
||||
restoreTarget.value = null
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!restoreTarget.value?.iscompatible) return
|
||||
isBusy.value = true
|
||||
try {
|
||||
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
|
||||
toast.success('Restore queued and validated against the live schema.')
|
||||
// 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 === 'upgrading' && !finalizeRequested) {
|
||||
// Hand off to the api container, which owns the Flask CLI, to run the
|
||||
// post-load schema upgrade. Fire once; polling keeps tracking until it
|
||||
// reports done or failed.
|
||||
finalizeRequested = true
|
||||
try {
|
||||
await api.post('/shopdbbackups/finalize-restore')
|
||||
} catch (error) {
|
||||
// The finalize endpoint stamps its own failed status on error, which the
|
||||
// next poll surfaces; nothing extra to do here.
|
||||
}
|
||||
}
|
||||
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()
|
||||
finalizeRequested = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// -- Confidence banner state ------------------------------------------------
|
||||
const STALE_HOURS = 48
|
||||
|
||||
const hoursSinceLastGood = computed(() => {
|
||||
if (!lastGoodAt.value) return null
|
||||
const then = new Date(normalizeStamp(lastGoodAt.value)).getTime()
|
||||
return (Date.now() - then) / 36e5
|
||||
})
|
||||
|
||||
const bannerState = computed(() => {
|
||||
if (!lastGoodAt.value) return 'none'
|
||||
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
const bannerClass = computed(() => `banner-${bannerState.value}`)
|
||||
const bannerIcon = computed(() => ({
|
||||
good: ShieldCheck, stale: Clock, none: ShieldAlert,
|
||||
}[bannerState.value]))
|
||||
const bannerHeadline = computed(() => ({
|
||||
good: 'Your data is protected',
|
||||
stale: 'Backup is overdue',
|
||||
none: 'No verified backup yet',
|
||||
}[bannerState.value]))
|
||||
const bannerSub = computed(() => {
|
||||
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
|
||||
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
|
||||
})
|
||||
|
||||
// -- Formatting -------------------------------------------------------------
|
||||
function formatTaken(value) {
|
||||
if (!value) return 'unknown'
|
||||
const date = new Date(normalizeStamp(value))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// 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
|
||||
// (YYYY-MM-DDTHHMMSSZ, sometimes prefixed with a site name), which the Date
|
||||
// parser rejects. Reinsert the colons so it parses as real ISO 8601.
|
||||
function normalizeStamp(value) {
|
||||
const match = String(value).match(/(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/)
|
||||
if (!match) return value
|
||||
const [, day, hour, minute, second] = match
|
||||
return `${day}T${hour}:${minute}:${second}Z`
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function statusClass(set) {
|
||||
if (set.ischeckpassed) return 'chip-verified'
|
||||
if (!set.hasmanifest) return 'chip-unverified'
|
||||
return 'chip-pending'
|
||||
}
|
||||
|
||||
function statusLabel(set) {
|
||||
if (set.ischeckpassed) return 'Verified'
|
||||
if (!set.hasmanifest) return 'Unverified'
|
||||
return 'Not checked'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadSettings()
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStatusPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confidence-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.confidence-banner .banner-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.banner-sub {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.banner-good { border-color: #2f855a; }
|
||||
.banner-good .banner-icon { color: #38a169; }
|
||||
.banner-stale { border-color: #b7791f; }
|
||||
.banner-stale .banner-icon { color: #d69e2e; }
|
||||
.banner-none { border-color: #c53030; }
|
||||
.banner-none .banner-icon { color: #e53e3e; }
|
||||
|
||||
.explain {
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.explain.small { margin: 0.75rem 0; }
|
||||
.explain summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-light);
|
||||
user-select: none;
|
||||
}
|
||||
.explain-body {
|
||||
padding: 0 0.9rem 0.8rem 0.9rem;
|
||||
}
|
||||
.explain-body p {
|
||||
margin: 0 0 0.6rem 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backups-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.backups-table th,
|
||||
.backups-table td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.backups-table th {
|
||||
color: var(--text-light);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.taken { display: block; color: var(--text); }
|
||||
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
|
||||
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
|
||||
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.btn-restore {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-restore.blocked { opacity: 0.55; }
|
||||
|
||||
.empty-state, .muted {
|
||||
color: var(--text-light);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, 92vw);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
.modal h3 { margin: 0 0 0.3rem 0; }
|
||||
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
|
||||
.compat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.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;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #c53030;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user