diff --git a/plugins/backups/api/routes.py b/plugins/backups/api/routes.py index deee70b..dba92ad 100644 --- a/plugins/backups/api/routes.py +++ b/plugins/backups/api/routes.py @@ -208,10 +208,16 @@ def download_revision(backuprevisionid): except ValueError as exc: return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) - filename = revision.sourcefilename or '{}-{}{}'.format( - assetnumber, kind.key, ext) + # Named for the MACHINE, not the revision or the kind: a tech restoring bay + # 3204 wants 3204.reg, matching how the per-machine backups on the share + # have always been named. sourcefilename is deliberately not reused here - + # a seeded revision carries "3204.reg" already and appending an extension + # to it produced "3204.reg.reg". + filename = '{}{}'.format(assetnumber, ext) if formatid == 'wow6432node': - filename = '{}-{}-wow6432node{}'.format(assetnumber, kind.key, ext) + # The two dialects must not collide in a downloads folder, and the + # suffix says which one will import correctly outside NTLARS. + filename = '{}-wow6432node{}'.format(assetnumber, ext) return Response( raw, diff --git a/plugins/backups/frontend/views/BackupHistory.vue b/plugins/backups/frontend/views/BackupHistory.vue index f7a344e..b2286e9 100644 --- a/plugins/backups/frontend/views/BackupHistory.vue +++ b/plugins/backups/frontend/views/BackupHistory.vue @@ -171,9 +171,11 @@ async function download(rev, fmt) { const response = await api.get( `/backups/revisions/${rev.backuprevisionid}/download?format=${fmt.id}`, { responseType: 'blob' }) + // Name for the MACHINE: 3204.reg, matching both the server's + // Content-Disposition and how the per-machine backups on the share are named. const suffix = fmt.id === 'wow6432node' ? '-wow6432node' : '' - const name = `${rev.sourcefilename || rev.backupkind}${suffix}${fmt.ext}` - .replace(/(\.reg)+$/, '.reg') + const stem = rev.assetnumber || rev.backupkind + const name = `${stem}${suffix}${fmt.ext}` const link = document.createElement('a') link.href = URL.createObjectURL(response.data) link.download = name @@ -185,39 +187,77 @@ onMounted(load) diff --git a/plugins/backups/models/backup.py b/plugins/backups/models/backup.py index 6dfb50e..4b945c4 100644 --- a/plugins/backups/models/backup.py +++ b/plugins/backups/models/backup.py @@ -46,6 +46,11 @@ class BackupRevision(db.Model): index=True, ) + # Read-only view of the owning asset, so a revision can name its own + # download .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') @@ -114,6 +119,9 @@ class BackupRevision(db.Model): 'sharepath': self.sharepath, 'sourcefilename': self.sourcefilename, 'bytesize': self.bytesize, + # The machine number. Carried on the revision so the UI can name a + # download .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, diff --git a/plugins/backups/services/dncinfo.py b/plugins/backups/services/dncinfo.py index e1333a3..d2b2a2e 100644 --- a/plugins/backups/services/dncinfo.py +++ b/plugins/backups/services/dncinfo.py @@ -152,10 +152,13 @@ def build(projection, assetid, partmarkertypes=None): sections.append(('MARK (part marker)', _fields(mark, mono=('DataPath', 'MarkMasterPath')))) - fields = [] - for title, entries in sections: - if not entries: - continue - fields.append({'label': title, 'value': '', 'heading': True}) - fields.extend(entries) - return {'fields': fields, 'sectioncount': len(sections)} + # Emitted as SECTIONS for the tabs renderer: one visible at a time, so the + # card stays the height of its largest section. Flattened into one list it + # ran to 30 rows, which the two-column multicol layout cannot split, so it + # dragged one column far past the other. + # + # Everything DNC lives on this one card - General, the interface sections + # and MARK - rather than MARK getting a card of its own. + out = [{'label': title, 'fields': entries} + for title, entries in sections if entries] + return {'sections': out, 'sectioncount': len(out)} diff --git a/plugins/backups/services/registry.py b/plugins/backups/services/registry.py index fdb37e1..0354fcc 100644 --- a/plugins/backups/services/registry.py +++ b/plugins/backups/services/registry.py @@ -180,10 +180,11 @@ class NtlarsKind(BackupKind): 'title': 'DNC Info', 'assettypes': ['machine'], 'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars', - # No 'empty' key: the keyvalue renderer decides visibility purely on - # field count and never displays empty text, so a machine with no - # NTLARS revision simply has no DNC Info card at all. - 'render': 'keyvalue', + # 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, @@ -219,7 +220,11 @@ class PartMarkerKind(BackupKind): key = 'partmarker' displayname = 'Part Marker Configuration' storagebackend = 'share' - assettypes = ['machine', 'measuring_tool'] + # 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 diff --git a/tests/test_plugins/test_backups.py b/tests/test_plugins/test_backups.py index 5a39086..3dfc381 100644 --- a/tests/test_plugins/test_backups.py +++ b/tests/test_plugins/test_backups.py @@ -526,7 +526,13 @@ DNCINFOREG = ( def _headings(card): - return [f['label'] for f in card['fields'] if f.get('heading')] + """Section labels of the DNC Info card (tabs renderer shape).""" + return [s['label'] for s in card['sections']] + + +def _labels(card): + """Every field label across all sections.""" + return [f['label'] for s in card['sections'] for f in s['fields']] def test_dncinfo_shows_efocas_and_serial(): @@ -563,15 +569,14 @@ def test_dncinfo_shows_mark_when_the_asset_is_a_part_marker(monkeypatch): def test_dncinfo_drops_empty_values_within_a_shown_section(): populated = DNCINFOREG.replace('"ShrHost"=""', '"ShrHost"="WJFMS3"') card = dncinfo.build(ntlars.parse(_asbytes(populated)), assetid=0) - labels = [f['label'] for f in card['fields'] if not f.get('heading')] - assert 'ShrHost' in labels - assert 'ShrFolder' not in labels + assert 'ShrHost' in _labels(card) + assert 'ShrFolder' not in _labels(card) def test_dncinfo_is_empty_for_a_projection_with_nothing_interesting(): card = dncinfo.build(ntlars.parse(_asbytes(CONFIGUREDREG)), assetid=0) assert card['sectioncount'] == 0 - assert card['fields'] == [] + assert card['sections'] == [] def test_ispartmarker_is_false_when_the_machines_plugin_is_absent(monkeypatch): @@ -606,8 +611,7 @@ def test_base_kind_buildinfo_is_an_empty_card(): def test_dncinfo_general_section_leads_with_controller_identity(): card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0) assert _headings(card)[0] == 'General' - labels = [f['label'] for f in card['fields'] if not f.get('heading')] - assert 'Cnc' in labels and 'HostType' in labels + assert 'Cnc' in _labels(card) and 'HostType' in _labels(card) def test_dncinfo_general_omits_the_rest_of_the_key(): @@ -615,8 +619,7 @@ def test_dncinfo_general_omits_the_rest_of_the_key(): reg = DNCINFOREG.replace('"MachineNo"="3204"', '"MachineNo"="3204"\r\n"Debug"="NO"\r\n"Site"="WJ"') card = dncinfo.build(ntlars.parse(_asbytes(reg)), assetid=0) - labels = [f['label'] for f in card['fields'] if not f.get('heading')] - assert 'Debug' not in labels and 'Site' not in labels + assert 'Debug' not in _labels(card) and 'Site' not in _labels(card) def test_cnc_marker_reveals_the_mark_section_without_shopdb():