Six findings from a four-agent review of the imported work, plus the contract bump the import owed. Nothing here changes what the imported features do. The contract version was not bumped for a change that widens what a plugin may declare. The asset-panel vocabulary gained two tab groups (serviceprocess, reports), a datetime column format, a map.body key and a tabs section that renders as a table. 0.29.0 bumped for exactly this class of change when the optional group was added, so this is 0.31.0 with a ledger row. All four are optional and resolve server-side when absent, so a plugin declaring none of them is unaffected. Site Backups now answers honestly where it has nothing behind it. The overlay is a compose profile: a separate container writes sets onto a shared volume and the api container only reads them. A Windows/IIS install runs no such container and the default root resolves to a drive-letter path nothing creates, so Back Up Now either failed on PermissionError or reported a run queued that would never happen, under a banner reading "No verified backup yet" - which describes a backup that failed, not a feature that was never installed. The three write routes refuse with the same 404 shape the configuration-policy routes use for a disabled feature, the list and status routes report whether a store exists, and the page says so instead of offering buttons. Two listAll callers still read a response that is not there. listAll resolves to a plain array through fetchAllPages, so response.data.data throws: PC Types settings rendered empty, and in Configuration Policies the throw happened inside the shared try, so PC types, applications, the action catalog and monitors were all left unassigned by one bad read. The agent package upload cannot succeed on IIS as shipped. MAX_CONTENT_LENGTH went to 256 MB to admit the ~69 MB tray binary, but IIS request filtering caps a body at 30,000,000 bytes and answers 413 before waitress sees the request, so web.config needs the matching requestLimits. The backup image could not be built by anyone outside a TLS-intercepting network. Dockerfile.backup did a plain COPY of a gitignored corporate root CA, and a COPY of a missing file fails the build. The trailing glob makes it a no-op when the cert is absent. endpointstates.domain was the one new string column written without a clamp. Under STRICT_TRANS_TABLES an over-long value raises Data too long and aborts the whole check-in, so a single malformed report would stop a machine reporting telemetry and events entirely. Not fixed here, and left with upstream: the restore path finalizes its schema upgrade only from a browser poll loop, so closing the tab mid-restore leaves the data behind the code; it restores instance files from the newest snapshot rather than the set that was asked for; and it takes no safety dump before the destructive load. All three are in the Docker-only overlay, which this site does not run, and none can be validated here. Scope: product
892 lines
27 KiB
Vue
892 lines
27 KiB
Vue
<template>
|
|
<div class="backups-page">
|
|
<div class="page-header">
|
|
<h2>Backups</h2>
|
|
</div>
|
|
|
|
<div v-if="!isConfigured" class="not-configured">
|
|
<strong>Site Backups is not configured on this deployment.</strong>
|
|
<span>
|
|
The feature needs the backup overlay (a separate container writing sets
|
|
onto a shared volume) and a readable backups root. A Windows/IIS install
|
|
does not run it, so nothing here would have a store behind it.
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Confidence banner: the single most reassuring thing an admin wants to
|
|
see - is there a recent, verified backup? Everything else is secondary. -->
|
|
<div v-if="isConfigured" 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)
|
|
// Assume configured until the list says otherwise, so a slow first load
|
|
// does not flash the not-configured notice on a working install.
|
|
const isConfigured = ref(true)
|
|
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
|
|
// A deployment without the backup overlay has no store behind any of
|
|
// this. Say so instead of showing 'No verified backup yet', which reads
|
|
// as a backup that failed rather than a feature that is not installed.
|
|
isConfigured.value = response.data.meta?.configured !== false
|
|
} 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; }
|
|
|
|
.not-configured {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
padding: 1rem 1.25rem;
|
|
margin-bottom: 1rem;
|
|
border: 1px solid var(--border-color, #d8dbe0);
|
|
border-radius: 8px;
|
|
background: var(--surface-muted, #f6f7f9);
|
|
}
|
|
|
|
.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>
|