Endpoint telemetry, PC Detail rework and a checked backup/restore path

This commit is contained in:
2026-09-06 18:53:11 -04:00
parent 32b8745d10
commit 993353dfc0
97 changed files with 13340 additions and 756 deletions

View File

@@ -0,0 +1,891 @@
<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>

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -113,6 +113,7 @@ export const settingsGroups = [
title: 'System',
cards: [
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
],
},
{