The plugin shipped five settings and no page for any of them. The core settings catalog is a hardcoded list a plugin cannot extend, so backups_shareroot and the collection interval were reachable only through the API or a flask shell on the server - which is not a configurable setting in any useful sense, and it is how today went: the setting simply was not there to set. The page is contributed the ADR-010 way rather than by editing core - a card from get_settings_cards plus a route in the plugin's own routes.js, both of which disappear when the plugin is disabled. Filed under Machines, next to Machine Types, because that is where the assets these back up live. It also states the two things the settings do not say for themselves: that health is judged on when a backup was last CONFIRMED rather than when it last changed, and that retention prunes database revisions only - files on the share stay, so enabling it on a share-backed kind leaves orphaned snapshot directories. Snapshot paths are now shown as full UNC text in both places: a Snapshot row on the Marker Contents card, and the existing path on the history page, which gains a copy button. Deliberately NOT a link - browsers refuse to navigate from an https page to file://, so a clickable path looks actionable and silently does nothing, which is worse than no link. Copy, then paste into Explorer. The path comes off the revision, never composed from the configured share root: a snapshot written before the layout changed still lives where it was put.
340 lines
12 KiB
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">← 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>
|