dashboard: render plugin-declared cards, starting with enforcement failures
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

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:
cproudlock
2026-08-11 13:09:46 -04:00
parent 8b50e6fe2a
commit 05c150c663
4 changed files with 405 additions and 0 deletions

View 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>