diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue
index 10fa5dcc..8362bc9f 100644
--- a/frontend/src/components/PluginAssetPanels.vue
+++ b/frontend/src/components/PluginAssetPanels.vue
@@ -34,6 +34,35 @@
+
+
@@ -71,7 +100,7 @@ import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
- keyvalueFields, tableColumns, cell, badges,
+ keyvalueFields, tableColumns, cell, badges, tabSections,
} from './pluginAssetPanels'
const props = defineProps({
@@ -80,6 +109,12 @@ const props = defineProps({
const panels = ref([])
+// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
+// than on the panel object because panels are replaced wholesale on refetch.
+const tabs = ref({})
+function activeTab(panel) { return tabs.value[panel.id] || 0 }
+function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
+
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
@@ -118,10 +153,44 @@ watch(() => props.assetid, load, { immediate: true })
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-manage { font-size: 0.82rem; }
-.pap-kv { display: flex; flex-direction: column; gap: 0.4rem; }
-.pap-kv-row { display: flex; justify-content: space-between; gap: 1rem; }
+/* Two aligned columns rather than space-between: a ragged right edge is hard
+ to scan once a panel carries more than a handful of rows, and a long value
+ (a UNC path) otherwise pushes its label away from the others. */
+.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; }
+.pap-kv-row { display: contents; }
.pap-kv-label { color: var(--text-light); }
-.pap-kv-value { color: var(--text); font-weight: 500; }
+.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
+/* Tab strip: a quiet segmented control, not browser-default buttons. The
+ active tab is marked by an underline AND weight, so it does not rely on
+ colour alone. */
+.pap-tabstrip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.15rem;
+ margin-bottom: 0.85rem;
+ border-bottom: 1px solid var(--border);
+}
+.pap-tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ padding: 0.35rem 0.7rem;
+ color: var(--text-light);
+ font-size: 0.78rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.pap-tab:hover { color: var(--text); }
+.pap-tab.on {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+ font-weight: 700;
+}
+
.pap-table-wrap { overflow-x: auto; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js
index 3872e415..7aebfa4b 100644
--- a/frontend/src/components/pluginAssetPanels.js
+++ b/frontend/src/components/pluginAssetPanels.js
@@ -98,7 +98,24 @@ export function badges(panel) {
return list.map((b) => ({ label: b.label, color: b.color }))
}
+// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
+// time, so a panel with 30 values stays the height of its largest section
+// instead of dragging one multicol column far past the other.
+export function tabSections(panel) {
+ const data = panel._data
+ const sections = (data && Array.isArray(data.sections)) ? data.sections : []
+ return sections
+ .map((s) => ({
+ label: s.label,
+ fields: (s.fields || []).map((f) => ({
+ label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
+ })),
+ }))
+ .filter((s) => s.fields.length > 0)
+}
+
export function panelVisible(panel) {
+ if (panel.render === 'tabs') return tabSections(panel).length > 0
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0
diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js
index 7c650354..c48b00be 100644
--- a/frontend/src/components/pluginAssetPanels.spec.js
+++ b/frontend/src/components/pluginAssetPanels.spec.js
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
- keyvalueFields, tableColumns, badges, panelVisible,
+ keyvalueFields, tableColumns, badges, panelVisible, tabSections,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -98,3 +98,35 @@ describe('keyvalue + table + badge', () => {
expect(badges({ render: 'badge', _data: { badges: [{ label: 'B', color: '#222' }] } })).toEqual([{ label: 'B', color: '#222' }])
})
})
+
+describe('tabSections', () => {
+ const panel = {
+ render: 'tabs',
+ _data: {
+ sections: [
+ { label: 'General', fields: [{ label: 'Cnc', value: 'OKUMA' }] },
+ { label: 'eFocas', fields: [{ label: 'IpAddr', value: '192.168.1.1', mono: true }] },
+ // A section whose fields all dropped out must not leave an empty tab.
+ { label: 'NTSHR', fields: [] },
+ ],
+ },
+ }
+
+ it('returns one entry per non-empty section', () => {
+ expect(tabSections(panel).map((s) => s.label)).toEqual(['General', 'eFocas'])
+ })
+
+ it('formats field values and carries mono through', () => {
+ const efocas = tabSections(panel)[1]
+ expect(efocas.fields[0]).toEqual({ label: 'IpAddr', value: '192.168.1.1', mono: true })
+ })
+
+ it('is empty for a payload with no sections', () => {
+ expect(tabSections({ render: 'tabs', _data: null })).toEqual([])
+ })
+
+ it('drives panel visibility', () => {
+ expect(panelVisible(panel)).toBe(true)
+ expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false)
+ })
+})
diff --git a/plugins/backups/api/routes.py b/plugins/backups/api/routes.py
index deee70b9..dba92ad1 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 f7a344e7..b2286e9c 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 6dfb50e8..4b945c40 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 e1333a35..d2b2a2e4 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 fdb37e19..0354fccf 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 5a390868..3dfc3810 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():