diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue new file mode 100644 index 0000000..bd414b1 --- /dev/null +++ b/frontend/src/components/DashboardCards.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/src/components/dashboardCards.js b/frontend/src/components/dashboardCards.js new file mode 100644 index 0000000..9c26aab --- /dev/null +++ b/frontend/src/components/dashboardCards.js @@ -0,0 +1,134 @@ +// 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] : '' +} + +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 + return { text: spec.label ? `${spec.label} ${value}` : value, 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 +} + +export function cardRows(card) { + return rows(card).map((item) => ({ + title: mapTitle(card, item), + detail: mapDetail(card, item), + meta: mapMeta(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)) +} diff --git a/frontend/src/components/dashboardCards.spec.js b/frontend/src/components/dashboardCards.spec.js new file mode 100644 index 0000000..1aa0cd9 --- /dev/null +++ b/frontend/src/components/dashboardCards.spec.js @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { + toApiPath, rows, mapMeta, mapLink, cardRows, metricValue, + cardVisible, sortCards, permittedCards, renderableCards, +} from './dashboardCards' + +const failuresCard = { + id: 'geenforce-failures', + render: 'exceptions', + severity: 'critical', + position: 10, + map: { + title: 'hostname', + detail: 'entryname', + meta: [{ key: 'message' }, { key: 'exitcode', label: 'exit' }], + link: '/pcs/{computerid}', + }, +} + +describe('endpoint paths', () => { + it('strips the api prefix the instance already carries', () => { + expect(toApiPath('/api/geenforce/dashboard/failures')) + .toBe('/geenforce/dashboard/failures') + }) + + it('leaves a relative endpoint alone', () => { + expect(toApiPath('/geenforce/x')).toBe('/geenforce/x') + }) +}) + +describe('reading rows from whatever shape the endpoint returns', () => { + it('accepts a bare array, {rows} or {items}', () => { + expect(rows({ _data: [1, 2] })).toEqual([1, 2]) + expect(rows({ _data: { rows: [1] } })).toEqual([1]) + expect(rows({ _data: { items: [1, 2, 3] } })).toEqual([1, 2, 3]) + }) + + it('treats a failed fetch as no rows rather than throwing', () => { + expect(rows({ _data: null })).toEqual([]) + }) +}) + +describe('mapping a row', () => { + it('builds title, detail, meta and link from the declaration', () => { + const card = { ...failuresCard, _data: [{ + hostname: 'WJSF1234', entryname: 'Install OpenText', + exitcode: 1603, message: 'Fatal error', computerid: 42, + }] } + const [row] = cardRows(card) + expect(row.title).toBe('WJSF1234') + expect(row.detail).toBe('Install OpenText') + expect(row.meta.map((m) => m.text)).toEqual(['Fatal error', 'exit 1603']) + expect(row.link).toBe('/pcs/42') + }) + + it('drops empty meta values instead of rendering a stray label', () => { + const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] } + expect(cardRows(card)[0].meta).toEqual([]) + }) + + it('omits the link when the substitution value is missing, keeping the row', () => { + // A PC shopdb does not know still reports its failure - that is the bay + // most likely to be misconfigured. It must not link to /pcs/undefined. + const card = { ...failuresCard, _data: [{ hostname: 'GHOSTPC', computerid: null }] } + const [row] = cardRows(card) + expect(row.title).toBe('GHOSTPC') + expect(row.link).toBeNull() + }) +}) + +describe('empty handling', () => { + it('hides a card with nothing to report by default', () => { + // The whole point: a card saying "nothing wrong" daily trains people to + // stop reading the page. + expect(cardVisible({ render: 'exceptions', _data: [] })).toBe(false) + }) + + it('keeps a card whose absence is itself news when it opts in', () => { + expect(cardVisible({ render: 'exceptions', _data: [], empty: 'line' })).toBe(true) + }) + + it('shows a card that has findings', () => { + expect(cardVisible({ render: 'exceptions', _data: [{ a: 1 }] })).toBe(true) + }) + + it('hides a card whose fetch failed rather than drawing it empty', () => { + expect(cardVisible({ render: 'exceptions', _data: null })).toBe(false) + }) + + it('hides a zero metric but shows a non-zero one', () => { + expect(cardVisible({ render: 'metric', _data: { value: 0 } })).toBe(false) + expect(cardVisible({ render: 'metric', _data: { value: 3 } })).toBe(true) + expect(metricValue({ render: 'metric', _data: { value: 3 } })).toBe(3) + }) +}) + +describe('ordering', () => { + it('puts severity before position, so info never sits above a failure', () => { + const ordered = sortCards([ + { id: 'info-recent', severity: 'info', position: 1 }, + { id: 'crit-failures', severity: 'critical', position: 90 }, + { id: 'warn-toner', severity: 'warning', position: 50 }, + ]) + expect(ordered.map((c) => c.id)) + .toEqual(['crit-failures', 'warn-toner', 'info-recent']) + }) + + it('falls back to position within one severity', () => { + const ordered = sortCards([ + { id: 'b', severity: 'critical', position: 20 }, + { id: 'a', severity: 'critical', position: 10 }, + ]) + expect(ordered.map((c) => c.id)).toEqual(['a', 'b']) + }) +}) + +describe('gating', () => { + it('drops a card the user lacks permission for', () => { + // The dashboard must not become a way around RBAC. + const has = (name) => name === 'printers.view' + const kept = permittedCards([ + { id: 'toner', permission: 'printers.view' }, + { id: 'enforce', permission: 'geenforce.manage' }, + { id: 'open', permission: null }, + ], has) + expect(kept.map((c) => c.id)).toEqual(['toner', 'open']) + }) + + it('skips a render mode this core does not have', () => { + // A plugin built against a newer core degrades instead of leaving a hole. + const kept = renderableCards([ + { id: 'ok', render: 'exceptions' }, + { id: 'future', render: 'sparkline' }, + ]) + expect(kept.map((c) => c.id)).toEqual(['ok']) + }) +}) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 76bdfb6..a3b6a30 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -7,6 +7,12 @@
Loading...