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.
248 lines
9.9 KiB
Python
248 lines
9.9 KiB
Python
"""Backup kind registry.
|
|
|
|
A "kind" is one category of configuration backup (NTLARS/DNC registry, part
|
|
marker, UDC, CMM ...). Each kind declares three things the rest of the plugin
|
|
needs and nothing else:
|
|
|
|
where its bytes live 'shopdb' (parsed into payloadjson) or 'share'
|
|
(opaque vendor file left on the SFLD share)
|
|
how to resolve an asset the collector reports an identifier; the kind turns
|
|
it into the assetid the revision belongs to
|
|
how to render downloads one or more output formats
|
|
|
|
Adding a kind is a class plus one REGISTRY entry. Nothing else in the plugin
|
|
knows kind names.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from . import ntlars as ntlarscodec
|
|
|
|
# Default root of the opaque-backup tree on the SFLD share. Connected PCs write
|
|
# here directly (they need SFLD creds - a SYSTEM process hitting a UNC path
|
|
# without them gets an access-denied that Test-Path reports as "not found").
|
|
#
|
|
# This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the
|
|
# backups_shareroot setting, because a bundled plugin in a multi-site product
|
|
# must not hardcode one site's file server.
|
|
DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups'
|
|
|
|
|
|
def canonicalhash(projection):
|
|
"""sha256 over the SEMANTIC content only. Dedup key for 'shopdb' kinds.
|
|
|
|
Deliberately hashes just schema + keys, excluding sourcedialect. The same
|
|
settings exported through NTLARS's Save... button and through a scripted
|
|
reg export parse to identical keys but differing sourcedialect; hashing the
|
|
whole projection would record that as a change and produce a spurious
|
|
revision every time the collection route changed. Storage is
|
|
dialect-neutral, so the dedup key has to be too.
|
|
"""
|
|
subset = {
|
|
'schema': projection.get('schema'),
|
|
'keys': projection.get('keys'),
|
|
}
|
|
blob = json.dumps(subset, sort_keys=True, separators=(',', ':'))
|
|
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def byteshash(raw):
|
|
"""sha256 over raw bytes. Dedup key for 'share' kinds."""
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
class BackupKind:
|
|
"""Base class. Subclasses override what applies to them."""
|
|
|
|
key = None
|
|
displayname = None
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['*']
|
|
# Text shown when the kind has no revisions for an asset. None means the
|
|
# panel HIDES entirely instead (the generic list renderer shows a panel
|
|
# only when it has rows or declares empty text). Default to hiding: a kind
|
|
# applies to an asset TYPE, but whether a given asset ever has this kind of
|
|
# backup is a property of the individual machine. A part marker panel on
|
|
# every one of 144 machines is noise, not information.
|
|
emptytext = None
|
|
|
|
def parse(self, raw):
|
|
"""Opaque kinds return None; parseable kinds return the projection."""
|
|
return None
|
|
|
|
def formats(self):
|
|
"""Downloadable formats: [{'id','label','ext','mimetype'}]."""
|
|
return []
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
"""Return (bytes, extension, mimetype) for a 'shopdb' kind."""
|
|
raise NotImplementedError
|
|
|
|
def resolveassetid(self, payload):
|
|
"""Map a collector payload to the assetid the backup belongs to."""
|
|
raise NotImplementedError
|
|
|
|
def infopanel(self):
|
|
"""Optional 'at a glance' card built from this kind's LATEST revision.
|
|
|
|
Returns an ADR-010 panel dict, or None when the kind has no summary
|
|
worth surfacing. Declared by the kind rather than hardcoded in the
|
|
plugin so a successor technology (NTLARS/DNC is expected to give way to
|
|
Shopfloor Connect) ships its own card by adding a class, without
|
|
touching the plugin or the panel wiring.
|
|
"""
|
|
return None
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
"""Build this kind's info-card payload. Only called when infopanel()."""
|
|
return {'fields': [], 'sectioncount': 0}
|
|
|
|
def sharedir(self, machinetype, identifier, shareroot=None):
|
|
"""UNC directory a 'share' kind's files are expected under.
|
|
|
|
Advisory only - the authoritative path is the sharepath the collector
|
|
reports, since the PC is what actually wrote the file. This builds the
|
|
conventional location for display and for validating a reported path.
|
|
"""
|
|
return '{}\\{}\\{}\\{}'.format(
|
|
shareroot or DEFAULTSHAREROOT,
|
|
machinetype or 'unknown', identifier or 'unknown', self.key)
|
|
|
|
|
|
class NtlarsKind(BackupKind):
|
|
"""NTLARS / DNC registry settings.
|
|
|
|
Collected from the CONTROLLING PC's registry but belongs to the MACHINE:
|
|
the settings describe how to talk to that machine's controller, so they
|
|
follow the machine when a PC is swapped. resolveassetid therefore keys on
|
|
the reported machine number, not the hostname.
|
|
"""
|
|
|
|
key = 'ntlars'
|
|
displayname = 'NTLARS / DNC Settings'
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['machine']
|
|
|
|
@staticmethod
|
|
def embeddedmachineno(projection):
|
|
"""The MachineNo NTLARS itself is configured with (General tab)."""
|
|
for key in (projection or {}).get('keys', []):
|
|
if (key.get('path') or '').lower() == 'general':
|
|
entry = (key.get('values') or {}).get('MachineNo') or {}
|
|
return str(entry.get('data') or '').strip()
|
|
return ''
|
|
|
|
def parse(self, raw):
|
|
"""Parse, then refuse to record an unconfigured NTLARS install.
|
|
|
|
A freshly imaged PC opens NTLARS with a blank General tab (no MachineNo,
|
|
CNC, Host or Interface type) until a tech restores the config. Storing
|
|
that would make an empty config the newest revision at exactly the
|
|
moment someone needs the last good one - so a blank MachineNo is
|
|
rejected as "nothing worth backing up" rather than accepted as a change.
|
|
Two of the 320 known-good backups on the share already have this shape.
|
|
"""
|
|
projection = ntlarscodec.parse(raw)
|
|
if not self.embeddedmachineno(projection):
|
|
raise ValueError(
|
|
'NTLARS General\\MachineNo is empty - unconfigured install, '
|
|
'refusing to record it as a revision')
|
|
return projection
|
|
|
|
def formats(self):
|
|
return [
|
|
{
|
|
'id': 'ntlars',
|
|
'label': 'NTLARS Load... (.reg)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Restore with the Load... button in the NTLARS settings dialog.',
|
|
},
|
|
{
|
|
'id': 'wow6432node',
|
|
'label': 'Direct reg import (.reg, WOW6432Node)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Use when importing outside NTLARS on a 64-bit machine.',
|
|
},
|
|
]
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
if formatid not in ('ntlars', 'wow6432node'):
|
|
raise ValueError('unknown format for ntlars kind: {}'.format(formatid))
|
|
raw = ntlarscodec.render(projection, dialect=formatid, comments=comments)
|
|
return raw, '.reg', 'application/octet-stream'
|
|
|
|
def infopanel(self):
|
|
return {
|
|
'id': 'backups-dncinfo',
|
|
'title': 'DNC Info',
|
|
'assettypes': ['machine'],
|
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
|
|
# tabs, not keyvalue: General / eFocas / Serial / NTSHR / MARK are
|
|
# one card the tech switches between, rather than a 30-row wall.
|
|
# No 'empty' key - the renderer hides a panel with no sections, so
|
|
# a machine with no NTLARS revision has no DNC Info card at all.
|
|
'render': 'tabs',
|
|
# Above the history panels: this answers the question a tech
|
|
# arrives with, while history is for the rarer restore case.
|
|
'position': 38,
|
|
}
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
from . import dncinfo
|
|
return dncinfo.build(projection, assetid,
|
|
partmarkertypes=options.get('partmarkertypes'))
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
machinenumber = (payload.get('machinenumber') or '').strip()
|
|
if not machinenumber:
|
|
return None, 'no machinenumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == machinenumber).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(machinenumber)
|
|
return asset.assetid, None
|
|
|
|
|
|
class PartMarkerKind(BackupKind):
|
|
"""Telesis part marker configuration.
|
|
|
|
Opaque vendor filetype with no useful JSON representation, so ShopDB stores
|
|
metadata and a UNC pointer while the file itself stays on the share. The
|
|
original filename and extension are preserved because the vendor tool
|
|
rejects a renamed file.
|
|
"""
|
|
|
|
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 = []
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
identifier = (payload.get('machinenumber')
|
|
or payload.get('assetnumber') or '').strip()
|
|
if not identifier:
|
|
return None, 'no machinenumber/assetnumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == identifier).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(identifier)
|
|
return asset.assetid, None
|
|
|
|
|
|
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
|
|
|
|
|
|
def getkind(key):
|
|
return REGISTRY.get((key or '').strip().lower())
|