Give the backups plugin a settings page, and show where a snapshot is
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.
This commit is contained in:
@@ -179,7 +179,11 @@ def asset_info(assetid):
|
||||
# The card names the PC a snapshot came from, and chains its diff on the
|
||||
# same value. Passed from the column rather than read out of the stored
|
||||
# manifest, which carries its own copy that can disagree.
|
||||
sourcehostname=revision.sourcehostname)
|
||||
sourcehostname=revision.sourcehostname,
|
||||
# Where the snapshot actually is. Read off the revision rather than
|
||||
# composed from the configured share root, so a snapshot written before
|
||||
# the layout changed still points at where it was really put.
|
||||
sharepath=revision.sharepath)
|
||||
data['backuprevisionid'] = revision.backuprevisionid
|
||||
data['collectedat'] = _utciso(revision.collectedat)
|
||||
return success_response(data)
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
* JWT + a backups.* permission; an anonymous visit would only render errors.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
// The plugin's five settings had no page at all until this one: the core
|
||||
// settings catalog is a hardcoded list that a plugin cannot extend, so
|
||||
// backups_shareroot and friends were reachable only through the API or a
|
||||
// shell on the server. Paired with get_settings_cards in plugin.py, which
|
||||
// puts the card in the rail - both vanish when the plugin is disabled.
|
||||
path: 'settings/backups',
|
||||
name: 'backups-settings',
|
||||
component: () => import('./views/BackupsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'backups' }
|
||||
},
|
||||
{
|
||||
path: 'backups/asset/:assetid',
|
||||
name: 'backups-asset-history',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
plugins/backups/frontend/views/BackupsSettings.vue
Normal file
157
plugins/backups/frontend/views/BackupsSettings.vue
Normal 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>
|
||||
@@ -133,6 +133,31 @@ class BackupsPlugin(BasePlugin):
|
||||
panels.append(panel)
|
||||
return panels
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""ADR-010: contribute the Backups settings card.
|
||||
|
||||
The core settings catalog (settingsNav.js) is a hardcoded list a plugin
|
||||
cannot extend, so this plugin shipped five settings and no page for any
|
||||
of them - backups_shareroot and the collection interval were reachable
|
||||
only through the API or a shell on the server, which is not a
|
||||
configurable setting in any useful sense.
|
||||
|
||||
Filed under Machines because that is where the assets these back up
|
||||
live, and it puts the card next to Machine Types rather than in a
|
||||
platform group nobody visits.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Machines',
|
||||
'to': '/settings/backups',
|
||||
'icon': 'package',
|
||||
'title': 'Backups',
|
||||
'description': 'Collection interval, share root for part-marker '
|
||||
'snapshots, staleness threshold and revision retention',
|
||||
'position': 21,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Schedule + retention, read by the collector and the prune job.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def _summarise(files):
|
||||
|
||||
|
||||
def build(projection, assetid, revisionid=None, sourcehostname=None,
|
||||
**options):
|
||||
sharepath=None, **options):
|
||||
"""Build the card from a stored partmarker manifest.
|
||||
|
||||
Returns the keyvalue shape. An older revision predating manifests has no
|
||||
@@ -128,6 +128,14 @@ def build(projection, assetid, revisionid=None, sourcehostname=None,
|
||||
if systems:
|
||||
fields.append({'label': 'Authored for', 'value': ', '.join(systems)})
|
||||
|
||||
# The snapshot's own location, in full, as plain text. NOT a link: browsers
|
||||
# refuse to navigate from an https page to file://, so a clickable path
|
||||
# would look actionable and silently do nothing, which is worse than no
|
||||
# link at all. The history page pairs the same string with a copy button;
|
||||
# here it is what a tech reads or selects before pasting it into Explorer.
|
||||
if sharepath:
|
||||
fields.append({'label': 'Snapshot', 'value': sharepath, 'mono': True})
|
||||
|
||||
# From the revision's own column where the caller passes it: the manifest
|
||||
# carries its own copy, and the two can disagree. The column is what the
|
||||
# collector recorded and what the diff above is chained on, so naming a
|
||||
|
||||
@@ -332,7 +332,8 @@ class PartMarkerKind(BackupKind):
|
||||
return partmarkerinfo.build(
|
||||
projection, assetid,
|
||||
revisionid=options.get('revisionid'),
|
||||
sourcehostname=options.get('sourcehostname'))
|
||||
sourcehostname=options.get('sourcehostname'),
|
||||
sharepath=options.get('sharepath'))
|
||||
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
Reference in New Issue
Block a user