Files
shopdb-flask/plugins/backups/frontend/views/BackupHistory.vue
cproudlock fca775c737
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
backups plugin: per-asset config backups with revision history
Adds a kind-pluggable backups plugin. Configuration captured from a PC is
filed against the MACHINE it controls, with a revision history and download
back to the native format.

NTLARS/DNC is the first kind. Settings live in the controlling PC's registry
but describe the machine, so revisions attach to the machine's asset and carry
no foreign key to the PC: history survives a PC being replaced or deleted, and
sourcehostname records the handover.

Storage splits by kind. Parseable kinds store a dialect-neutral JSON
projection in ShopDB and re-render on download; opaque vendor formats (part
marker and similar) keep their bytes on the SFLD share with ShopDB holding
metadata and the UNC pointer.

Two .reg dialects exist in the wild: NTLARS's own Save... export omits the
WOW6432Node path segment, scripted exports include it. Parsing strips whichever
root matched, so a stored revision commits to neither and download offers both
(NTLARS Load... by default, WOW6432Node for direct reg import). Getting this
backwards is silent, so the dedup hash deliberately excludes sourcedialect and
both dialects of one config dedup to a single revision.

Dedup is load-bearing: the collector runs every GE-Enforce cycle across the
fleet, so a revision is inserted only when the content hash differs from that
asset's latest for that kind.

A freshly imaged PC opens NTLARS with a blank General tab. Recording that would
make an empty config the newest revision exactly when someone needs the last
good one, so a blank MachineNo is rejected rather than accepted as a change.
Two of the 320 known-good backups on the share already have that shape.

DNC Info card summarises the latest revision on the machine page: General
(Cnc, NcIF, HostType), eFocas, Serial, NTSHR when populated (only 18 of 147
machines), and MARK when the machine is a marker. MARK is gated on Cnc=MARKER
or the ShopDB machine type, not on the MARK key having content: MARK carries
serial defaults on 145 of 147 machines and DncPatterns reads YES on 103
including ordinary lathes, so neither identifies a marker.

The info card is owned by the kind (BackupKind.infopanel/buildinfo) and served
by a generic endpoint, so the expected successor to DNC ships its own card by
adding a class rather than changing the plugin or the panel wiring.

Also: schedule and retention settings with a prune that never drops the newest
or the oldest revision, and scripts/import_ntlars_backups.py to seed history
from the existing per-machine .reg files (144 of 147 resolve to assets).

Codec verified against all 320 real backups: round-trips clean through both
dialects. Bay-side generation verified on Windows against reg.exe export.
2026-08-07 14:24:42 -04:00

211 lines
6.9 KiB
Vue

<template>
<div class="bh">
<div class="bh-head">
<router-link :to="`/assets/${assetid}`" class="bh-back">&larr; Back to asset</router-link>
<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 } from 'vue-router'
import api from '@/api'
const route = useRoute()
const assetid = route.params.assetid
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' })
const suffix = fmt.id === 'wow6432node' ? '-wow6432node' : ''
const name = `${rev.sourcefilename || rev.backupkind}${suffix}${fmt.ext}`
.replace(/(\.reg)+$/, '.reg')
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>
.bh { padding: 1.5rem; }
.bh-head { margin-bottom: 1rem; }
.bh-back { font-size: 0.85rem; }
.bh-sub { color: #666; margin: 0.2rem 0 0; }
.bh-note, .bh-muted { color: #666; }
.bh-error { color: #c00; }
.bh-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.bh-table th, .bh-table td {
text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #e0e0e0;
vertical-align: top;
}
.bh-latest { background: #f4f9ff; }
.bh-badge {
font-size: 0.7rem; background: #0d6efd; color: #fff;
border-radius: 3px; padding: 0.05rem 0.35rem; margin-left: 0.4rem;
}
.bh-mono, .bh-path { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
.bh-path { word-break: break-all; }
.bh-actions-col { width: 22rem; }
.bh-btn {
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.25rem 0.5rem;
border: 1px solid #0d6efd; background: #fff; color: #0d6efd;
border-radius: 4px; cursor: pointer;
}
.bh-btn:hover { background: #0d6efd; color: #fff; }
.bh-link {
background: none; border: none; color: #0d6efd; cursor: pointer;
padding: 0; font-size: 0.82rem; text-decoration: underline;
}
.bh-diff { margin: 0 0 1.5rem; }
.bh-before { color: #b00; }
.bh-after { color: #070; }
</style>