// Pure render helpers for DashboardCards.vue - the fleet dashboard's generic // renderers. Same shape as pluginAssetPanels.js: the mapping logic that turns a // plugin's JSON card declaration plus its endpoint data into rendered rows // lives here so it can be unit tested without mounting a component. // // WHY GENERIC RENDERERS AT ALL: the older widget contract named a Vue component // per widget. That cannot survive a lean build, where a plugin's component may // never be staged into the frontend bundle - which is exactly why five plugins // declared widgets pointing at components nobody ever wrote. A plugin declares // data and shape; core owns the drawing. export function toApiPath(endpoint) { // Cards declare absolute endpoints (/api/...); the api instance already // carries the /api base, so strip it before fetching through the instance. return String(endpoint).replace(/^\/api(?=\/)/, '') } export function rows(card) { const data = card._data if (Array.isArray(data)) return data if (data && Array.isArray(data.rows)) return data.rows if (data && Array.isArray(data.items)) return data.items return [] } 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() } if (typeof value === 'boolean') return value ? 'Yes' : 'No' return value } export function mapTitle(card, item) { const key = card.map && card.map.title return key ? item[key] : '' } export function mapDetail(card, item) { const key = card.map && card.map.detail return key ? item[key] : '' } // The untruncated text behind a shortened detail. Same rule as elsewhere: the // tooltip explains, it never carries the only copy of something essential. export function mapDetailTip(card, item) { const key = card.map && card.map.detailtooltip const full = key ? (item[key] || '') : '' return full && full !== mapDetail(card, item) ? full : '' } export function mapMeta(card, item) { const meta = (card.map && card.map.meta) || [] return meta .map((spec) => { const value = formatValue(item[spec.key], spec) if (value === '') return null // label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence, // where 'quiet for 3' reads as a truncation. const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}` return { text, mono: !!spec.mono } }) .filter(Boolean) } // A link template is '/pcs/{computerid}'. A row whose substitution value is // missing gets NO link rather than a link to '/pcs/undefined' - the row still // shows, because the finding is real even when the inventory is behind. export function mapLink(card, item) { const template = card.map && card.map.link if (!template) return null let missing = false const href = String(template).replace(/\{(\w+)\}/g, (_m, key) => { const value = item[key] if (value === null || value === undefined || value === '') { missing = true return '' } return value }) return missing ? null : href } // How many rows a card shows before collapsing the rest behind a count. A card // listing forty PCs is a report someone has to read, not a board someone can // scan - and it pushes every card below it off the screen. Five is enough to // see the shape of the problem; the link goes to the full list. export const MAXROWS = 5 export function visibleRows(card) { return cardRows(card).slice(0, MAXROWS) } export function overflowCount(card) { return Math.max(0, rows(card).length - MAXROWS) } // Chips are the row's own small facts, each carrying a tooltip: a depleted // cartridge shows "Black 4%" and reveals the part number to order on hover. // The percentage says something is wrong; the tooltip says what to do about // it, which otherwise means opening the printer's page to find out. export function mapChips(card, item) { const key = card.map && card.map.chips const list = key ? item[key] : null if (!Array.isArray(list)) return [] return list .filter((chip) => chip && chip.text) .map((chip) => ({ text: chip.text, title: chip.title || '', level: chip.level || '' })) } // A tooltip on the row title, for context that would clutter the line - a // printer's location, say. Never load-bearing: hover is not discoverable and // does not exist on touch, so nothing essential goes here. export function mapTitleTip(card, item) { const key = card.map && card.map.titletooltip return key ? (item[key] || '') : '' } // Floor-plan preview on hover, for rows that carry coordinates. Returns null // when the card declares none or the row has not been placed on the map - an // unplaced printer still belongs on the card, it just has nothing to preview. export function mapHover(card, item) { const spec = card.map && card.map.maphover if (!spec) return null const x = item[spec.x] const y = item[spec.y] if (x === null || x === undefined || y === null || y === undefined) return null return { x, y, label: spec.label ? (item[spec.label] || '') : '' } } export function cardRows(card) { return rows(card).map((item) => ({ title: mapTitle(card, item), titletip: mapTitleTip(card, item), maphover: mapHover(card, item), detail: mapDetail(card, item), detailtip: mapDetailTip(card, item), meta: mapMeta(card, item), chips: mapChips(card, item), link: mapLink(card, item), timestamp: card.map && card.map.timestamp ? item[card.map.timestamp] : null, })) } // A metric card is the count, for the cases where the count IS the story. export function metricValue(card) { const data = card._data if (typeof data === 'number') return data if (data && typeof data.value === 'number') return data.value return rows(card).length } // EMPTY HANDLING is the part that keeps the board readable. A card reporting // "nothing wrong" every day teaches people to stop reading the page - the same // way a fleet log reached 3,234 lines of which 3,217 were one repeated line. // Default is to disappear; 'line' is for the few where absence is itself news. export function cardVisible(card) { if (card._data === null || card._data === undefined) return false // fetch failed const populated = card.render === 'metric' ? metricValue(card) > 0 : rows(card).length > 0 if (populated) return true return card.empty === 'line' } // Sorted by how much it wants a person: critical, then warning, then info, and // by declared position within a severity. Ordering by position alone would let // an info card sit above a failure. const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 } export function sortCards(cards) { return [...cards].sort((a, b) => { const sa = SEVERITY_ORDER[a.severity] ?? 3 const sb = SEVERITY_ORDER[b.severity] ?? 3 if (sa !== sb) return sa - sb return (a.position ?? 99) - (b.position ?? 99) }) } // A card declares the permission its endpoint requires. Filtering here keeps // the dashboard from becoming a way around RBAC, and avoids firing a request // that would only 403. `hasPermission` comes from the auth store. export function permittedCards(cards, hasPermission) { return cards.filter((card) => !card.permission || hasPermission(card.permission)) } // Only cards using a renderer core actually has. An unknown render mode is // skipped rather than drawn blank, so a plugin built against a newer core // degrades instead of leaving a hole on the page. export const RENDERERS = ['exceptions', 'metric', 'list'] export function renderableCards(cards) { return cards.filter((card) => RENDERERS.includes(card.render)) }