tabbed DNC card, machine-numbered downloads, themed backup history

This commit is contained in:
2026-08-07 15:22:59 -04:00
parent 2487981d91
commit 3ebc1f353a
9 changed files with 233 additions and 50 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