Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	shopdb/data/catalog.json
This commit is contained in:
benjamin.baker
2026-08-26 12:53:03 -04:00
21 changed files with 1675 additions and 20 deletions

View File

@@ -156,9 +156,34 @@ def asset_info(assetid):
if revision is None:
return success_response({'fields': [], 'sectioncount': 0})
try:
projection = revision.payload
except ValueError:
# payload deliberately raises on unreadable payloadjson so a corrupt row
# is named rather than mistaken for an opaque one. That is right for a
# download; for an at-a-glance card it would take the whole asset page
# down over one bad row, so the card degrades to empty instead.
current_app.logger.warning(
'backuprevision %s has unreadable payloadjson; info card empty',
revision.backuprevisionid)
return success_response({'fields': [], 'sectioncount': 0})
data = kind.buildinfo(
revision.payload, assetid,
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
projection, assetid,
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'),
# A kind that diffs against its own previous revision needs to know
# which revision it is looking at. Passed generically rather than
# queried inside the kind, so the endpoint stays the single place that
# decides "latest".
revisionid=revision.backuprevisionid,
# 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,
# 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)

View File

@@ -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',

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>

View File

@@ -67,7 +67,10 @@ class BackupRevision(db.Model):
# (assetid, backupkind).
contenthash = db.Column(db.String(64), nullable=False, index=True)
# Populated for storagebackend='shopdb' only. MEDIUMTEXT holding serialized
# Populated for storagebackend='shopdb', and optionally for 'share' kinds
# that send a manifest describing what their snapshot contains (the bytes
# still live on the share; this is the index, not the content).
# MEDIUMTEXT holding serialized
# JSON rather than a native JSON column, matching geenforce.manifestjson.
# Text also means the bytes come back exactly as written, so the canonical
# key ordering the codec produces survives the round trip - a native JSON
@@ -105,7 +108,18 @@ class BackupRevision(db.Model):
@property
def payload(self):
"""Decoded payloadjson, or None for opaque (share-backed) kinds."""
"""Decoded payloadjson, or None when the row stores nothing.
A 'shopdb' kind always has one: the parsed projection ShopDB is
authoritative for. A 'share' kind has one only when its client sends a
manifest describing the snapshot's contents, and None otherwise - which
includes every share revision written before manifests existed.
Note the manifest is NOT covered by contenthash: the hash attests the
bytes on the share, so two posts with identical content but different
manifests dedup to one row. apply_collector_payload backfills a missing
manifest on a no-op for that reason.
"""
if not self.payloadjson:
return None
try:

View File

@@ -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.
@@ -263,6 +288,15 @@ class BackupsPlugin(BasePlugin):
'type': 'string',
'description': "Full UNC path. Required for 'share' kinds.",
},
'manifest': {
'type': 'object',
'description': "Optional description of a 'share' kind's "
"contents - what is in the snapshot, not "
"the bytes themselves. Lets an asset panel "
"list a snapshot and diff it against the "
"previous one without the app server "
"mounting the share.",
},
'sourcefilename': {'type': 'string'},
'sourcehostname': {
'type': 'string',
@@ -325,6 +359,42 @@ class BackupsPlugin(BasePlugin):
"contenthash and sharepath are required for kind '{}'".format(
kindkey))
# A share kind may also describe itself. The bytes stay on the
# share - that part is unchanged - but a manifest of what the
# snapshot CONTAINS is small, structured, and the only way an asset
# panel can list a part marker's patterns or say which one
# disappeared last month. The alternative is the app server
# mounting SFLD, which the read routes deliberately refuse to do.
#
# It is advisory: dedup still keys on contenthash, so a client that
# sends no manifest behaves exactly as before.
manifest = payload.get('manifest')
if manifest is not None:
# Validated at the door, not at render time. A shallow check
# here would let a malformed manifest become the LATEST
# revision, and every view of the asset panel would then fail
# until the content next changed - a client bug turning into a
# broken page that outlives it.
if not isinstance(manifest, dict):
raise ValueError('manifest must be an object')
files = manifest.get('files')
if files is not None:
if not isinstance(files, list):
raise ValueError('manifest files must be a list')
for entry in files:
if not isinstance(entry, dict):
raise ValueError(
'each manifest file must be an object')
if not isinstance(entry.get('name'), str) or not entry['name']:
raise ValueError(
'each manifest file needs a name')
size = entry.get('bytes')
if size is not None and not isinstance(size, int):
raise ValueError(
'manifest file {!r} has a non-integer size'.format(
entry['name']))
projection = manifest
# Dedup is against the LATEST row of THIS SOURCE's chain, not the
# asset's. Several PCs legitimately share one machine number here - the
# part markers on 0613, 0615 and WJPRT do, and their configs genuinely
@@ -356,6 +426,26 @@ class BackupsPlugin(BasePlugin):
# backup that died six months ago. Cheap: one column on a row that
# already exists, no new history.
latest.lastseenat = datetime.utcnow()
# BACKFILL, on a no-op. The contenthash attests the BYTES, not the
# manifest, so a client that starts describing itself posts the same
# hash as before and would otherwise no-op forever - leaving the
# index unstored on exactly the markers that are healthy and
# unchanging, which is most of them. A revision that already has a
# manifest is left alone; this only fills a hole.
#
# Honest by construction: the hash proves the content is identical,
# so indexing it now describes the same snapshot. lastseenat
# semantics are untouched.
if projection is not None and latest.payload is None:
latest.payload = projection
# Same reasoning for the pointer: a snapshot that moved on the share
# (a changed layout, a renamed asset directory) reports a new
# sharepath with unchanged bytes, and the stored path would
# otherwise go on pointing at a directory nobody writes to.
if sharepath and latest.sharepath != sharepath:
latest.sharepath = sharepath
db.session.flush()
return {
'action': 'noop',

View File

@@ -0,0 +1,184 @@
"""Marker Contents card.
What a part marker holds right now, on the marker's own asset page, plus what
changed since the previous snapshot.
WHY THIS EXISTS AT ALL
A 'share' kind keeps its bytes on SFLD and only a UNC pointer in ShopDB, so
the obvious way to list a snapshot's contents - read the directory - is
exactly what the read routes refuse to do: mounting the share in the app
server turns a permissions slip into an unexplained empty panel. Instead the
collector sends a MANIFEST alongside the hash, and this builds the card from
that. Small, structured, and it works from a server that cannot see the
share at all.
WHAT IT ANSWERS
"What patterns are on this marker" - the question a tech arrives with, and
today the only way to answer it is to walk to the bay and read the panel.
"What changed" - the added/removed diff against the previous snapshot. This
is the reason the snapshot layout exists: a pattern deleted from a
controller is invisible everywhere else until someone needs it. Here it is
dated, and the snapshot that still holds it is one row above in the history.
The manifest is written by Backup-PartMarker.ps1 and carries, per file: name,
bytes, sha256, and the marking system it was authored for.
"""
def _previousmanifest(assetid, currentid):
"""The manifest of the snapshot before this one, or None.
Scoped to the same source host, matching how dedup chains revisions: two
PCs reporting the same marker keep separate histories, and diffing across
them would report every file as added and removed at once.
The host comes from the REVISION's own column, not from a field inside the
manifest. Those can disagree - the column is what the collector recorded and
what retention chains on - and using the manifest's copy would silently diff
against the wrong chain, or against nothing at all.
"""
from shopdb.api import db
from ..models import BackupRevision
from .retention import samesource
current = db.session.get(BackupRevision, currentid)
if current is None:
return None, None
sourcehostname = current.sourcehostname
row = (db.session.query(BackupRevision)
.filter(BackupRevision.assetid == assetid,
BackupRevision.backupkind == 'partmarker',
BackupRevision.backuprevisionid < currentid,
samesource(sourcehostname))
.order_by(BackupRevision.backuprevisionid.desc())
.first())
if row is None:
return None, None
when = row.collectedat or row.createdat
try:
return row.payload, when
except ValueError:
# A corrupt neighbour must not take the card down - the current
# snapshot is still worth showing without its diff.
return None, None
def _hashes(manifest):
"""name -> sha256, for spotting a file edited in place."""
return {entry.get('name'): entry.get('sha256')
for entry in (manifest or {}).get('files', [])
if entry.get('name') and entry.get('sha256')}
def _names(manifest):
return {entry.get('name') for entry in (manifest or {}).get('files', [])
if entry.get('name')}
def _summarise(files):
"""Counts by extension, so 34 patterns and 12 settings read as two facts."""
groups = {}
for entry in files:
name = entry.get('name') or ''
ext = name.rsplit('.', 1)[-1].upper() if '.' in name else 'OTHER'
groups[ext] = groups.get(ext, 0) + 1
labels = {'PAT': 'Patterns', 'DAT': 'Settings files', 'TFF': 'Fonts'}
return [(labels.get(ext, ext), count) for ext, count in sorted(groups.items())]
def build(projection, assetid, revisionid=None, sourcehostname=None,
sharepath=None, **options):
"""Build the card from a stored partmarker manifest.
Returns the keyvalue shape. An older revision predating manifests has no
projection at all, which renders as no card rather than an error - the
revision itself is still listed in the history panel.
"""
# "No manifest" and "a manifest listing nothing" are different facts. The
# first is an older revision predating manifests, which has no card. The
# second is a controller that has been wiped or replaced - precisely the
# disaster the Removed line exists to report - so it must render, with the
# diff intact.
if not projection:
return {'fields': [], 'sectioncount': 0}
manifest = projection
files = manifest.get('files') or []
fields = []
for label, count in _summarise(files):
fields.append({'label': label, 'value': str(count)})
total = sum(int(entry.get('bytes') or 0) for entry in files)
fields.append({'label': 'Total size', 'value': '{:,} bytes'.format(total)})
if manifest.get('family'):
fields.append({'label': 'Controller', 'value': manifest['family']})
# The heads patterns were authored for. Worth showing because they can
# DIFFER from the controller holding them - a TMC420 in the reference site
# holds both a TMP3200/470 and a TMP4210/420 pattern - and pushing a pattern
# back onto a system with a different head is a mistake worth catching
# before the restore, not after.
systems = manifest.get('systems') or []
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
# different PC here would contradict the comparison shown beside it.
capturedfrom = sourcehostname or manifest.get('sourcehost')
if capturedfrom:
fields.append({'label': 'Captured from', 'value': capturedfrom})
# The diff. Only meaningful with a predecessor: on a marker's first snapshot
# every file would otherwise read as "added", which says nothing.
if revisionid:
previous, when = _previousmanifest(assetid, revisionid)
if previous is not None:
before, now = _names(previous), _names(manifest)
added = sorted(now - before)
removed = sorted(before - now)
# Same name, different bytes. A revision exists only because the
# content hash moved, so if the file SET is identical then something
# was edited in place - and saying "no files added or removed" would
# be a flat contradiction of why this row exists. The per-file
# sha256 is in the manifest, so the edited files can be named.
beforehashes = _hashes(previous)
changed = sorted(name for name, digest in _hashes(manifest).items()
if name in beforehashes and beforehashes[name] != digest)
# Date the window. Retention prunes middle revisions, so "previous"
# can be months older than the row before it in the history, and an
# undated diff would quietly attribute an old removal to this
# capture.
label = 'Since {}'.format(when.strftime('%Y-%m-%d')) if when else 'Since previous'
if added:
fields.append({'label': 'Added', 'value': ', '.join(added)})
if removed:
# The one the whole design exists for. Named, not counted -
# "1 removed" would send someone to the share to find out which.
fields.append({'label': 'Removed', 'value': ', '.join(removed)})
if changed:
fields.append({'label': 'Changed', 'value': ', '.join(changed)})
if not (added or removed or changed):
fields.append({'label': label, 'value': 'No files added, removed or changed'})
else:
fields.append({'label': 'Compared with', 'value': label.replace('Since ', '')})
return {'fields': fields, 'sectioncount': 1}

View File

@@ -279,11 +279,61 @@ class PartMarkerKind(BackupKind):
key = 'partmarker'
displayname = 'Part Marker Configuration'
storagebackend = 'share'
# NO asset panel. The marker's DNC settings are a tab on the single DNC Info
# card, and a separate card that is empty on 145 of 147 machines earns
# nobody anything. The kind is still fully live - it stores, dedupes and
# serves revisions, which are listed on the backup history page.
assettypes = []
# Marker assets are core type 'machine', like the other 144 machines - but
# only a marker ever has a partmarker revision, and the list renderer hides
# a panel with no rows and no empty text. So declaring 'machine' shows the
# panel on markers and nowhere else, with no special-casing.
#
# This was deliberately EMPTY while backups were filed against the operation
# number, when a partmarker card would have appeared on every machine and
# said nothing. Since a marker PC's backup is filed against its own marker
# asset (see markerforsource), the panel has a home of its own.
assettypes = ['machine']
def sharedir(self, machinetype, identifier, shareroot=None):
"""<root>\\partmarker\\<marker asset>\\ - the SITE's layout, which
inverts the base class's <root>\\<machinetype>\\<identifier>\\<kind>.
Overridden rather than left to the default so what ShopDB displays and
validates matches what the collector actually writes. Still advisory:
the authoritative path is the sharepath on each revision, which is what
keeps older snapshots resolvable after a PC swap renames the asset.
Snapshots live in timestamped subdirectories BELOW this - one per
capture, so a pattern deleted from the controller stays recoverable
from the snapshot that still holds it.
"""
root = shareroot or DEFAULTSHAREROOT
if not root:
return ''
return '{}\\{}\\{}'.format(root, self.key, identifier or 'unknown')
def infopanel(self):
"""Contents of the latest snapshot, on the marker's own asset page.
A 'share' kind stores no bytes in ShopDB, so this is built from the
manifest the collector sends alongside the hash. Without it the app
server would have to mount SFLD to say what a snapshot contains, which
the read routes refuse to do on purpose.
"""
return {
'id': 'backups-partmarker-contents',
'title': 'Marker Contents',
'assettypes': ['machine'],
'endpoint': '/api/backups/asset/{assetid}/info?kind=partmarker',
'render': 'keyvalue',
# Above the history panel: "what is on this marker" is the question
# a tech arrives with; the history is for the rarer restore.
'position': 39,
}
def buildinfo(self, projection, assetid, **options):
from . import partmarkerinfo
return partmarkerinfo.build(
projection, assetid,
revisionid=options.get('revisionid'),
sourcehostname=options.get('sourcehostname'),
sharepath=options.get('sharepath'))
def resolveassetid(self, payload):
from shopdb.api import db, Asset

View File

@@ -1079,10 +1079,64 @@ def _attach_backup_state(facts, hostnames):
rows.sort(key=lambda revision: (revision.lastseenat is not None,
revision.lastseenat or _EPOCH),
reverse=True)
# Reduce to the FRESHEST revision per (host, kind), then judge a host by its
# STALEST kind.
#
# Until part markers, a PC reported exactly one kind and "newest wins" was
# the same thing as "the truth". A marker bay reports two - ntlars for the
# machine's DNC settings and partmarker for the controller's patterns - and
# newest-wins then means the daily ntlars post keeps the badge green while
# the marker backup has been failing for a month. The failure would show
# only on that marker's own asset page, which is not where anyone looks to
# find out that something stopped.
#
# So: a host is healthy when EVERYTHING it backs up is current, and the
# badge names the kind that is not. Freshest-per-kind first, because
# lastseenat only moves on the newest revision of a chain - an older
# revision of the same kind keeps its original value and would otherwise
# read as stale for ever.
#
# KNOWN CONSEQUENCE: a device retired from a PC leaves its last revision
# behind, so that host stays stale until the revision is pruned or removed.
# That is the honest failure direction - it complains about a backup that
# genuinely is not running any more, rather than staying quiet about one.
freshest = {}
for revision in rows:
key = (revision.sourcehostname or '').lower()
if key not in facts:
continue
kindkey = (key, revision.backupkind)
if kindkey not in freshest:
# rows are already newest-confirmed-first, so the first is freshest.
freshest[kindkey] = revision
# Among kinds that can be JUDGED, the stalest decides. A kind whose newest
# revision has never been confirmed (lastseenat NULL - a backfilled row, or
# one written before the column existed) is not stale; it is unmeasurable,
# and it must not win.
#
# Treating NULL as "worst" looked right and was wrong: a host with a healthy
# ntlars backup and one never-confirmed row showed NO BADGE, because the
# verdict for a NULL confirmation is tri-state None. A working backup then
# renders as nothing at all, which is the one thing the badge must never do.
# This is the same rule staleness.py already applies: a chain becomes
# measurable the first time its PC posts, and says nothing until then.
stalest = {}
for (key, _kind), revision in freshest.items():
if revision.lastseenat is None:
continue
current = stalest.get(key)
if current is None or revision.lastseenat < current.lastseenat:
stalest[key] = revision
# Every kind unmeasurable: fall back to any revision so the host still
# reports which kind it has, with a None verdict and no badge.
for (key, _kind), revision in freshest.items():
stalest.setdefault(key, revision)
for key, revision in stalest.items():
entry = facts.get(key)
# First row per host wins.
if entry and entry['backuplastseen'] is None:
entry['backupkind'] = revision.backupkind
entry['backuplastseen'] = (

View File

@@ -0,0 +1,13 @@
{
"name": "guides",
"display_name": "Guides",
"version": "1.0.0",
"description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/guides",
"provides": {
"features": ["guides"]
}
}

View File

@@ -0,0 +1,3 @@
from .guide import Guide, GuideImage, BLOCKTYPES
__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES']

View File

@@ -0,0 +1,188 @@
"""Guides: step-by-step procedures written and read inside ShopDB.
WHY THIS IS NOT THE KNOWLEDGE BASE PLUGIN
The knowledgebase plugin is a LINK REGISTRY - a short description, a URL and
some keywords, pointing at a document that lives somewhere else. That is a
different job. This holds the document itself.
WHY BLOCKS RATHER THAN A BLOB OF HTML
A guide is stored as an ordered list of typed blocks, each with a stable id,
serialized as JSON. Free-form HTML would be easier to write and would defeat
three of the four things this feature exists for:
checklists a tickable step has to be identifiable to be remembered, and
an <input> buried in pasted markup has no identity
dark mode pasted content carries hardcoded colours, which then fight the
theme - black text on a dark background, and nobody can say
why
printing print has to know where a step begins and ends to avoid
splitting one across a page
Blocks also mean a new block type later is additive: old guides keep
rendering, because the renderer skips what it does not know.
INLINE TEXT IS A RESTRICTED MARKDOWN SUBSET - bold, italic, inline code, links,
line breaks, and nothing else. No fonts, sizes or colours: emphasis is semantic
and the theme decides how it looks. That single rule is what keeps a guide
written next year looking like one written today, and what makes dark mode work
without an author thinking about it.
"""
import json
from datetime import datetime
from shopdb.api import db
from shopdb.core.models.base import BaseModel
def _utcnow():
return datetime.utcnow()
# Block types the renderer knows. Stored as data, not as markup, so the same
# guide can render to screen, to dark mode and to paper differently.
BLOCKTYPES = (
'heading',
'paragraph',
'step', # a numbered step, with its own body
'checklist', # tickable items - the reason progress is a feature
'bullets',
'numbers',
'callout', # info / warning / success, per the KB house style
'code',
'image',
'table',
)
class Guide(BaseModel):
"""One guide. Blocks live in a JSON column rather than a child table.
A guide is edited and saved WHOLE - nobody reorders one paragraph in
isolation - so a child table would buy ordering machinery, a second
migration and N+1 reads, in exchange for nothing this feature needs. The
same reasoning the geenforce manifests and backup payloads already follow.
"""
__tablename__ = 'guides'
guideid = db.Column(db.Integer, primary_key=True)
# Human title, and the slug the URL uses. The slug is stored rather than
# derived on read so an edited title cannot silently break a link somebody
# pasted into a work order.
title = db.Column(db.String(300), nullable=False)
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
# One line, shown in the list and in search results.
summary = db.Column(db.String(1000))
# Comma-separated, matching how knowledgebase.keywords already works, so one
# search can cover both and there is one tag vocabulary rather than two.
tags = db.Column(db.String(500))
# 'draft' or 'published'. A draft is visible to people who can edit, and
# invisible to everyone else: a half-written procedure on a shop floor is
# worse than no procedure.
status = db.Column(db.String(20), nullable=False, default='draft', index=True)
# MEDIUMTEXT: serialized blocks. Text rather than a native JSON column so the
# bytes come back exactly as written and a diff between versions is stable -
# the same reason the backups plugin stores its projection as text.
blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]')
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
updatedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
onupdate=_utcnow)
publishedat = db.Column(db.DateTime)
# Bumped on every save. Cheap, and it gives a printed copy something
# honest to put in its footer: "version 7, printed 26 Aug 2026".
revision = db.Column(db.Integer, nullable=False, default=1)
@property
def blocks(self):
"""The decoded block list, or [] on a corrupt row.
Deliberately forgiving, unlike the backups payload getter: a guide that
will not render is a page a person cannot read, and an empty guide with
its title and tags intact is a better failure than an error page. The
corruption is still visible - the editor shows no blocks.
"""
if not self.blocksjson:
return []
try:
data = json.loads(self.blocksjson)
except (ValueError, TypeError):
return []
return data if isinstance(data, list) else []
@blocks.setter
def blocks(self, value):
self.blocksjson = json.dumps(value or [], separators=(',', ':'))
def taglist(self):
"""Tags as a clean list, empties dropped."""
return [t.strip() for t in (self.tags or '').split(',') if t.strip()]
def to_dict(self, includeblocks=False):
data = {
'guideid': self.guideid,
'title': self.title,
'slug': self.slug,
'summary': self.summary,
'tags': self.taglist(),
'status': self.status,
'revision': self.revision,
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
'publishedat': (self.publishedat.isoformat() + 'Z'
if self.publishedat else None),
}
if includeblocks:
data['blocks'] = self.blocks
return data
class GuideImage(BaseModel):
"""An image pasted or dropped into a guide.
Bytes live in the database, not on disk. Guides are small, the images are
screenshots rather than photographs, and a row travels with a database
backup - whereas a file under instance/ has been lost to a container rebuild
on this very deployment before. If a site ever puts thousands of these in,
the storage decision is one column and one route to revisit.
"""
__tablename__ = 'guideimages'
guideimageid = db.Column(db.Integer, primary_key=True)
# Nullable: an image is uploaded while a NEW guide is still unsaved, so it
# exists before the guide it belongs to does. The editor attaches it on
# save; anything still unattached is orphaned and prunable.
guideid = db.Column(db.Integer, db.ForeignKey('guides.guideid',
ondelete='CASCADE'), index=True)
filename = db.Column(db.String(255))
mimetype = db.Column(db.String(100), nullable=False)
bytesize = db.Column(db.Integer, nullable=False)
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
def to_dict(self):
return {
'guideimageid': self.guideimageid,
'guideid': self.guideid,
'filename': self.filename,
'mimetype': self.mimetype,
'bytesize': self.bytesize,
'url': '/api/guides/images/{}'.format(self.guideimageid),
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
}

