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.
313 lines
12 KiB
JavaScript
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
|
|
}
|