DNC Info becomes a single tabbed card - General, eFocas, Serial, NTSHR and MARK - instead of a flat wall of every value. On 3204 that is 11 rows visible rather than 22, and on 0600 eleven rather than 27, which also stops the card unbalancing the detail page's two-column layout. Everything DNC now lives on that one card, so the Part Marker panel is gone: its settings are the MARK tab. The partmarker KIND is untouched and still stores, dedupes and serves revisions - they are listed on the backup history page - it simply contributes no card of its own, which on 145 of 147 machines would have been an empty box. Downloads are named for the machine: 3204.reg, and 3204-wow6432node.reg for the dialect that imports outside NTLARS. The view had been rebuilding the name from sourcefilename and producing 3204.reg-wow6432node.reg, so the revision now carries assetnumber and both ends agree. That needed a viewonly relationship to Asset - no backref, so the core asset side gains no dependency on this plugin. The history page was hardcoded to light colours (#e0e0e0, #f4f9ff, #666) and rendered as a white table on a dark page. It now uses the palette variables throughout, per frontend/CLAUDE.md. The current-revision tint is a color-mix against --primary so it reads in both themes rather than a baked light blue that disappears on dark, and the diff columns are headed as well as red/green, since colour alone does not survive a colourblind reader.
132 lines
5.7 KiB
Python
132 lines
5.7 KiB
Python
"""Backup revision model.
|
|
|
|
One row per DISTINCT configuration snapshot of an asset. The collector runs
|
|
every GE-Enforce cycle across the whole fleet, so the write path dedupes on
|
|
contenthash: a row appears only when a setting actually changed. That is what
|
|
turns a high-frequency collector into a readable revision history.
|
|
|
|
Two storage backends, chosen by the kind (see services/registry.py):
|
|
|
|
'shopdb' Parsed structured config lives in payloadjson. The original file
|
|
is not kept because it re-renders exactly from the projection
|
|
(NTLARS .reg is the motivating case), which also lets one stored
|
|
revision render into more than one dialect on download.
|
|
|
|
'share' Opaque vendor formats that have no useful JSON representation
|
|
(part marker files and similar). Bytes stay on the SFLD share and
|
|
the row carries sharepath plus enough metadata to list, dedupe and
|
|
link to them. ShopDB never needs to parse these.
|
|
|
|
contenthash is sha256 over the canonical form of whatever is authoritative for
|
|
the backend: the canonical JSON for 'shopdb', the raw file bytes for 'share'.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from shopdb.api import db
|
|
|
|
|
|
class BackupRevision(db.Model):
|
|
"""A single point-in-time configuration snapshot of an asset."""
|
|
|
|
__tablename__ = 'backuprevisions'
|
|
|
|
backuprevisionid = db.Column(db.Integer, primary_key=True)
|
|
|
|
# The asset the config BELONGS to, which is not always the asset it was
|
|
# collected from. NTLARS settings live in the controlling PC's registry but
|
|
# describe the machine, so the collector reports a machine number and the
|
|
# kind resolves it to the machine's asset. sourcehostname records the PC it
|
|
# actually came off.
|
|
assetid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Read-only view of the owning asset, so a revision can name its own
|
|
# download <machinenumber>.reg. No cascade or backref: the asset side must
|
|
# not gain a dependency on this plugin (ADR-014 lean builds).
|
|
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
|
|
|
backupkind = db.Column(db.String(50), nullable=False, index=True)
|
|
storagebackend = db.Column(db.String(20), nullable=False, default='shopdb')
|
|
|
|
# sha256 of the canonical authoritative form. Dedup key together with
|
|
# (assetid, backupkind).
|
|
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
|
|
|
# Populated for storagebackend='shopdb' only. 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
|
|
# column would renormalize it and make diffs between revisions unstable.
|
|
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
|
|
|
|
# Populated for storagebackend='share' only. Full UNC path.
|
|
sharepath = db.Column(db.String(500), nullable=True)
|
|
|
|
# Original filename incl. extension. Vendor tools reject a renamed file, so
|
|
# downloads hand back exactly this name.
|
|
sourcefilename = db.Column(db.String(255), nullable=True)
|
|
bytesize = db.Column(db.Integer, nullable=True)
|
|
|
|
# Which PC produced it, and when it was read off that PC (not when ShopDB
|
|
# stored it - a share-drop fallback can arrive much later).
|
|
sourcehostname = db.Column(db.String(255), nullable=True)
|
|
collectedat = db.Column(db.DateTime, nullable=True)
|
|
|
|
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
|
|
|
__table_args__ = (
|
|
db.Index('ixbackuprevisionsassetkind', 'assetid', 'backupkind'),
|
|
)
|
|
|
|
@property
|
|
def payload(self):
|
|
"""Decoded payloadjson, or None for opaque (share-backed) kinds."""
|
|
if not self.payloadjson:
|
|
return None
|
|
try:
|
|
return json.loads(self.payloadjson)
|
|
except (ValueError, TypeError) as exc:
|
|
# Do NOT return None here. A corrupt row would then look identical
|
|
# to an opaque share-backed revision, and the download route would
|
|
# fail somewhere further along with an unrelated error. Name the
|
|
# actual problem and the row it is in.
|
|
raise ValueError(
|
|
'backuprevision {} has unreadable payloadjson: {}'.format(
|
|
self.backuprevisionid, exc))
|
|
|
|
@payload.setter
|
|
def payload(self, value):
|
|
if value is None:
|
|
self.payloadjson = None
|
|
else:
|
|
self.payloadjson = json.dumps(value, sort_keys=True,
|
|
separators=(',', ':'))
|
|
|
|
def to_dict(self, includepayload=False):
|
|
data = {
|
|
'backuprevisionid': self.backuprevisionid,
|
|
'assetid': self.assetid,
|
|
'backupkind': self.backupkind,
|
|
'storagebackend': self.storagebackend,
|
|
'contenthash': self.contenthash,
|
|
'shorthash': (self.contenthash or '')[:12],
|
|
'sharepath': self.sharepath,
|
|
'sourcefilename': self.sourcefilename,
|
|
'bytesize': self.bytesize,
|
|
# The machine number. Carried on the revision so the UI can name a
|
|
# download <machinenumber>.reg without a second round trip.
|
|
'assetnumber': self.asset.assetnumber if self.asset else None,
|
|
'sourcehostname': self.sourcehostname,
|
|
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
|
'createdat': self.createdat.isoformat() if self.createdat else None,
|
|
}
|
|
if includepayload:
|
|
data['payloadjson'] = self.payload
|
|
return data
|