Give the backups plugin a settings page, and show where a snapshot is
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
|
||||
|
||||
@@ -1381,3 +1381,38 @@ def test_buildinfo_kwargs_do_not_disturb_the_live_ntlars_card(bk_app, bk_plugin)
|
||||
partmarkertypes=None, revisionid=revision.backuprevisionid,
|
||||
sourcehostname=revision.sourcehostname)
|
||||
assert 'sectioncount' in card
|
||||
|
||||
|
||||
def test_the_card_shows_the_snapshot_path_it_was_actually_written_to(bk_app, bk_plugin):
|
||||
"""Plain text, and from the REVISION - not composed from the setting.
|
||||
|
||||
A snapshot written before the share layout changed still lives where it was
|
||||
put, so the stored path is the only honest answer. It is not a link: a
|
||||
browser refuses to navigate from https to file://, so a clickable path
|
||||
would look actionable and do nothing.
|
||||
"""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'c3' * 32,
|
||||
'sharepath': r'\\host\share\partmarker\3204\20260825-144500',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['A.PAT'])})
|
||||
_db.session.commit()
|
||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid,
|
||||
sharepath=revision.sharepath)
|
||||
snapshot = [f for f in card['fields'] if f['label'] == 'Snapshot']
|
||||
assert snapshot and snapshot[0]['value'] == revision.sharepath
|
||||
assert snapshot[0]['mono'] is True
|
||||
|
||||
|
||||
def test_the_card_omits_the_snapshot_row_when_no_path_is_passed(bk_app, bk_plugin):
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
card = partmarkerinfo.build(_markermanifest(['A.PAT']), 1)
|
||||
assert 'Snapshot' not in [f['label'] for f in card['fields']]
|
||||
|
||||
Reference in New Issue
Block a user