tabbed DNC card, machine-numbered downloads, themed backup history
This commit is contained in:
@@ -34,6 +34,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- tabs: several label/value sections, one visible at a time. Keeps a
|
||||||
|
panel with many fields to the height of its largest section - a
|
||||||
|
30-row card cannot be split by the two-column multicol layout and
|
||||||
|
drags one column far past the other. -->
|
||||||
|
<div v-else-if="panel.render === 'tabs'" class="pap-tabs">
|
||||||
|
<div class="pap-tabstrip" role="tablist">
|
||||||
|
<button
|
||||||
|
v-for="(section, si) in tabSections(panel)"
|
||||||
|
:key="si"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="pap-tab"
|
||||||
|
:class="{ on: si === activeTab(panel) }"
|
||||||
|
:aria-selected="si === activeTab(panel)"
|
||||||
|
@click="setTab(panel, si)"
|
||||||
|
>{{ section.label }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="pap-kv">
|
||||||
|
<div
|
||||||
|
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
||||||
|
:key="fi"
|
||||||
|
class="pap-kv-row"
|
||||||
|
>
|
||||||
|
<span class="pap-kv-label">{{ field.label }}</span>
|
||||||
|
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- keyvalue: a label/value grid -->
|
<!-- keyvalue: a label/value grid -->
|
||||||
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
|
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
|
||||||
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
|
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
|
||||||
@@ -71,7 +100,7 @@ import api from '../api'
|
|||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import {
|
import {
|
||||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||||
keyvalueFields, tableColumns, cell, badges,
|
keyvalueFields, tableColumns, cell, badges, tabSections,
|
||||||
} from './pluginAssetPanels'
|
} from './pluginAssetPanels'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -80,6 +109,12 @@ const props = defineProps({
|
|||||||
|
|
||||||
const panels = ref([])
|
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.
|
// Fetch the declared panels for this asset, then each panel's data endpoint.
|
||||||
async function load() {
|
async function load() {
|
||||||
panels.value = []
|
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-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-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
.pap-manage { font-size: 0.82rem; }
|
.pap-manage { font-size: 0.82rem; }
|
||||||
.pap-kv { display: flex; flex-direction: column; gap: 0.4rem; }
|
/* Two aligned columns rather than space-between: a ragged right edge is hard
|
||||||
.pap-kv-row { display: flex; justify-content: space-between; gap: 1rem; }
|
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-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-wrap { overflow-x: auto; }
|
||||||
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
.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); }
|
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||||
|
|||||||
@@ -98,7 +98,24 @@ export function badges(panel) {
|
|||||||
return list.map((b) => ({ label: b.label, color: b.color }))
|
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) {
|
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 === 'list') return rows(panel).length > 0 || !!panel.empty
|
||||||
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
|
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
|
||||||
if (panel.render === 'table') return rows(panel).length > 0
|
if (panel.render === 'table') return rows(panel).length > 0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||||
keyvalueFields, tableColumns, badges, panelVisible,
|
keyvalueFields, tableColumns, badges, panelVisible, tabSections,
|
||||||
} from './pluginAssetPanels'
|
} from './pluginAssetPanels'
|
||||||
|
|
||||||
const warrantyPanel = {
|
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' }])
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -208,10 +208,16 @@ def download_revision(backuprevisionid):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||||
|
|
||||||
filename = revision.sourcefilename or '{}-{}{}'.format(
|
# Named for the MACHINE, not the revision or the kind: a tech restoring bay
|
||||||
assetnumber, kind.key, ext)
|
# 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':
|
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(
|
return Response(
|
||||||
raw,
|
raw,
|
||||||
|
|||||||
@@ -171,9 +171,11 @@ async function download(rev, fmt) {
|
|||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/backups/revisions/${rev.backuprevisionid}/download?format=${fmt.id}`,
|
`/backups/revisions/${rev.backuprevisionid}/download?format=${fmt.id}`,
|
||||||
{ responseType: 'blob' })
|
{ 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 suffix = fmt.id === 'wow6432node' ? '-wow6432node' : ''
|
||||||
const name = `${rev.sourcefilename || rev.backupkind}${suffix}${fmt.ext}`
|
const stem = rev.assetnumber || rev.backupkind
|
||||||
.replace(/(\.reg)+$/, '.reg')
|
const name = `${stem}${suffix}${fmt.ext}`
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = URL.createObjectURL(response.data)
|
link.href = URL.createObjectURL(response.data)
|
||||||
link.download = name
|
link.download = name
|
||||||
@@ -185,39 +187,77 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 { margin-bottom: 1rem; }
|
||||||
|
.bh-head h1 { margin: 0.3rem 0 0; }
|
||||||
.bh-back {
|
.bh-back {
|
||||||
font-size: 0.85rem; background: none; border: none; padding: 0;
|
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 {
|
.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;
|
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 {
|
.bh-badge {
|
||||||
font-size: 0.7rem; background: #0d6efd; color: #fff;
|
font-size: 0.68rem; background: var(--primary); color: #fff;
|
||||||
border-radius: 3px; padding: 0.05rem 0.35rem; margin-left: 0.4rem;
|
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-actions-col { width: 22rem; }
|
||||||
|
|
||||||
.bh-btn {
|
.bh-btn {
|
||||||
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.25rem 0.5rem;
|
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.28rem 0.6rem;
|
||||||
border: 1px solid #0d6efd; background: #fff; color: #0d6efd;
|
border: 1px solid var(--primary); background: transparent;
|
||||||
border-radius: 4px; cursor: pointer;
|
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 {
|
.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;
|
padding: 0; font-size: 0.82rem; text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bh-diff { margin: 0 0 1.5rem; }
|
.bh-diff { margin: 0 0 1.5rem; }
|
||||||
.bh-before { color: #b00; }
|
.bh-diff h3 { font-size: 0.95rem; margin-bottom: 0.4rem; }
|
||||||
.bh-after { color: #070; }
|
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class BackupRevision(db.Model):
|
|||||||
index=True,
|
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)
|
backupkind = db.Column(db.String(50), nullable=False, index=True)
|
||||||
storagebackend = db.Column(db.String(20), nullable=False, default='shopdb')
|
storagebackend = db.Column(db.String(20), nullable=False, default='shopdb')
|
||||||
|
|
||||||
@@ -114,6 +119,9 @@ class BackupRevision(db.Model):
|
|||||||
'sharepath': self.sharepath,
|
'sharepath': self.sharepath,
|
||||||
'sourcefilename': self.sourcefilename,
|
'sourcefilename': self.sourcefilename,
|
||||||
'bytesize': self.bytesize,
|
'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,
|
'sourcehostname': self.sourcehostname,
|
||||||
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
||||||
'createdat': self.createdat.isoformat() if self.createdat else None,
|
'createdat': self.createdat.isoformat() if self.createdat else None,
|
||||||
|
|||||||
@@ -152,10 +152,13 @@ def build(projection, assetid, partmarkertypes=None):
|
|||||||
sections.append(('MARK (part marker)',
|
sections.append(('MARK (part marker)',
|
||||||
_fields(mark, mono=('DataPath', 'MarkMasterPath'))))
|
_fields(mark, mono=('DataPath', 'MarkMasterPath'))))
|
||||||
|
|
||||||
fields = []
|
# Emitted as SECTIONS for the tabs renderer: one visible at a time, so the
|
||||||
for title, entries in sections:
|
# card stays the height of its largest section. Flattened into one list it
|
||||||
if not entries:
|
# ran to 30 rows, which the two-column multicol layout cannot split, so it
|
||||||
continue
|
# dragged one column far past the other.
|
||||||
fields.append({'label': title, 'value': '', 'heading': True})
|
#
|
||||||
fields.extend(entries)
|
# Everything DNC lives on this one card - General, the interface sections
|
||||||
return {'fields': fields, 'sectioncount': len(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)}
|
||||||
|
|||||||
@@ -180,10 +180,11 @@ class NtlarsKind(BackupKind):
|
|||||||
'title': 'DNC Info',
|
'title': 'DNC Info',
|
||||||
'assettypes': ['machine'],
|
'assettypes': ['machine'],
|
||||||
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
|
||||||
# No 'empty' key: the keyvalue renderer decides visibility purely on
|
# tabs, not keyvalue: General / eFocas / Serial / NTSHR / MARK are
|
||||||
# field count and never displays empty text, so a machine with no
|
# one card the tech switches between, rather than a 30-row wall.
|
||||||
# NTLARS revision simply has no DNC Info card at all.
|
# No 'empty' key - the renderer hides a panel with no sections, so
|
||||||
'render': 'keyvalue',
|
# 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
|
# Above the history panels: this answers the question a tech
|
||||||
# arrives with, while history is for the rarer restore case.
|
# arrives with, while history is for the rarer restore case.
|
||||||
'position': 38,
|
'position': 38,
|
||||||
@@ -219,7 +220,11 @@ class PartMarkerKind(BackupKind):
|
|||||||
key = 'partmarker'
|
key = 'partmarker'
|
||||||
displayname = 'Part Marker Configuration'
|
displayname = 'Part Marker Configuration'
|
||||||
storagebackend = 'share'
|
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):
|
def resolveassetid(self, payload):
|
||||||
from shopdb.api import db, Asset
|
from shopdb.api import db, Asset
|
||||||
|
|||||||
@@ -526,7 +526,13 @@ DNCINFOREG = (
|
|||||||
|
|
||||||
|
|
||||||
def _headings(card):
|
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():
|
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():
|
def test_dncinfo_drops_empty_values_within_a_shown_section():
|
||||||
populated = DNCINFOREG.replace('"ShrHost"=""', '"ShrHost"="WJFMS3"')
|
populated = DNCINFOREG.replace('"ShrHost"=""', '"ShrHost"="WJFMS3"')
|
||||||
card = dncinfo.build(ntlars.parse(_asbytes(populated)), assetid=0)
|
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(card)
|
||||||
assert 'ShrHost' in labels
|
assert 'ShrFolder' not in _labels(card)
|
||||||
assert 'ShrFolder' not in labels
|
|
||||||
|
|
||||||
|
|
||||||
def test_dncinfo_is_empty_for_a_projection_with_nothing_interesting():
|
def test_dncinfo_is_empty_for_a_projection_with_nothing_interesting():
|
||||||
card = dncinfo.build(ntlars.parse(_asbytes(CONFIGUREDREG)), assetid=0)
|
card = dncinfo.build(ntlars.parse(_asbytes(CONFIGUREDREG)), assetid=0)
|
||||||
assert card['sectioncount'] == 0
|
assert card['sectioncount'] == 0
|
||||||
assert card['fields'] == []
|
assert card['sections'] == []
|
||||||
|
|
||||||
|
|
||||||
def test_ispartmarker_is_false_when_the_machines_plugin_is_absent(monkeypatch):
|
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():
|
def test_dncinfo_general_section_leads_with_controller_identity():
|
||||||
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
||||||
assert _headings(card)[0] == 'General'
|
assert _headings(card)[0] == 'General'
|
||||||
labels = [f['label'] for f in card['fields'] if not f.get('heading')]
|
assert 'Cnc' in _labels(card) and 'HostType' in _labels(card)
|
||||||
assert 'Cnc' in labels and 'HostType' in labels
|
|
||||||
|
|
||||||
|
|
||||||
def test_dncinfo_general_omits_the_rest_of_the_key():
|
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"',
|
reg = DNCINFOREG.replace('"MachineNo"="3204"',
|
||||||
'"MachineNo"="3204"\r\n"Debug"="NO"\r\n"Site"="WJ"')
|
'"MachineNo"="3204"\r\n"Debug"="NO"\r\n"Site"="WJ"')
|
||||||
card = dncinfo.build(ntlars.parse(_asbytes(reg)), assetid=0)
|
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(card) and 'Site' not in _labels(card)
|
||||||
assert 'Debug' not in labels and 'Site' not in labels
|
|
||||||
|
|
||||||
|
|
||||||
def test_cnc_marker_reveals_the_mark_section_without_shopdb():
|
def test_cnc_marker_reveals_the_mark_section_without_shopdb():
|
||||||
|
|||||||
Reference in New Issue
Block a user