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 @@
+
+
+
+
+ {{ card.title }}
+ {{ countOf(card) }}
+
+
+
+ {{ metricValue(card) }}
+
+
+
+ -
+
+ {{ row.title }}
+
+ {{ row.title }}
+ {{ row.detail }}
+ {{ meta.text }}
+
+
+
+ Nothing to action.
+
+
+
+
+
+
+
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...
+
+
+