DNC Info becomes a single tabbed card - General, eFocas, Serial, NTSHR and MARK - instead of a flat wall of every value. On 3204 that is 11 rows visible rather than 22, and on 0600 eleven rather than 27, which also stops the card unbalancing the detail page's two-column layout. Everything DNC now lives on that one card, so the Part Marker panel is gone: its settings are the MARK tab. The partmarker KIND is untouched and still stores, dedupes and serves revisions - they are listed on the backup history page - it simply contributes no card of its own, which on 145 of 147 machines would have been an empty box. Downloads are named for the machine: 3204.reg, and 3204-wow6432node.reg for the dialect that imports outside NTLARS. The view had been rebuilding the name from sourcefilename and producing 3204.reg-wow6432node.reg, so the revision now carries assetnumber and both ends agree. That needed a viewonly relationship to Asset - no backref, so the core asset side gains no dependency on this plugin. The history page was hardcoded to light colours (#e0e0e0, #f4f9ff, #666) and rendered as a white table on a dark page. It now uses the palette variables throughout, per frontend/CLAUDE.md. The current-revision tint is a color-mix against --primary so it reads in both themes rather than a baked light blue that disappears on dark, and the diff columns are headed as well as red/green, since colour alone does not survive a colourblind reader.
264 lines
8.8 KiB
Vue
264 lines
8.8 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>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>
|
|
<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 from '@/api'
|
|
|
|
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('')
|
|
|
|
function formatWhen(value) {
|
|
if (!value) return 'unknown'
|
|
const d = new Date(value)
|
|
return isNaN(d) ? value : d.toLocaleString()
|
|
}
|
|
|
|
function display(value) {
|
|
if (value === null || value === undefined) return '(absent)'
|
|
if (value === '') return '(empty)'
|
|
return String(value)
|
|
}
|
|
|
|
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(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>
|