Files
shopdb-flask/frontend/src/components/pluginAssetPanels.js
benjamin.baker ba38e4d39d PC Detail: flow Details cards, drop the IP Configuration card
Details tab card layout
-----------------------
The Details tab sat on .content-grid, a two-column CSS grid. Grid packs
items into implicit rows, so every row is as tall as its tallest card and
align-items:start leaves the shorter one with dead space beneath it:
Warranty is short, Endpoint Status is tall, and Policy had to wait for the
taller of the two rather than sliding up. That read as a staggered layout.

Switched that one tab to multicol via a .details-flow modifier, which has no
rows at all - each card starts where the previous one ended, so the holes
close while cards still size to their content.

Multicol is deliberately NOT applied to .content-grid globally. The note by
that rule in style.css records why it was abandoned there: a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column when the
two are unequal. That cannot happen on this tab - every panel in the details
group is a keyvalue or list render, and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in the subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.

The modifier is scoped to PCDetail, so the nine other detail views on
.content-grid are untouched.

Chosen over JS masonry on purpose. Masonry packs perfectly but has to
measure every card and re-measure on resize, on panel collapse and on every
async panel load - and these panels fetch after paint, so it would mean
continuous forced reflow on a page that already polls. This is pure CSS:
no measurement, no observer, no runtime cost. The tradeoff accepted is that
column bottoms can end at slightly different heights.

Cards carry break-inside: avoid so none fragments across the column break,
and the 25px spacing returns as a margin because multicol has no gap between
stacked items the way grid does.

IP Configuration card
---------------------
Removed: the data has no purpose on the page anymore. Taken out at the root
rather than hidden - the panel declaration, the /asset/<id>/ipconfig route
and the _IPCONFIG_COLUMNS constant all go, since a route with no consumer is
dead surface that still answers requests.

test_the_two_cards_do_not_duplicate_columns_beyond_the_address existed only
to police the history/config column split; with one card gone it guards
nothing, so it and its import go with it. The panel-count assertion in
test_endpoints_asset_panels_are_computer_scoped moves 15 -> 14, which is the
test doing its job and catching the removal.

IP History is unchanged and still carries the address list.

Not addressed here: three tzset() tests in test_endpoints_plugin.py fail on
Windows because time.tzset() is Unix-only. Verified pre-existing against
unmodified code; they pass in the Linux container.
2026-09-05 15:27:50 -04:00

313 lines
12 KiB
JavaScript