View File

View File

@@ -0,0 +1,131 @@
"""Block validation.
The editor is the only thing that writes blocks today, so this is not defending
against a hostile author - it is defending against a BAD SAVE. A block list that
is wrong in a way nobody notices at save time becomes a guide that will not
render weeks later, when the person who wrote it has moved on and the only
evidence is a blank page.
So: validate at the door, name the block that is wrong, and refuse the save.
The alternative - storing whatever arrives and coping at render time - was tried
in the backups plugin with a manifest and produced exactly the failure this
avoids: a client bug turning into a broken page that outlived it.
WHAT IS NOT VALIDATED HERE: the inline text. It is a restricted Markdown subset
rendered through a sanitiser in the browser, which is where a renderer belongs.
Storing text verbatim keeps what the author typed recoverable even if the
rendering rules change later.
"""
import json
from ..models import BLOCKTYPES
# A guide is a procedure, not a document repository. This cap is generous for
# anything a person will read on a shop floor and small enough that a runaway
# paste cannot fill a MEDIUMTEXT column.
MAXBLOCKSBYTES = 2 * 1024 * 1024
MAXBLOCKS = 500
MAXTEXT = 20000
class BlockError(ValueError):
"""Raised with a message naming the offending block, for the API to relay."""
def _fail(index, message):
raise BlockError('block {}: {}'.format(index + 1, message))
def validate(blocks):
"""Check a block list, returning it unchanged, or raise BlockError.
Kept pure - no database, no request - so the rules are testable directly and
the same function can be reused by an importer later.
"""
if not isinstance(blocks, list):
raise BlockError('blocks must be a list')
if len(blocks) > MAXBLOCKS:
raise BlockError('a guide is limited to {} blocks'.format(MAXBLOCKS))
seen = set()
for index, block in enumerate(blocks):
if not isinstance(block, dict):
_fail(index, 'must be an object')
blockid = block.get('id')
if not isinstance(blockid, str) or not blockid.strip():
# Ids are what make a checklist item rememberable between visits.
# A block without one silently loses its tick the next time the
# guide is opened, which reads as "the checkboxes do not work".
_fail(index, 'needs a stable id')
if blockid in seen:
_fail(index, 'duplicate id {!r} - ticks would follow the wrong item'
.format(blockid))
seen.add(blockid)
blocktype = block.get('type')
if blocktype not in BLOCKTYPES:
_fail(index, 'unknown type {!r}. Known types: {}'.format(
blocktype, ', '.join(BLOCKTYPES)))
text = block.get('text')
if text is not None:
if not isinstance(text, str):
_fail(index, 'text must be a string')
if len(text) > MAXTEXT:
_fail(index, 'text is longer than {} characters'.format(MAXTEXT))
items = block.get('items')
if items is not None:
if not isinstance(items, list):
_fail(index, 'items must be a list')
for item in items:
# Checklist items carry their own id for the same reason blocks
# do: that id is the thing a tick is stored against.
if isinstance(item, dict):
if not isinstance(item.get('id'), str) or not item['id'].strip():
_fail(index, 'every item needs an id')
if not isinstance(item.get('text', ''), str):
_fail(index, 'item text must be a string')
elif not isinstance(item, str):
_fail(index, 'items must be strings or objects')
encoded = json.dumps(blocks, separators=(',', ':'))
if len(encoded.encode('utf-8')) > MAXBLOCKSBYTES:
raise BlockError('this guide is larger than {} MB'.format(
MAXBLOCKSBYTES // (1024 * 1024)))
return blocks
def plaintext(blocks):
"""Flatten a guide to searchable text.
Search should find a guide by something written halfway down it, not only by
its title - that is how people actually look for a procedure ("the one about
the drive letter"). Cheap because it runs on save, not on every query.
"""
parts = []
for block in blocks or []:
if not isinstance(block, dict):
continue
if isinstance(block.get('text'), str):
parts.append(block['text'])
for item in block.get('items') or []:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and isinstance(item.get('text'), str):
parts.append(item['text'])
for row in block.get('rows') or []:
if isinstance(row, list):
parts.extend(cell for cell in row if isinstance(cell, str))
return '\n'.join(parts)
def checklistitemcount(blocks):
"""How many tickable items a guide has, for the list and the progress bar."""
total = 0
for block in blocks or []:
if isinstance(block, dict) and block.get('type') == 'checklist':
total += len(block.get('items') or [])
return total