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.
185 lines
8.3 KiB
Python
185 lines
8.3 KiB
Python
"""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}
|