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():