177 lines
7.8 KiB
Python
177 lines
7.8 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,
|
|
**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)})
|
|
|
|
# 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}
|