Give the backups plugin a settings page, and show where a snapshot is

This commit is contained in:
2026-08-25 14:53:13 -04:00
parent fa856c1f94
commit 8e10584c4d
8 changed files with 287 additions and 3 deletions

View File

@@ -66,6 +66,16 @@
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"
@@ -115,6 +125,29 @@ 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
@@ -274,6 +307,16 @@ onMounted(async () => {
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 {

View File

@@ -0,0 +1,157 @@
<template>
<div>
<div class="page-header">
<h2>Backups</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Collection</h3>
<p class="setting-description">
How often a PC attempts to capture the config it backs up. GE-Enforce runs every
cycle, so this is what stops a bay collecting several times a day. A capture that
finds nothing changed writes no revision - it only records that the check happened.
</p>
<div class="setting-row">
<label>
<span>Interval (hours)</span>
<input type="number" min="1" v-model="values.backups_intervalhours"
@blur="save('backups_intervalhours')" :disabled="saving">
<small class="input-hint">Default 24. Read by the fleet from /api/settings/public.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Share root (UNC)</span>
<input type="text" v-model="values.backups_shareroot"
placeholder="\\fileserver\shopfloor\backup"
@blur="save('backups_shareroot')" :disabled="saving">
<small class="input-hint">
Where opaque backups (part-marker snapshots) are written by the collecting PC.
Blank until the site sets it - no default is right at more than one site.
Advisory: each revision records the path it was actually written to.
</small>
</label>
</div>
</div>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Health</h3>
<p class="setting-description">
A backup is judged on when it was last CONFIRMED, not when it last changed. An
unchanged config writes no revision, so a machine stable for months is healthy -
the one to worry about is the machine whose backup stopped running.
</p>
<div class="setting-row">
<label>
<span>Stale after (days)</span>
<input type="number" min="0" v-model="values.backups_staledays"
@blur="save('backups_staledays')" :disabled="saving">
<small class="input-hint">
Default 3. Drives the fleet Backup badge and the dashboard card. 0 disables both.
</small>
</label>
</div>
</div>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Retention</h3>
<p class="setting-description">
Both off by default, and dedup already keeps growth low - a revision appears only
when a config really changed. Retention exists for a value that flaps. The newest
and oldest revision of a chain are never pruned.
</p>
<p class="setting-description">
<strong>Note:</strong> pruning removes database revisions only. Files written to the
share are not deleted, so enabling this on a share-backed kind leaves snapshot
directories with no revision pointing at them.
</p>
<div class="setting-row">
<label>
<span>Keep at most (revisions per chain)</span>
<input type="number" min="0" v-model="values.backups_retentioncount"
@blur="save('backups_retentioncount')" :disabled="saving">
<small class="input-hint">0 = keep everything.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Also drop older than (days)</span>
<input type="number" min="0" v-model="values.backups_retentiondays"
@blur="save('backups_retentiondays')" :disabled="saving">
<small class="input-hint">0 = no age limit.</small>
</label>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
// The backups plugin ships five settings and, until now, no page for any of
// them - they were reachable only through the API or a flask shell on the
// server. The core settings catalog is hardcoded and cannot be extended by a
// plugin, so this page is contributed the ADR-010 way: a settings card from
// get_settings_cards plus a route here, both of which disappear when the
// plugin is disabled.
import { ref, reactive, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { apiError } from '@/utils/apiError'
const KEYS = [
'backups_intervalhours',
'backups_shareroot',
'backups_staledays',
'backups_retentioncount',
'backups_retentiondays',
]
const values = reactive({})
const saving = ref(false)
const error = ref('')
const success = ref('')
async function load() {
try {
const response = await settingsApi.list()
const rows = response.data?.data || response.data || []
// The list endpoint returns every setting; pick out ours rather than
// fetching five times.
for (const key of KEYS) {
const row = rows.find(item => item.key === key)
values[key] = row ? row.value : ''
}
} catch (err) {
error.value = apiError(err, 'Could not load backup settings')
}
}
async function save(key) {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, values[key])
success.value = 'Saved'
setTimeout(() => { success.value = '' }, 2000)
} catch (err) {
error.value = apiError(err, `Could not save ${key}`)
} finally {
saving.value = false
}
}
onMounted(load)
</script>