dashboard: render plugin-declared cards, starting with enforcement failures
The frontend now calls /api/dashboard/widgets. It never had, which is why five plugins have been declaring widgets into a void for months, pointing at components nobody ever wrote. Core owns three generic renderers - exceptions, metric, list - and a plugin declares data, a shape and a link template. The mapping logic lives in a plain module beside the component, the same split as pluginAssetPanels.js, so it is unit tested without mounting anything: 16 tests covering row mapping, empty handling, ordering and gating. The behaviours worth naming, because each is a decision rather than an implementation detail: Cards fetch INDEPENDENTLY and a failure becomes null. One hung endpoint - a Zabbix call, a plugin mid-upgrade - cannot blank the board. A card whose fetch failed HIDES rather than drawing empty, because "nothing wrong" and "I could not tell" must not look the same. Empty cards disappear by default. A card reporting nothing every day teaches people to stop reading the page, which is precisely how a fleet log reached 3,234 lines with 17 that mattered. A card opts into a one-line presence only when its absence is itself news. Severity outranks position, so an info card can never sit above a failure. Permission filtering happens BEFORE fetching: no point firing a request that would only 403, and the dashboard must not become a way around RBAC. An unknown render mode is skipped, so a plugin built against a newer core degrades instead of leaving a hole. A row whose link substitution is missing keeps the row and drops the link - a PC shopdb does not know still reports its failure, and that is the bay most likely to be misconfigured. Cards sit ABOVE the totals: what needs a person first, context second. The existing stat cards are untouched for now.
This commit is contained in:
127
frontend/src/components/DashboardCards.vue
Normal file
127
frontend/src/components/DashboardCards.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div v-if="visibleCards.length" class="dc-grid">
|
||||
<section
|
||||
v-for="card in visibleCards"
|
||||
:key="card.id"
|
||||
class="dc-card"
|
||||
:class="'dc-' + (card.severity || 'info')"
|
||||
>
|
||||
<header class="dc-head">
|
||||
<h3 class="dc-title">{{ card.title }}</h3>
|
||||
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
|
||||
</header>
|
||||
|
||||
<!-- metric: the count IS the story -->
|
||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
||||
|
||||
<!-- exceptions / list: things that need a person, each linking to itself -->
|
||||
<ul v-else class="dc-rows">
|
||||
<li v-for="(row, index) in cardRows(card)" :key="index" class="dc-row">
|
||||
<router-link v-if="row.link" :to="row.link" class="dc-row-title">
|
||||
{{ row.title }}
|
||||
</router-link>
|
||||
<span v-else class="dc-row-title dc-row-nolink">{{ row.title }}</span>
|
||||
<span v-if="row.detail" class="dc-row-detail">{{ row.detail }}</span>
|
||||
<span v-for="(meta, m) in row.meta" :key="m" class="dc-row-meta"
|
||||
:class="{ mono: meta.mono }">{{ meta.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="!hasRows(card)" class="dc-clear">Nothing to action.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Renders the dashboard cards plugins declare via get_dashboard_widgets
|
||||
// (GET /api/dashboard/widgets). Core owns the renderers; a plugin declares data
|
||||
// and shape. See docs/proposals/dashboard-live-fleet.md.
|
||||
//
|
||||
// Each card fetches INDEPENDENTLY and a failure is swallowed to null, so one
|
||||
// slow or broken endpoint - a hung Zabbix call, a plugin mid-upgrade - cannot
|
||||
// blank the board. A card whose fetch failed is hidden rather than drawn empty,
|
||||
// because an empty card and a broken card must not look the same.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
toApiPath, cardRows, metricValue, cardVisible, sortCards,
|
||||
permittedCards, renderableCards, rows as cardData,
|
||||
} from './dashboardCards'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const cards = ref([])
|
||||
|
||||
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
|
||||
|
||||
function hasRows(card) {
|
||||
return card.render === 'metric' ? metricValue(card) > 0 : cardData(card).length > 0
|
||||
}
|
||||
|
||||
function countOf(card) {
|
||||
return cardData(card).length
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let declared
|
||||
try {
|
||||
const response = await api.get('/dashboard/widgets')
|
||||
declared = response.data.data || []
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter BEFORE fetching: no point firing a request that would only 403, and
|
||||
// the permission gate belongs on both sides regardless.
|
||||
const wanted = renderableCards(
|
||||
permittedCards(declared, (name) => auth.hasPermission(name)))
|
||||
|
||||
cards.value = await Promise.all(wanted.map(async (card) => {
|
||||
try {
|
||||
const response = await api.get(toApiPath(card.endpoint))
|
||||
return { ...card, _data: response.data.data }
|
||||
} catch (err) {
|
||||
return { ...card, _data: null }
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.dc-card {
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border, #e3e3e3);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
/* Severity is carried by the left edge only. A fully coloured card reads as an
|
||||
alert even when it holds one minor row, and six of them read as a crisis. */
|
||||
.dc-critical { border-left-color: #dc3545; }
|
||||
.dc-warning { border-left-color: #ffc107; }
|
||||
.dc-info { border-left-color: #0d6efd; }
|
||||
|
||||
.dc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.dc-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); }
|
||||
.dc-count { font-size: 0.8rem; color: var(--text-light); }
|
||||
.dc-metric { margin: 0.4rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
|
||||
|
||||
.dc-rows { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.dc-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.85rem; }
|
||||
.dc-row-title { font-weight: 600; color: var(--link, #0d6efd); text-decoration: none; }
|
||||
.dc-row-title:hover { text-decoration: underline; }
|
||||
.dc-row-nolink { color: var(--text); }
|
||||
.dc-row-detail { color: var(--text); }
|
||||
.dc-row-meta { color: var(--text-light); }
|
||||
.dc-row-meta.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.dc-clear { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--text-light); }
|
||||
</style>
|
||||
134
frontend/src/components/dashboardCards.js
Normal file
134
frontend/src/components/dashboardCards.js
Normal file
@@ -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))
|
||||
}
|
||||
137
frontend/src/components/dashboardCards.spec.js
Normal file
137
frontend/src/components/dashboardCards.spec.js
Normal file
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,12 @@
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- What needs a person, before the totals that are true every day.
|
||||
Cards are declared by plugins (get_dashboard_widgets) and rendered
|
||||
generically; an empty or failed card hides itself. See
|
||||
docs/proposals/dashboard-live-fleet.md. -->
|
||||
<DashboardCards />
|
||||
|
||||
<!-- Main Stats -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
@@ -92,6 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DashboardCards from '@/components/DashboardCards.vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi, assetsApi, printersApi } from '../api'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user