lastseenat already recorded it and the API already returned it; nothing displayed it, so from the UI a healthy machine still looked abandoned - one revision from last spring and no sign anything had looked at it since. The history page gains a Last verified column beside Captured, and the asset panel a Verified field. Only the CURRENT revision carries one: an older revision was superseded, so saying it was verified today would be false - what was verified is the configuration the PC holds now. A current revision with no check yet says "not yet checked" rather than showing a blank or borrowing the captured date. That state is real and temporary: the column is new, so every chain reports it until its PC next posts.
297 lines
10 KiB
Vue
297 lines
10 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>
|
|
</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 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); }
|
|
.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>
|