// Pure render helpers for PluginAssetPanels.vue (ADR-010 get_asset_panels, Path
// A). Kept separate from the .vue so the mapping logic - which turns a plugin's
// JSON panel declaration + its endpoint data into rendered rows - is unit
// tested directly (same pattern as geenforce/entryForm.js).
export function subst(text, assetid) {
return String(text).replace('{assetid}', assetid)
}
// Panel endpoints are declared absolute (/api/...); the api instance already
// carries the /api base, so strip it before fetching through the instance.
export function toApiPath(endpoint, assetid) {
return subst(endpoint, assetid).replace(/^\/api(?=\/)/, '')
}
export function formatValue(value, spec = {}) {
if (value === null || value === undefined || value === '') return ''
if (spec.format === 'date') {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
// format is UTC by contract. Render in the viewer's local zone: an operator
// comparing "last reported" against the clock on the wall cannot do the
// offset in their head, and the Z suffix invites reading it as local.
if (spec.format === 'datetime') {
const parsed = new Date(String(value))
if (isNaN(parsed)) return value
return parsed.toLocaleString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
export function humanize(key) {
return key.charAt(0).toUpperCase() + key.slice(1)
}
export function rows(panel) {
const data = panel._data
let list
if (Array.isArray(data)) list = data
else if (data && Array.isArray(data.rows)) list = data.rows
else if (data && Array.isArray(data.items)) list = data.items
else if (data && Array.isArray(data.reports)) list = data.reports
else return []
// Optional panel-level row filter so two panels can share one endpoint feed
// and each show a slice of it (for example Tracked Services vs Tracked
// Processes off the same services snapshot). Absent filter = all rows.
const filter = panel.filter
if (filter && filter.key) {
const want = String(filter.equals).toLowerCase()
return list.filter((item) => String(item[filter.key]).toLowerCase() === want)
}
return list
}
// Optional in-card filter chips. A list panel declares facets: {key, order,
// labels} and the renderer offers one chip per distinct value of that key,
// with a count. Filtering is client-side over rows the panel already fetched -
// no extra request, and the counts stay honest because they are computed from
// the same array.
//
// Declared rather than hardcoded because the reason it exists is generic: the
// activity feed mixes info, warning and critical, and somebody triaging wants
// the criticals alone. Any list panel with a category column has that problem.
// Colour names carry no meaning to a reader. A panel keyed on statecolor
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
// a word (info/warning/critical) is labelled by the word itself.
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
// A panel that shows a health dot has already declared what it is dotting on.
// Reuse that instead of asking every panel to declare filters a second time:
// any card with a status rollup gets chips, and the two cannot disagree.
function facetSpec(panel) {
if (panel.facets && panel.facets.key) return panel.facets
const status = panel.status
if (!status || !status.key) return null
const levels = status.levels || {}
const order = Object.keys(levels).sort(
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
)
const labels = {}
Object.keys(levels).forEach((value) => {
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
? humanize(levels[value])
: humanize(value)
})
return { key: status.key, order, labels }
}
export function facets(panel) {
const spec = facetSpec(panel)
if (!spec || !spec.key) return []
const all = rows(panel)
const counts = new Map()
all.forEach((item) => {
const value = item[spec.key]
if (value === null || value === undefined || value === '') return
const key = String(value)
counts.set(key, (counts.get(key) || 0) + 1)
})
// Declared order first, so severity reads critical-warning-info rather than
// alphabetically; anything unexpected in the data still gets a chip.
const declared = (spec.order || []).filter((value) => counts.has(value))
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
return [...declared, ...extra].map((value) => ({
value,
label: (spec.labels && spec.labels[value]) || humanize(value),
count: counts.get(value)
}))
}
// rows() after the viewer's chip selection. An empty selection means "no filter
// applied", NOT "hide everything": a card that vanished when you deselected the
// last chip would read as broken.
export function facetedRows(panel, selected) {
const all = rows(panel)
const spec = facetSpec(panel)
if (!spec || !spec.key || !selected || !selected.length) return all
return all.filter((item) => selected.includes(String(item[spec.key])))
}
export function mapTitle(panel, item) {
const key = panel.map && panel.map.title
return key ? item[key] : ''
}
export function mapBadge(panel, item) {
const badge = panel.map && panel.map.badge
if (!badge) return null
const raw = item[badge.label]
const label = (badge.labelmap && badge.labelmap[raw]) || raw
return { label, color: badge.color ? item[badge.color] : undefined }
}
export function mapMeta(panel, item) {
const meta = (panel.map && panel.map.meta) || []
return meta
.map((spec) => {
const value = formatValue(item[spec.key], spec)
if (value === '') return null
const text = spec.label ? `${spec.label} ${value}` : value
return { text, mono: !!spec.mono }
})
.filter(Boolean)
}
export function mapBody(panel, item) {
const key = panel.map && panel.map.body
if (!key) return null
const value = item[key]
if (value === null || value === undefined || value === '') return null
return String(value)
}
export function manageLink(panel, assetid) {
return panel.manage ? subst(panel.manage.to, assetid) : null
}
export function keyvalueFields(panel) {
const data = panel._data
if (data && Array.isArray(data.fields)) {
return data.fields.map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
}))
}
if (data && typeof data === 'object' && !Array.isArray(data)) {
return Object.entries(data).map(([k, v]) => ({
label: humanize(k), value: formatValue(v),
}))
}
return []
}
export function tableColumns(panel) {
if (Array.isArray(panel.columns) && panel.columns.length) {
return panel.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) }))
}
const first = rows(panel)[0]
if (!first) return []
return Object.keys(first).map((k) => ({ key: k, label: humanize(k) }))
}
export function cell(row, col) {
return formatValue(row[col.key], col)
}
export function badges(panel) {
const data = panel._data
const list = Array.isArray(data) ? data : (data && data.badges) || []
return list.map((b) => ({ label: b.label, color: b.color }))
}
// Header status rollup for a card. A panel opts in with
// status: { key, levels: { <rowvalue>: 'critical'|'warning'|'ok'|'unknown' } }.
// The card header then shows a single green/yellow/red dot reflecting the worst
// row: any critical -> red, else any warning -> yellow, else ok -> green. A
// value not in levels is treated as unknown and does not raise the level.
export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 }
export const STATUS_COLOR = {
ok: '#2dce89',
warning: '#ffc107',
critical: '#f5365c',
unknown: '#6c757d',
}
export function rollupStatus(panel) {
const status = panel && panel.status
if (!status || !status.key) return null
const items = rows(panel)
if (!items.length) return null
const levels = status.levels || {}
let worst = 'ok'
let sawKnown = false
for (const item of items) {
const level = levels[item[status.key]]
if (!level) continue
sawKnown = true
if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level
}
if (!sawKnown) return null
return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown }
}
// 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.
//
// A section may instead carry { columns, rows, empty } and render as a table.
// This exists so two tables that belong side by side for the reader but share
// NO key can sit in one card - Storage is per volume, Disk health is per
// physical device. The sections are independent tables by design: nothing here
// reconciles rows across them, unlike the mapped-drive merge where two sources
// described the same drive.
export function tabSections(panel) {
const data = panel._data
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
return sections
.map((s) => {
if (Array.isArray(s.columns) && s.columns.length) {
return {
label: s.label,
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
rows: Array.isArray(s.rows) ? s.rows : [],
empty: s.empty || '',
fields: [],
}
}
return {
label: s.label,
fields: (s.fields || []).map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
})),
}
})
// A table section declares its columns up front, so it survives having no
// rows and shows its own empty message. Dropping it would take the tab with
// it and read as "this machine has no disks" rather than "nothing reported".
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
}
// Inline row action for a list panel. A monitored services/processes row may
// carry action: { running, start, stop, status } when it has a registered
// command (e.g. MTConnect). The card renders a single toggle on that row: when
// running it offers Stop, otherwise Start. Rows with no action are badge-only.
// commandendpoint on the panel is where the enqueue POST goes.
export function rowAction(panel, item) {
const action = item && item.action
if (!action || (!action.start && !action.stop)) return null
const running = action.running === true
return {
running,
// The action name to enqueue for the primary toggle button.
toggle: running ? action.stop : action.start,
toggleLabel: running ? 'Stop' : 'Start',
// status is a non-mutating probe, always available when declared.
status: action.status || null,
// Generic actions (service_*/process_*) carry the subject to act on; fixed
// actions (mtconnect_*) leave this null.
target: action.target || null,
// The declared enqueue endpoint template; the caller substitutes assetid.
endpoint: panel.commandendpoint || null,
}
}
// A table needs the full card width. `table` panels get it via panel.render,
// but a `tabs` panel only needs it when one of its sections is a table, and
// that is not knowable from the declaration - it depends on fetched data.
export function spansFullWidth(panel) {
if (!panel) return false
if (panel.render === 'table') return true
if (panel.render !== 'tabs') return false
return tabSections(panel).some((s) => (s.columns || []).length > 0)
}
// A panel declaring `empty` is asking to stay visible with nothing in it and
// say so - "No installed software reported." is an answer, where an absent
// card is a question. `list` honoured that and `table` did not, for no reason
// anybody wrote down, so a table panel with an empty message still vanished.
// It matters more now that a panel can be the only thing in a tab: the tab
// itself disappears with it.
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 || !!panel.empty
if (panel.render === 'badge') return badges(panel).length > 0
if (panel.render === 'operatorreports') return rows(panel).length > 0 || !!panel.empty
return false
}