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
|
# 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
|
# same value. Passed from the column rather than read out of the stored
|
||||||
# manifest, which carries its own copy that can disagree.
|
# 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['backuprevisionid'] = revision.backuprevisionid
|
||||||
data['collectedat'] = _utciso(revision.collectedat)
|
data['collectedat'] = _utciso(revision.collectedat)
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
* JWT + a backups.* permission; an anonymous visit would only render errors.
|
* JWT + a backups.* permission; an anonymous visit would only render errors.
|
||||||
*/
|
*/
|
||||||
export default [
|
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',
|
path: 'backups/asset/:assetid',
|
||||||
name: 'backups-asset-history',
|
name: 'backups-asset-history',
|
||||||
|
|||||||
@@ -66,6 +66,16 @@
|
|||||||
UNC path so the tech can open it directly. -->
|
UNC path so the tech can open it directly. -->
|
||||||
<template v-if="rev.storagebackend === 'share'">
|
<template v-if="rev.storagebackend === 'share'">
|
||||||
<code class="bh-path" :title="rev.sharepath">{{ rev.sharepath }}</code>
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button v-for="fmt in rev.formats" :key="fmt.id"
|
<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 api, { settingsApi } from '@/api'
|
||||||
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const assetid = route.params.assetid
|
const assetid = route.params.assetid
|
||||||
@@ -274,6 +307,16 @@ onMounted(async () => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.bh-path { word-break: break-all; color: var(--text-light); }
|
.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-actions-col { width: 22rem; }
|
||||||
|
|
||||||
.bh-btn {
|
.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)
|
panels.append(panel)
|
||||||
return panels
|
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]:
|
def get_settings_defaults(self) -> List[Dict]:
|
||||||
"""Schedule + retention, read by the collector and the prune job.
|
"""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,
|
def build(projection, assetid, revisionid=None, sourcehostname=None,
|
||||||
**options):
|
sharepath=None, **options):
|
||||||
"""Build the card from a stored partmarker manifest.
|
"""Build the card from a stored partmarker manifest.
|
||||||
|
|
||||||
Returns the keyvalue shape. An older revision predating manifests has no
|
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:
|
if systems:
|
||||||
fields.append({'label': 'Authored for', 'value': ', '.join(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
|
# 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
|
# 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
|
# collector recorded and what the diff above is chained on, so naming a
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ class PartMarkerKind(BackupKind):
|
|||||||
return partmarkerinfo.build(
|
return partmarkerinfo.build(
|
||||||
projection, assetid,
|
projection, assetid,
|
||||||
revisionid=options.get('revisionid'),
|
revisionid=options.get('revisionid'),
|
||||||
sourcehostname=options.get('sourcehostname'))
|
sourcehostname=options.get('sourcehostname'),
|
||||||
|
sharepath=options.get('sharepath'))
|
||||||
|
|
||||||
def resolveassetid(self, payload):
|
def resolveassetid(self, payload):
|
||||||
from shopdb.api import db, Asset
|
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,
|
partmarkertypes=None, revisionid=revision.backuprevisionid,
|
||||||
sourcehostname=revision.sourcehostname)
|
sourcehostname=revision.sourcehostname)
|
||||||
assert 'sectioncount' in card
|
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