Files
shopdb-flask/frontend/src/components/DashboardCards.vue
Cam Proudlock 43fed63277 Site branch tooling, branch-aware publication, dashboard fixes
Publication now mirrors the branch and writes one bundle per branch. Dashboard:
guests see what they can reach rather than a blank page, and the estate total
lists every type it counts.
2026-08-30 11:19:00 -04:00

455 lines
14 KiB
Vue

<template>
<div class="dc-board">
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
below rather than navigating away: landing on a number that tells you
whether to keep reading is the one thing an RMM board gets right. -->
<div v-if="totalRows" class="dc-attention">
<button
v-for="level in attentionLevels"
:key="level.key"
type="button"
class="dc-att"
:class="['dc-att-' + level.key, { 'dc-att-on': filter === level.key }]"
:aria-pressed="filter === level.key"
@click="toggleFilter(level.key)"
>
<span class="dc-att-n">{{ level.count }}</span>
<span class="dc-att-label">{{ level.label }}</span>
</button>
<button v-if="filter || sourcefilter" type="button" class="dc-att-clear"
@click="clearFilters">
Clear filter
</button>
</div>
<!-- What KIND, next to how bad. When five endpoints go quiet at once,
setting those aside to look at the crashes is the difference between a
board and a list. Chips reflect the severity filter above them, so the
two read as one control rather than two that can contradict. -->
<div v-if="facets.length > 1" class="dc-facets">
<button
v-for="facet in facets"
:key="facet.key"
type="button"
class="dc-facet"
:class="['dc-facet-' + facet.severity, { 'dc-facet-on': sourcefilter === facet.key }]"
:aria-pressed="sourcefilter === facet.key"
@click="toggleSource(facet.key)"
>
{{ facet.label }}
<span class="dc-facet-n">{{ facet.count }}</span>
</button>
</div>
<!-- A check that cannot answer. Not a queue row: there is no asset to act
on, and burying "the monitor is dead" among the things it failed to
find would be the same mistake in a new place. -->
<p v-for="card in unavailable" :key="card.id" class="dc-unavailable">
<span class="dc-unavailable-dot" aria-hidden="true"></span>
{{ card.title }}: {{ reasonFor(card) }}
</p>
<div class="dc-cols">
<!-- ONE queue, worst first, instead of a card per source. Reading order
is work order. -->
<section v-if="canseechecks && (queue.length || overflow.length)"
class="dc-panel dc-queue">
<header class="dc-panel-head">
<h3>Needs attention</h3>
<span class="dc-panel-count">{{ queueTotal }}</span>
</header>
<ul class="dc-rows">
<DashboardRow
v-for="row in queue"
:key="row.key"
:row="row"
:severity="row.severity"
showsource
/>
</ul>
<!-- What the board could not fit, per source and with somewhere to go.
A bare "and 45 more" that leads nowhere is worse than silence. -->
<p v-for="entry in overflow" :key="entry.id" class="dc-more">
<router-link v-if="entry.viewall" :to="entry.viewall" class="dc-more-link">
{{ entry.hidden }} more {{ entry.title.toLowerCase() }}
</router-link>
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
</p>
</section>
<!-- Only claim the board is clear to somebody who can actually see the
checks. A reader with no permission for any of them is not looking at
a healthy fleet, they are looking at nothing - and telling them
"nothing needs attention" is the same lie as reporting Low Supplies 0
while the monitoring is down. -->
<section v-else-if="canseechecks" class="dc-panel dc-clear">
<p class="dc-clear-line">Nothing needs attention.</p>
</section>
<aside class="dc-rail">
<!-- The page owns what sits above the context cards: inventory is not
an exception, so it is not this component's to declare. -->
<slot name="rail"></slot>
<section v-for="card in context" :key="card.id" class="dc-panel">
<header class="dc-panel-head">
<h3>{{ card.title }}</h3>
<span class="dc-panel-count">{{ countOf(card) }}</span>
</header>
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<ul v-else class="dc-rows">
<DashboardRow
v-for="(row, index) in visibleRows(card)"
:key="index"
:row="row"
:stacked="card.layout === 'stacked'"
/>
</ul>
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
</aside>
</div>
</div>
</template>
<script setup>
// The fleet dashboard. Plugins declare cards via get_dashboard_widgets
// (GET /api/dashboard/widgets); core owns the drawing. See
// docs/proposals/dashboard-live-fleet.md.
//
// LAYOUT: this used to be one card per source in a fixed grid, and both halves
// hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card beside it left a hole; and a
// box per source flattened priority, because an enforcement failure and a
// warranty notice look equally urgent when each owns a card. Now the
// critical and warning rows merge into one worst-first queue, and info cards
// sit in a rail as context. The severity driving it was always declared - the
// old layout just threw it away.
//
// 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 DashboardRow from '@/components/DashboardRow.vue'
import {
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
permittedCards, renderableCards, rows as cardData,
queueRows, queueOverflow, contextCards, severityTotals,
sourceFacets, unavailableCards, cardUnavailable,
} from './dashboardCards'
const auth = useAuthStore()
const cards = ref([])
// Severity filter from the rollup. Empty means show everything.
const filter = ref('')
const liveCards = computed(() => cards.value.filter(cardVisible))
const totals = computed(() => severityTotals(liveCards.value))
const attentionLevels = computed(() => {
const totalcounts = totals.value
return [
{ key: 'critical', label: 'Critical', count: totalcounts.critical },
{ key: 'warning', label: 'Needs a look', count: totalcounts.warning },
{ key: 'info', label: 'For information', count: totalcounts.info },
].filter((level) => level.count > 0)
})
const totalRows = computed(
() => attentionLevels.value.reduce((sum, level) => sum + level.count, 0))
// The filter narrows what is already on the board rather than refetching: the
// counts and the rows then cannot disagree.
const filteredCards = computed(() => (filter.value
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
: liveCards.value))
// Which check to show, on top of the severity filter. Both narrow what is
// already loaded, so no combination can disagree with the counts.
const sourcefilter = ref('')
const facets = computed(() => sourceFacets(filteredCards.value))
const sourceFiltered = computed(() => (sourcefilter.value
? filteredCards.value.filter((card) => card.id === sourcefilter.value)
: filteredCards.value))
const unavailable = computed(() => unavailableCards(liveCards.value))
const queue = computed(() => queueRows(sourceFiltered.value))
const overflow = computed(() => queueOverflow(sourceFiltered.value))
const context = computed(
() => contextCards(filteredCards.value).filter((card) => !cardUnavailable(card)))
const queueTotal = computed(
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
// How many cards this caller is ALLOWED to see, independent of whether they
// currently hold rows. Zero means no visibility into exceptions at all, which
// must not be drawn as "nothing wrong".
const permittedcount = ref(0)
const canseechecks = computed(() => permittedcount.value > 0)
function toggleFilter(level) {
filter.value = filter.value === level ? '' : level
// A source chip that the new severity filter excludes would leave the queue
// empty with two controls both looking active and neither obviously wrong.
if (sourcefilter.value && !facets.value.some((f) => f.key === sourcefilter.value)) {
sourcefilter.value = ''
}
}
function toggleSource(key) {
sourcefilter.value = sourcefilter.value === key ? '' : key
}
function clearFilters() {
filter.value = ''
sourcefilter.value = ''
}
function reasonFor(card) {
return cardUnavailable(card)
}
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)))
permittedcount.value = wanted.length
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-board { margin-bottom: 1.75rem; }
/* ---- rollup ---- */
.dc-attention {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1rem;
}
.dc-att {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font: inherit;
color: inherit;
}
.dc-att:hover { border-color: var(--text-light); }
.dc-att-on { box-shadow: 0 0 0 2px var(--primary) inset; }
.dc-att-critical { border-left-color: var(--danger); }
.dc-att-warning { border-left-color: var(--warning); }
.dc-att-info { border-left-color: var(--primary); }
.dc-att-n {
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.dc-att-critical .dc-att-n { color: var(--danger); }
.dc-att-warning .dc-att-n { color: var(--warning); }
.dc-att-info .dc-att-n { color: var(--primary); }
.dc-att-label { font-size: 0.85rem; color: var(--text-light); }
.dc-att-clear {
background: none;
border: none;
color: var(--link);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0 0.5rem;
}
/* ---- source chips ---- */
.dc-facets {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
}
.dc-facet {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.2rem 0.7rem;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
color: var(--text-light);
}
.dc-facet:hover { border-color: var(--text-light); }
.dc-facet-on {
color: var(--text);
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
}
.dc-facet-critical { border-left: 3px solid var(--danger); }
.dc-facet-warning { border-left: 3px solid var(--warning); }
.dc-facet-n {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
}
/* ---- a check that cannot answer ---- */
.dc-unavailable {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
padding: 0.5rem 0.8rem;
border: 1px solid var(--border);
border-left: 3px solid var(--warning);
border-radius: 6px;
background: var(--bg-card);
font-size: 0.85rem;
color: var(--text-light);
}
.dc-unavailable-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--warning);
flex: none;
}
/* ---- two columns ---- */
.dc-cols {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 120rem;
}
/* Context sits BELOW the queue, in a row, rather than in a side rail. The
queue then gets the full width - which is what "not enough room to relay the
data" was about - and a quiet day leaves no column-shaped void where the
queue ran out of rows. */
.dc-rail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 1rem;
align-items: start;
min-width: 0;
}
/* ---- panels ---- */
.dc-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
min-width: 0;
/* Nothing escapes the panel: a printer row carrying three cartridge readings
plus a location was pushing past the border, because a flex child will not
shrink below its content unless told to. */
overflow: hidden;
}
.dc-panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.dc-panel-head h3 {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1 1 auto;
}
.dc-panel-count {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
font-variant-numeric: tabular-nums;
}
.dc-metric {
margin: 0.5rem 0 0;
font-size: 2rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.dc-rows { list-style: none; margin: 0; padding: 0; }
.dc-more {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--text-light);
}
.dc-more-link { color: var(--link); text-decoration: none; }
.dc-more-link:hover { text-decoration: underline; }
.dc-clear-line {
margin: 0.25rem 0;
color: var(--text-light);
}
</style>