Files
shopdb-flask/plugins/backups/frontend/views/BackupHistory.vue

340 lines
12 KiB
Vue

<template>
<div class="bh">
<div class="bh-head">
<!-- back(), not a route: this page is reached from an asset panel, and
each asset type has its own detail route keyed by its OWN id
(/machines/:machineid, not the assetid we are given). There is no
generic /assets/:id to link to. -->
<button type="button" class="bh-back" @click="goback">&larr; Back</button>
<h1>Configuration Backups</h1>
<p v-if="assetname" class="bh-sub">{{ assetname }}</p>
</div>
<div v-if="loading" class="bh-note">Loading...</div>
<div v-else-if="error" class="bh-error">{{ error }}</div>
<div v-else-if="!revisions.length" class="bh-note">
No backups on record for this asset yet.
</div>
<table v-else class="bh-table">
<thead>
<tr>
<th>Captured</th>
<th>Last verified</th>
<th>Kind</th>
<th>From PC</th>
<th>Hash</th>
<th>Changes</th>
<th class="bh-actions-col">Download</th>
</tr>
</thead>
<tbody>
<tr v-for="rev in revisions" :key="rev.backuprevisionid"
:class="{ 'bh-latest': rev.islatest }">
<td>
{{ formatWhen(rev.collectedat || rev.createdat) }}
<span v-if="rev.islatest" class="bh-badge">current</span>
</td>
<!-- CAPTURED is when this configuration was written; LAST VERIFIED is
when a PC last posted it and the hash still matched. They differ
by design: an unchanged config writes no new revision, so a
machine can be checked daily for a year and still show one
revision from last spring. Without this column that machine
looked abandoned. Only the current revision is verified - an
older one was superseded, so it is not what the PC holds now. -->
<td>
<template v-if="rev.islatest && rev.lastseenat">
{{ formatWhen(rev.lastseenat) }}
</template>
<span v-else-if="rev.islatest" class="bh-muted"
title="No check recorded since this was introduced. The next
collection will record one.">not yet checked</span>
<span v-else class="bh-muted">-</span>
</td>
<td>{{ rev.backupkind }}</td>
<td>{{ rev.sourcehostname || '-' }}</td>
<td class="bh-mono">{{ rev.shorthash }}</td>
<td>
<button v-if="rev.storagebackend === 'shopdb'"
class="bh-link" @click="loadDiff(rev)">
{{ diffs[rev.backuprevisionid] ? 'hide' : 'compare' }}
</button>
<span v-else class="bh-muted">-</span>
</td>
<td class="bh-actions">
<!-- Share-backed revisions are not streamed by ShopDB; show the
UNC path so the tech can open it directly. -->
<template v-if="rev.storagebackend === 'share'">
<code class="bh-path" :title="rev.sharepath">{{ rev.sharepath }}</code>
<!--
A copy button rather than a file:// link. Browsers block
navigation from an https page to file://, so a link would look
clickable and silently do nothing - worse than no link. Copy,
then paste into Explorer's address bar.
-->
<button type="button" class="bh-copy" @click="copyPath(rev.sharepath)"
:title="'Copy path for Explorer'">
{{ copiedPath === rev.sharepath ? 'Copied' : 'Copy path' }}
</button>
</template>
<template v-else>
<button v-for="fmt in rev.formats" :key="fmt.id"
class="bh-btn" :title="fmt.hint"
@click="download(rev, fmt)">
{{ fmt.label }}
</button>
</template>
</td>
</tr>
</tbody>
</table>
<div v-for="rev in revisions" :key="`d-${rev.backuprevisionid}`">
<div v-if="diffs[rev.backuprevisionid]" class="bh-diff">
<h3>
Changes in revision {{ rev.backuprevisionid }}
<span v-if="diffs[rev.backuprevisionid].againstid">
vs {{ diffs[rev.backuprevisionid].againstid }}
</span>
</h3>
<p v-if="diffs[rev.backuprevisionid].message" class="bh-muted">
{{ diffs[rev.backuprevisionid].message }}
</p>
<table v-else-if="diffs[rev.backuprevisionid].changes.length" class="bh-table">
<thead>
<tr><th>Key</th><th>Value</th><th>Before</th><th>After</th></tr>
</thead>
<tbody>
<tr v-for="(c, i) in diffs[rev.backuprevisionid].changes" :key="i">
<td class="bh-mono">{{ c.keypath || '(root)' }}</td>
<td class="bh-mono">{{ c.valuename }}</td>
<td class="bh-before">{{ display(c.before) }}</td>
<td class="bh-after">{{ display(c.after) }}</td>
</tr>
</tbody>
</table>
<p v-else class="bh-muted">No differences.</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api, { settingsApi } from '@/api'
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
const copiedPath = ref('')
async function copyPath(path) {
if (!path) { return }
try {
await navigator.clipboard.writeText(path)
copiedPath.value = path
setTimeout(() => { copiedPath.value = '' }, 2000)
} catch (err) {
// Clipboard needs a secure context and a user gesture. Both hold here, but
// an older browser or a http:// instance will refuse - select the text so
// the tech can copy it by hand instead of getting nothing.
const node = document.querySelector('.bh-path')
if (node && window.getSelection) {
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
}
}
}
const route = useRoute()
const router = useRouter()
const assetid = route.params.assetid
function goback() {
if (window.history.length > 1) router.back()
else router.push('/machines')
}
const revisions = ref([])
const diffs = ref({})
const assetname = ref('')
const loading = ref(true)
const error = ref('')
// Timestamps are stored UTC and must show in the SITE's zone, not the viewer's,
// so a remote admin and someone at the bay read the same wall clock.
const siteTimezone = ref(DEFAULT_TZ)
function formatWhen(value) {
if (!value) return 'unknown'
return formatInZone(value, siteTimezone.value, { second: '2-digit' }) || value
}
function display(value) {
if (value === null || value === undefined) return '(absent)'
if (value === '') return '(empty)'
return String(value)
}
async function loadTimezone() {
try {
const response = await settingsApi.get('site_timezone')
const value = response?.data?.data?.value
if (value) siteTimezone.value = value
} catch (e) {
// Keep the default zone; a missing setting must not blank the page.
}
}
async function load() {
loading.value = true
error.value = ''
try {
const response = await api.get(`/backups/asset/${assetid}`)
revisions.value = response.data.data || []
} catch (e) {
error.value = e.response?.data?.error?.message || 'Failed to load backups.'
} finally {
loading.value = false
}
try {
const asset = await api.get(`/assets/${assetid}`)
assetname.value = asset.data.data?.name || ''
} catch (e) {
// Name is decoration; the history is still usable without it.
}
}
async function loadDiff(rev) {
if (diffs.value[rev.backuprevisionid]) {
delete diffs.value[rev.backuprevisionid]
diffs.value = { ...diffs.value }
return
}
try {
const response = await api.get(
`/backups/revisions/${rev.backuprevisionid}/diff`)
diffs.value = { ...diffs.value, [rev.backuprevisionid]: response.data.data }
} catch (e) {
diffs.value = {
...diffs.value,
[rev.backuprevisionid]: {
changes: [],
message: e.response?.data?.error?.message || 'Could not build a diff.'
}
}
}
}
async function download(rev, fmt) {
// responseType blob so the UTF-16LE .reg is not mangled by JSON parsing;
// the Content-Disposition filename is not readable cross-origin, so rebuild
// it here to match what the server sends.
const response = await api.get(
`/backups/revisions/${rev.backuprevisionid}/download?format=${fmt.id}`,
{ responseType: 'blob' })
// Name for the MACHINE: 3204.reg, matching both the server's
// Content-Disposition and how the per-machine backups on the share are named.
const suffix = fmt.id === 'wow6432node' ? '-wow6432node' : ''
const stem = rev.assetnumber || rev.backupkind
const name = `${stem}${suffix}${fmt.ext}`
const link = document.createElement('a')
link.href = URL.createObjectURL(response.data)
link.download = name
link.click()
URL.revokeObjectURL(link.href)
}
onMounted(async () => {
await loadTimezone()
await load()
})
</script>
<style scoped>
/* Themed throughout: this page renders in light and dark, so every colour is a
variable from the app palette. Hardcoded hex here showed up as a white table
on a dark page. */
.bh { padding: 1.5rem; color: var(--text); }
.bh-head { margin-bottom: 1rem; }
.bh-head h1 { margin: 0.3rem 0 0; }
.bh-back {
font-size: 0.85rem; background: none; border: none; padding: 0;
color: var(--link); cursor: pointer;
}
.bh-back:hover { text-decoration: underline; }
.bh-sub { color: var(--text-light); margin: 0.2rem 0 0; }
.bh-note, .bh-muted { color: var(--text-light); }
.bh-error { color: var(--danger); }
.bh-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.bh-table th, .bh-table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.bh-table th {
color: var(--text-light);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.bh-table tr:last-child td { border-bottom: none; }
/* The current revision is the one a tech restores from, so it is tinted rather
than merely labelled. color-mix keeps that tint correct in both themes
instead of baking in a light-blue that vanishes on a dark background. */
.bh-latest td { background: color-mix(in srgb, var(--primary) 10%, transparent); }
.bh-badge {
font-size: 0.68rem; background: var(--primary); color: #fff;
border-radius: 999px; padding: 0.1rem 0.5rem; margin-left: 0.4rem;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700;
}
.bh-mono, .bh-path {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.bh-path { word-break: break-all; color: var(--text-light); }
/* Deliberately quiet: the path is the content, this is just the way to get it
into Explorer. Sits inline after the path rather than in the actions column,
which belongs to the revision itself. */
.bh-copy {
font-size: 0.72rem; margin-left: 0.4rem; padding: 0.12rem 0.4rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer;
white-space: nowrap; vertical-align: middle;
}
.bh-copy:hover { border-color: var(--primary); color: var(--primary); }
.bh-actions-col { width: 22rem; }
.bh-btn {
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.28rem 0.6rem;
border: 1px solid var(--primary); background: transparent;
color: var(--primary); border-radius: 4px; cursor: pointer;
}
.bh-btn:hover { background: var(--primary); color: #fff; }
.bh-link {
background: none; border: none; color: var(--link); cursor: pointer;
padding: 0; font-size: 0.82rem; text-decoration: underline;
}
.bh-diff { margin: 0 0 1.5rem; }
.bh-diff h3 { font-size: 0.95rem; margin-bottom: 0.4rem; }
/* Named AND coloured: a red/green pair alone does not survive a colourblind
reader, so the before/after columns are headed as well as tinted. */
.bh-before { color: var(--danger); }
.bh-after { color: var(--success-dark, var(--success)); }
</style>