backups plugin with NTLARS revision history; shopfloor dashboard upcoming cards and state chips
This commit is contained in:
237
plugins/backups/services/registry.py
Normal file
237
plugins/backups/services/registry.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""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 = ['*']
|
||||
# Human note shown on the panel when there is nothing yet.
|
||||
emptytext = 'No backups on record.'
|
||||
|
||||
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']
|
||||
emptytext = 'No NTLARS settings captured yet.'
|
||||
|
||||
@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',
|
||||
'render': 'keyvalue',
|
||||
'empty': 'No NTLARS settings captured for this machine yet.',
|
||||
# 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'
|
||||
assettypes = ['machine', 'measuring_tool']
|
||||
emptytext = 'No part marker backups on record.'
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user