backups: one tabbed DNC card, machine-numbered downloads, themed history
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

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.
This commit is contained in:
cproudlock
2026-08-07 15:22:45 -04:00
parent a33470a34b
commit c93ec7a949
6 changed files with 110 additions and 45 deletions

View File

@@ -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,

View File

@@ -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)
</script>
<style scoped>
.bh { padding: 1.5rem; }
/* Themed throughout: this page renders in light and dark, so every colour is a
variable from the app palette. Hardcoded hex here showed up as a white table
on a dark page. */
.bh { padding: 1.5rem; color: var(--text); }
.bh-head { margin-bottom: 1rem; }
.bh-head h1 { margin: 0.3rem 0 0; }
.bh-back {
font-size: 0.85rem; background: none; border: none; padding: 0;
color: #0d6efd; cursor: pointer;
color: var(--link); cursor: pointer;
}
.bh-back:hover { text-decoration: underline; }
.bh-sub { color: var(--text-light); margin: 0.2rem 0 0; }
.bh-note, .bh-muted { color: var(--text-light); }
.bh-error { color: var(--danger); }
.bh-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.bh-sub { color: #666; margin: 0.2rem 0 0; }
.bh-note, .bh-muted { color: #666; }
.bh-error { color: #c00; }
.bh-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.bh-table th, .bh-table td {
text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #e0e0e0;
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.bh-latest { background: #f4f9ff; }
.bh-table th {
color: var(--text-light);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.bh-table tr:last-child td { border-bottom: none; }
/* The current revision is the one a tech restores from, so it is tinted rather
than merely labelled. color-mix keeps that tint correct in both themes
instead of baking in a light-blue that vanishes on a dark background. */
.bh-latest td { background: color-mix(in srgb, var(--primary) 10%, transparent); }
.bh-badge {
font-size: 0.7rem; background: #0d6efd; color: #fff;
border-radius: 3px; padding: 0.05rem 0.35rem; margin-left: 0.4rem;
font-size: 0.68rem; background: var(--primary); color: #fff;
border-radius: 999px; padding: 0.1rem 0.5rem; margin-left: 0.4rem;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700;
}
.bh-mono, .bh-path { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
.bh-path { word-break: break-all; }
.bh-mono, .bh-path {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.bh-path { word-break: break-all; color: var(--text-light); }
.bh-actions-col { width: 22rem; }
.bh-btn {
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.25rem 0.5rem;
border: 1px solid #0d6efd; background: #fff; color: #0d6efd;
border-radius: 4px; cursor: pointer;
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.28rem 0.6rem;
border: 1px solid var(--primary); background: transparent;
color: var(--primary); border-radius: 4px; cursor: pointer;
}
.bh-btn:hover { background: #0d6efd; color: #fff; }
.bh-btn:hover { background: var(--primary); color: #fff; }
.bh-link {
background: none; border: none; color: #0d6efd; cursor: pointer;
background: none; border: none; color: var(--link); cursor: pointer;
padding: 0; font-size: 0.82rem; text-decoration: underline;
}
.bh-diff { margin: 0 0 1.5rem; }
.bh-before { color: #b00; }
.bh-after { color: #070; }
.bh-diff h3 { font-size: 0.95rem; margin-bottom: 0.4rem; }
/* Named AND coloured: a red/green pair alone does not survive a colourblind
reader, so the before/after columns are headed as well as tinted. */
.bh-before { color: var(--danger); }
.bh-after { color: var(--success-dark, var(--success)); }
</style>

View File

@@ -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 <machinenumber>.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 <machinenumber>.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,

View File

@@ -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)}

View File

@@ -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

View File

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