Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours

This commit is contained in:
benjamin.baker
2026-08-30 14:41:56 -04:00
53 changed files with 3671 additions and 483 deletions

View File

@@ -1,16 +1,32 @@
<template>
<div class="relationships-section">
<div class="section-header">
<h3 class="section-title">Relationships</h3>
<h3
class="section-title rel-header"
role="button"
tabindex="0"
:aria-expanded="!collapsed"
@click="collapsed = !collapsed"
@keydown.enter.prevent="collapsed = !collapsed"
@keydown.space.prevent="collapsed = !collapsed"
>
<span class="rel-caret" :class="{ collapsed }" aria-hidden="true">&#9662;</span>
<span>Relationships</span>
<!-- The count is what makes a collapsed card honest: without it, "no
relationships" and "four relationships you cannot see" look the
same from the outside. -->
<span v-if="!loading && relationshipCount" class="rel-count">{{ relationshipCount }}</span>
</h3>
<button
v-if="authStore.isAuthenticated"
class="btn btn-sm btn-primary"
@click="showAddModal = true"
@click.stop="openAdd"
>
Add Relationship
</button>
</div>
<div v-show="!collapsed">
<div v-if="loading" class="loading">Loading relationships...</div>
<div v-else-if="!hasRelationships" class="empty-state">
@@ -96,6 +112,7 @@
</div>
</div>
</template>
</div>
<!-- Add Relationship Modal -->
<div v-if="showAddModal" class="modal-overlay">
@@ -186,6 +203,7 @@
</template>
<script setup>
import { assetRoute } from '@/utils/assetRoute'
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
@@ -224,6 +242,22 @@ function colorForType(name) {
}
const showAddModal = ref(false)
// Collapsed by default. The triage band above answers the common case - what
// this asset serves or is driven by - so the full edge list is reference rather
// than the first thing to read. It is NOT redundant with the band and is not
// removed: it is the only surface with Add and delete, and it keeps each edge's
// exact type where the band deduplicates by asset. On a printer, four rows
// (usesprinter and defaultprinter, per machine) collapse to two band chips, and
// which machine DEFAULTS to it is a distinction the band deliberately drops.
const collapsed = ref(true)
function openAdd() {
// Adding from a collapsed card would otherwise drop the new row behind the
// caret, which reads as the add having failed.
collapsed.value = false
showAddModal.value = true
}
// New relationship form
const newRel = ref({
direction: 'outgoing',
@@ -304,6 +338,14 @@ const directionalItems = computed(() => {
return items
})
// Counts what the card RENDERS, not raw edges. A symmetric link (Dualpath) is
// stored in both directions in the legacy shape, so it is two rows in
// outgoing+incoming and one "Connected" entry on screen. Counting edges
// displayed 3 on a machine that shows 2 when you expand it, which is precisely
// the mismatch this badge exists to avoid.
const relationshipCount = computed(
() => connectedItems.value.length + directionalItems.value.length)
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
@@ -472,28 +514,7 @@ function getAssetIcon(assettype) {
return icons[assettype] || Package
}
function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// pluginid is the extension-table id the detail routes key on
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
const getAssetRoute = assetRoute
</script>
<style scoped>
@@ -522,6 +543,38 @@ function getAssetRoute(asset) {
font-weight: 600;
}
/* Matches the collapsible panels on the same page (PluginAssetPanels) so a
card does not gain a second, slightly different caret. */
.rel-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.rel-caret {
font-size: 0.7rem;
color: var(--text-light);
transition: transform 0.15s ease;
}
.rel-caret.collapsed { transform: rotate(-90deg); }
.rel-count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-light);
background: var(--bg-hover, rgba(127, 127, 127, 0.14));
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.rel-caret { transition: none; }
}
.empty-state {
color: var(--text-light);
font-style: italic;

View File

@@ -8,17 +8,31 @@
</div>
<div v-if="machines.length" class="triage-item">
<span class="triage-label">{{ machines.length === 1 ? 'Serves machine' : 'Serves machines' }}</span>
<span class="triage-label">{{ counterpartheading }}</span>
<span class="triage-values">
<router-link
v-for="machine in machines"
:key="machine.assetid"
class="triage-chip link"
:to="`/assets/${machine.assetid}`"
:to="assetRoute(machine)"
>{{ machine.assetnumber || machine.name }}</router-link>
</span>
</div>
<div v-for="item in extraitems" :key="item.label" class="triage-item">
<span class="triage-label">{{ item.label }}</span>
<span class="triage-values">
<component
v-for="link in item.links"
:key="link.text"
:is="link.to ? 'router-link' : 'span'"
:to="link.to || undefined"
class="triage-chip"
:class="{ link: Boolean(link.to) }"
>{{ link.text }}</component>
</span>
</div>
<div v-if="accessmethods && accessmethods.length" class="triage-item">
<span class="triage-label">Remote access</span>
<span class="triage-values">
@@ -63,6 +77,7 @@
*/
import { ref, computed, onMounted, watch, useSlots } from 'vue'
import api from '@/api'
import { assetRoute } from '@/utils/assetRoute'
const props = defineProps({
assetid: { type: Number, required: true },
@@ -71,23 +86,51 @@ const props = defineProps({
// Shape: { level: 'ok'|'warning'|'critical'|'unknown', label: 'Reporting, 4m ago' }
liveness: { type: Object, default: null },
accessmethods: { type: Array, default: () => [] },
locationname: { type: String, default: '' }
locationname: { type: String, default: '' },
// Which side of a 'controls' edge this page wants to see, by asset type
// name. A PC page wants the machines it serves; a machine page wants the
// PCs that drive it. Defaults to 'machine' so the PC page - the only
// caller when this was written - keeps its exact behaviour.
counterparttype: { type: String, default: 'machine' },
// Overrides the heading. Empty keeps the shop-floor phrasing that earned
// its place: a PC matters because a machine stops without it.
counterpartlabel: { type: String, default: '' },
// Which relationship types link this asset to its counterpart. Not every
// pairing is 'controls': a PC controls a machine, but a machine reaches a
// printer through usesprinter and defaultprinter. Hardcoding 'controls'
// left the band blank on the printer page, which is the one page where the
// link is not obvious from the asset itself.
relationshiptypes: { type: Array, default: () => ['controls'] },
// Triage facts a page knows but core cannot derive from a relationship
// edge. The machine page's dual-bay sibling is the case this exists for:
// it is resolved by the machines plugin, not from the relationships
// endpoint, and it used to sit in its own banner above the hero - two
// strips of chrome saying two halves of the same "what am I looking at".
// Shape: [{ label, links: [{ text, to }] }]. A link with no `to` renders
// as plain text rather than a dead chip.
extraitems: { type: Array, default: () => [] }
})
const machines = ref([])
const counterpartheading = computed(() => {
if (props.counterpartlabel) return props.counterpartlabel
return machines.value.length === 1 ? 'Serves machine' : 'Serves machines'
})
const slots = useSlots()
const hasAnything = computed(() =>
Boolean(slots.actions) ||
Boolean(props.liveness) ||
machines.value.length > 0 ||
props.extraitems.length > 0 ||
(props.accessmethods && props.accessmethods.length > 0) ||
Boolean(props.locationname)
)
onMounted(loadMachines)
watch(() => props.assetid, loadMachines)
watch(() => [props.assetid, props.counterparttype, props.relationshiptypes], loadMachines)
async function loadMachines() {
machines.value = []
@@ -96,9 +139,11 @@ async function loadMachines() {
const response = await api.get(`/assets/${props.assetid}/relationships`)
const data = response.data.data || {}
const found = []
// BOTH directions. The canonical edge is PC controls machine, but
// production also holds reciprocal rows, so reading one direction shows
// the link on half the pages and not the other half.
// BOTH directions, then deduped. Production holds reciprocal rows for
// the same link, so reading one direction shows it on half the pages and
// not the other half - and reading both without deduping shows it twice.
// Direction is therefore not trustworthy here and is deliberately not
// used to label anything.
for (const edge of data.outgoing || []) collect(edge, edge.targetasset, found)
for (const edge of data.incoming || []) collect(edge, edge.sourceasset, found)
machines.value = found
@@ -111,10 +156,19 @@ async function loadMachines() {
function collect(edge, other, found) {
if (!other) return
if ((edge.relationshiptypename || '').toLowerCase() !== 'controls') return
if ((other.assettypename || '').toLowerCase() !== 'machine') return
const wanted = props.relationshiptypes.map(t => t.toLowerCase())
if (!wanted.includes((edge.relationshiptypename || '').toLowerCase())) return
if ((other.assettypename || '').toLowerCase() !== props.counterparttype.toLowerCase()) return
if (found.some(m => m.assetid === other.assetid)) return
found.push({ assetid: other.assetid, assetnumber: other.assetnumber, name: other.name })
// pluginid and assettypename are what assetRoute() needs; without them the
// chip falls back to /assets/<assetid>, which is not a route.
found.push({
assetid: other.assetid,
assetnumber: other.assetnumber,
name: other.name,
pluginid: other.pluginid,
assettypename: other.assettypename
})
}
</script>

View File

@@ -1,71 +1,142 @@
<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">
<span class="dc-dot" aria-hidden="true"></span>
<h3 class="dc-title">{{ card.title }}</h3>
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
</header>
<div class="dc-board">
<!-- metric: the count IS the story -->
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<!-- 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>
<!-- exceptions / list: one line per thing, each linking to itself -->
<ul v-else class="dc-rows">
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
<!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
<!-- 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 class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</ul>
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
</p>
</section>
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
quiet and leaving them to find the list is worse than not saying it.
A card without a viewall still says the count, because the number
itself is information. -->
<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>
<!-- 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>
// 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.
// 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
@@ -74,16 +145,89 @@
import { ref, computed, onMounted } from 'vue'
import api from '../api'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import DashboardRow from '@/components/DashboardRow.vue'
import {
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
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([])
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
// 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
@@ -103,6 +247,8 @@ async function load() {
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))
@@ -118,154 +264,191 @@ defineExpose({ load })
</script>
<style scoped>
.dc-grid {
display: grid;
/* Four across on a wide screen. A card holds a hostname, a machine number
and a state on one line, so the track cannot go much below this without
truncating the rows that matter most - but 28rem only fits three once the
sidebar takes its share at 1920, which is the common case here. auto-fit
rather than auto-fill so two cards fill the width instead of leaving
empty tracks. */
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
max-width: 120rem;
gap: 1rem;
margin-bottom: 1.75rem;
align-items: start;
.dc-board { margin-bottom: 1.75rem; }
/* ---- rollup ---- */
.dc-attention {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1rem;
}
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
alone this reaches five on an ultrawide, and a fifth column buys nothing -
the cards get narrower and the rows they hold start truncating again. */
@media (min-width: 96rem) {
.dc-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.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-card {
.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;
/* Nothing escapes the card. A printer row carrying three cartridge readings
plus a location was pushing past the border: a flex child will not shrink
below its content width unless told to, so text-overflow never engaged and
the row simply overflowed. min-width:0 below is what actually enables the
ellipsis; this is the backstop. */
overflow: hidden;
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;
}
/* Severity is a small dot beside the title, not a coloured card or a thick
bar. A card painted by severity reads as an alert even when it holds one
minor row, and six of them read as a crisis - which is how a board stops
being read at all. */
.dc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
.dc-critical .dc-dot { background: var(--danger); }
.dc-warning .dc-dot { background: var(--warning); }
.dc-info .dc-dot { background: var(--primary); }
.dc-head {
.dc-panel-head {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.35rem;
}
.dc-title {
.dc-panel-head h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1;
flex: 1 1 auto;
}
.dc-count {
font-size: 0.8rem;
.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-metric { margin: 0.5rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
/* One row per line, not a wrapped paragraph. Each row is title / detail /
meta on a single line that truncates, so ten rows are ten scannable lines
rather than a block of text that has to be read. */
.dc-rows { list-style: none; margin: 0; padding: 0; }
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
to make room for text that is then truncated anyway, and neither reads. */
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-title {
font-weight: 600;
color: var(--link);
text-decoration: none;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
.dc-row-detail {
color: var(--text);
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Meta is pushed right and allowed to disappear first: it is the least
important part of the line, and letting it wrap is what made rows look
like paragraphs. */
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
/* Shrinks and truncates before the title or detail do: it is the least
important part of the line, and it was the part running off the edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* One chip per depleted supply. Bordered rather than filled: a row of solid
red pills reads as an emergency even when a cartridge is merely low. */
.dc-chip {
flex: none;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.75rem;
color: var(--text);
white-space: nowrap;
cursor: help;
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
.dc-more {
display: block;
margin: 0.5rem 0 0;
font-size: 0.8rem;
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>

View File

@@ -0,0 +1,154 @@
<template>
<li class="dc-row" :class="{ 'dc-row-stacked': stacked }">
<span v-if="severity" class="dc-row-dot" :class="'dc-dot-' + severity" aria-hidden="true"></span>
<!-- With coordinates, the name carries the same floor-plan preview the
asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title" :title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<!-- Which check produced this row. Only in the merged queue, where rows
from different sources sit together and the title alone no longer says
what is wrong with the thing. -->
<span v-if="showsource && row.source" class="dc-row-source"
:title="row.source">{{ row.source }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta && row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</template>
<script setup>
/**
* One row of the dashboard board.
*
* Extracted from DashboardCards.vue when the six per-source cards became one
* merged queue plus a context rail: both render the same row, and the map
* tooltip, link fallbacks and chips are too easy to get subtly different in a
* second copy.
*/
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
defineProps({
row: { type: Object, required: true },
// Severity dot, drawn only in the merged queue. In a single-source panel the
// panel header already says the severity, and a dot on every row is noise.
severity: { type: String, default: '' },
showsource: { type: Boolean, default: false },
stacked: { type: Boolean, default: false },
})
</script>
<style scoped>
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
flex: none;
align-self: center;
}
.dc-dot-critical { background: var(--danger); }
.dc-dot-warning { background: var(--warning); }
.dc-dot-info { background: var(--primary); }
.dc-row-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
min-width: 0;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
/* Which check produced a queued row. Cards declare a `shortname` for this,
because their `title` is a card header - "Endpoints with crashes or
shutdowns" - and as a row badge it ran wider than the hostname it qualified,
putting the badge rather than the asset at the start of every line.
Truncating the header instead was worse: four consecutive rows reading
"ENDPOINTS WITH CR..." carry no information at all, and the badge's one real
job is separating the two checks that read alike, PCs gone silent and
endpoints gone quiet. The cap stays as a guard for a plugin that declares no
shortname. */
.dc-row-source {
flex: 0 1 auto;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.68rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-light);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
}
.dc-row-detail {
color: var(--text-light);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
flex: none;
font-variant-numeric: tabular-nums;
}
.dc-chip {
flex: none;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
color: var(--text-light);
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
</style>

View File

@@ -31,8 +31,25 @@
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
<template v-if="rows(panel).length">
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
<!-- Filter chips for any list panel that shows a health dot. Client
side over rows already fetched, so the counts cannot disagree with
what is on screen. -->
<div v-if="facets(panel).length > 1" class="pap-facets">
<button
v-for="facet in facets(panel)"
:key="facet.value"
type="button"
class="pap-facet"
:class="{ on: isFacetOn(panel, facet.value) }"
@click="toggleFacet(panel, facet.value)"
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
type="button" class="pap-facet pap-facet-clear"
@click="facetSelection[panel.id] = []">Clear</button>
</div>
<template v-if="visibleRows(panel).length">
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge"
@@ -59,6 +76,9 @@
{{ actionError[panel.id] }}
</div>
</template>
<div v-else-if="rows(panel).length" class="pap-empty">
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
</div>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
const facetSelection = ref({})
function isFacetOn(panel, value) {
return (facetSelection.value[panel.id] || []).includes(value)
}
function toggleFacet(panel, value) {
const current = facetSelection.value[panel.id] || []
facetSelection.value = {
...facetSelection.value,
[panel.id]: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
}
}
function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
})
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.pap-facet {
font: inherit;
font-size: 0.78rem;
line-height: 1.6;
padding: 0.05rem 0.5rem;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text-light);
cursor: pointer;
}
.pap-facet:hover { border-color: var(--primary); }
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
.pap-facet-clear { border-style: dashed; }
/* The count is the point: a red dot over a list of info rows is confusing
until the chip says "Critical 2" and one click shows them. */
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
.pap-manage { font-size: 0.82rem; }
/* Two aligned columns rather than space-between: a ragged right edge is hard
to scan once a panel carries more than a handful of rows, and a long value

View File

@@ -51,6 +51,26 @@ export function mapDetailTip(card, item) {
return full && full !== mapDetail(card, item) ? full : ''
}
// "silent 1 days". A suffix is written for the common case, which is plural,
// and the card declaring it has no way to know the value. One row saying it was
// a shrug; the merged queue puts every quiet endpoint on one screen, so it now
// says it five times in a column and reads as broken.
//
// Trims a trailing 's' at exactly 1, on the FIRST word only, keeping whatever
// follows it: ' events (48h)' becomes ' event (48h)'.
//
// The length floor is not arbitrary. It stops abbreviated units being mangled,
// since 'ms' and 'hrs' end in s and are not plurals - a test asserting ' ms'
// stays ' ms' is what caught that. The first version also refused multiword
// suffixes outright to avoid chopping the wrong token, which was too blunt:
// it left ' events (48h)' plural, and capturing the word fixes the same hazard
// properly.
export function singularise(suffix, value) {
const text = suffix || ''
if (Number(value) !== 1) return text
return text.replace(/^ ([a-z]{3,})s(?=$|[ (])/, ' $1')
}
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
@@ -59,7 +79,7 @@ export function mapMeta(card, item) {
if (value === '') return null
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
// where 'quiet for 3' reads as a truncation.
const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}`
const text = `${spec.label ? spec.label + ' ' : ''}${value}${singularise(spec.suffix, value)}`
return { text, mono: !!spec.mono }
})
.filter(Boolean)
@@ -164,12 +184,30 @@ export function metricValue(card) {
// "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.
// Why a card has no rows, when it knows. "Nothing is low" and "we cannot tell
// whether anything is low" are different answers, and a card that gives the
// same empty for both is not merely unhelpful - it reads as reassurance. The
// printer supplies card returns this when Zabbix is unconfigured or
// unreachable, which is how a dead monitor used to render as "Low Supplies 0".
export function cardUnavailable(card) {
const data = card._data
if (!data || Array.isArray(data)) return ''
return typeof data.unavailable === 'string' ? data.unavailable : ''
}
export function unavailableCards(cards) {
return cards.filter((card) => cardUnavailable(card))
}
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
// A card that cannot answer says so, whatever its empty rule: 'hide' means
// "nothing to report", and this is not nothing to report.
if (cardUnavailable(card)) return true
return card.empty === 'line'
}
@@ -202,3 +240,138 @@ export const RENDERERS = ['exceptions', 'metric', 'list']
export function renderableCards(cards) {
return cards.filter((card) => RENDERERS.includes(card.render))
}
// ---------------------------------------------------------------------------
// THE MERGED QUEUE
//
// The board used to be one card per source in a fixed grid, and both halves of
// that hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card sat beside it leaving a hole;
// and giving each source its own box flattened priority, because an enforcement
// failure and a warranty notice look equally urgent when each owns a card.
//
// Merging them into one list sorted worst-first makes reading order the work
// order. The severity that drives it is already declared per card - this only
// stops the layout from throwing it away.
// ---------------------------------------------------------------------------
// Severities that are somebody's job right now, in the order they are worked.
export const QUEUE_SEVERITIES = ['critical', 'warning']
// Rows one source may contribute to the queue. Without a per-source cap, fifty
// expiring warranties bury two crashed PCs; the cap is what keeps a noisy
// source from crowding out a rare one.
export const QUEUEPERSOURCE = 5
// Total rows on the board. Past this it is a report, not something to scan.
export const QUEUEMAX = 12
function severityRank(severity) {
const rank = SEVERITY_ORDER[severity]
return rank === undefined ? 3 : rank
}
/**
* One flat, worst-first list of rows from every queue-severity card.
*
* Each row keeps the fields its own card mapped, plus where it came from, so a
* merged row can still say which check produced it.
*/
/**
* The checks currently contributing to the queue, for the filter chips:
* [{key, label, count}] ordered worst-first then biggest-first.
*
* Severity answers "how bad"; this answers "what kind". On a fleet where five
* endpoints go quiet at once, being able to set those aside and look at the
* crashes is the difference between a board and a list.
*/
export function sourceFacets(cards) {
const seen = new Map()
for (const card of sortCards(cards.filter(
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
const count = rows(card).length
if (!count) continue
seen.set(card.id, {
key: card.id,
label: card.shortname || card.title || card.id,
count,
severity: card.severity || 'info',
})
}
return [...seen.values()]
}
export function queueRows(cards) {
const out = []
for (const card of sortCards(cards.filter(
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
const taken = cardRows(card).slice(0, QUEUEPERSOURCE)
taken.forEach((row, index) => {
out.push({
...row,
key: card.id + ':' + index,
severity: card.severity || 'info',
// The badge in a merged queue. Cards declare `shortname` for this:
// their `title` is a card HEADER ("Endpoints with crashes or
// shutdowns") and ran wider than the hostname it qualified, putting the
// badge rather than the asset at the start of every line. Falls back to
// the title so a plugin that has not declared one still reads.
source: card.shortname || card.title || '',
sourceid: card.id,
viewall: card.viewall || '',
})
})
}
return out.slice(0, QUEUEMAX)
}
/**
* What the queue could not show, per source: [{id, title, hidden, viewall}].
*
* Kept per source rather than as one number because the number alone is not
* actionable - "and 45 more" with no way to reach them is worse than silence,
* and each source has its own list to link to.
*/
export function queueOverflow(cards) {
const shown = {}
for (const row of queueRows(cards)) {
shown[row.sourceid] = (shown[row.sourceid] || 0) + 1
}
return cards
.filter((card) => QUEUE_SEVERITIES.includes(card.severity))
.map((card) => ({
id: card.id,
title: card.title || '',
hidden: Math.max(0, rows(card).length - (shown[card.id] || 0)),
viewall: card.viewall || '',
}))
.filter((entry) => entry.hidden > 0)
.sort((a, b) => b.hidden - a.hidden)
}
/**
* Cards that are context rather than work - the info severity. They keep their
* own list rendering in a rail beside the queue, because 50 expiring warranties
* are worth knowing about and are not worth pushing a crashed PC down the page.
*/
export function contextCards(cards) {
return sortCards(cards.filter(
(card) => !QUEUE_SEVERITIES.includes(card.severity)))
}
/**
* Row counts by severity, for the rollup above the queue: {critical, warning,
* info}. Counts ROWS, not cards - three cards holding one row each is three
* things to do, not three.
*/
export function severityTotals(cards) {
const totals = { critical: 0, warning: 0, info: 0 }
for (const card of cards) {
const severity = card.severity || 'info'
if (totals[severity] === undefined) continue
totals[severity] += card.render === 'metric'
? metricValue(card)
: rows(card).length
}
return totals
}

View File

@@ -3,6 +3,9 @@ import {
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
cardVisible, sortCards, permittedCards, renderableCards,
visibleRows, overflowCount,
queueRows, queueOverflow, contextCards, severityTotals,
QUEUEPERSOURCE, QUEUEMAX, singularise,
cardUnavailable, unavailableCards, sourceFacets,
} from './dashboardCards'
const failuresCard = {
@@ -226,3 +229,242 @@ describe('stacked rows', () => {
.toBe('Start of a long message that continues well past the card')
})
})
// ---------------------------------------------------------------------------
// The merged queue. These exist because the old grid failed in ways that were
// invisible: a card truncated at 5 of 50 and nothing said where the rest went,
// and severity was declared but the layout ignored it.
// ---------------------------------------------------------------------------
const critA = {
id: 'endpoints-stability', title: 'Endpoints with crashes', render: 'exceptions',
severity: 'critical', position: 20, viewall: '/pcs',
map: { title: 'hostname' },
_data: [{ hostname: 'PC-CMM-TEST' }, { hostname: 'HOST-7' }],
}
const critB = {
id: 'geenforce-failures', title: 'Enforcement failures', render: 'exceptions',
severity: 'critical', position: 10, viewall: '/geenforce',
map: { title: 'hostname' },
_data: [{ hostname: 'WJPC0615' }],
}
const warnA = {
id: 'computers-quiet', title: 'PCs gone silent', render: 'exceptions',
severity: 'warning', position: 30,
map: { title: 'hostname' },
_data: [{ hostname: 'FFBWTH63' }],
}
const infoA = {
id: 'warranty-expiring', title: 'Warranties expiring', render: 'exceptions',
severity: 'info', position: 40, viewall: '/reports/warranty',
map: { title: 'assetnumber' },
_data: Array.from({ length: 50 }, (_, i) => ({ assetnumber: 'A' + i })),
}
describe('queueRows', () => {
it('merges every queue card into one list, worst first', () => {
const queue = queueRows([warnA, critA, critB])
expect(queue.map((r) => r.severity)).toEqual(
['critical', 'critical', 'critical', 'warning'])
})
it('orders by declared position within a severity', () => {
// geenforce is position 10, endpoints 20: a failure outranks a crash.
const queue = queueRows([critA, critB])
expect(queue[0].title).toBe('WJPC0615')
})
it('keeps the source on every row, so a merged row can still say why', () => {
const queue = queueRows([critB])
expect(queue[0].source).toBe('Enforcement failures')
expect(queue[0].sourceid).toBe('geenforce-failures')
expect(queue[0].viewall).toBe('/geenforce')
})
it('leaves info cards out - they are context, not work', () => {
const queue = queueRows([critB, infoA])
expect(queue).toHaveLength(1)
expect(queue.every((r) => r.severity !== 'info')).toBe(true)
})
it('caps one source so a noisy check cannot bury a rare one', () => {
const noisy = { ...critA, _data: Array.from({ length: 40 }, (_, i) => ({ hostname: 'H' + i })) }
const queue = queueRows([noisy, warnA])
expect(queue.filter((r) => r.sourceid === 'endpoints-stability')).toHaveLength(QUEUEPERSOURCE)
// the warning still made it onto the board
expect(queue.some((r) => r.sourceid === 'computers-quiet')).toBe(true)
})
it('caps the whole board', () => {
const many = Array.from({ length: 6 }, (_, i) => ({
...critA, id: 'c' + i, position: i,
_data: Array.from({ length: 5 }, (_, j) => ({ hostname: 'H' + i + j })),
}))
expect(queueRows(many)).toHaveLength(QUEUEMAX)
})
it('gives every row a stable key', () => {
const keys = queueRows([critA, critB]).map((r) => r.key)
expect(new Set(keys).size).toBe(keys.length)
})
})
describe('queueOverflow', () => {
it('reports what was hidden, per source, with somewhere to go', () => {
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
const [entry] = queueOverflow([noisy])
expect(entry.hidden).toBe(7) // 12 rows, 5 shown
expect(entry.viewall).toBe('/pcs')
expect(entry.title).toBe('Endpoints with crashes')
})
it('says nothing when everything is on the board', () => {
expect(queueOverflow([critB, warnA])).toEqual([])
})
it('ignores info cards, which are not in the queue to overflow from', () => {
expect(queueOverflow([infoA])).toEqual([])
})
it('lists the biggest backlog first', () => {
const a = { ...critA, id: 'a', _data: Array.from({ length: 8 }, (_, i) => ({ hostname: 'x' + i })) }
const b = { ...critA, id: 'b', _data: Array.from({ length: 20 }, (_, i) => ({ hostname: 'y' + i })) }
expect(queueOverflow([a, b])[0].id).toBe('b')
})
})
describe('contextCards', () => {
it('is the info cards, sorted', () => {
const context = contextCards([critA, infoA, warnA])
expect(context.map((c) => c.id)).toEqual(['warranty-expiring'])
})
})
describe('severityTotals', () => {
it('counts ROWS, not cards', () => {
// Three cards holding one row each is three things to do, not three cards.
const totals = severityTotals([critA, critB, warnA, infoA])
expect(totals).toEqual({ critical: 3, warning: 1, info: 50 })
})
it('counts a metric card by its value', () => {
const metric = { id: 'm', render: 'metric', severity: 'warning', _data: { value: 7 } }
expect(severityTotals([metric]).warning).toBe(7)
})
it('treats an undeclared severity as info, matching the sort', () => {
const bare = { id: 'b', render: 'exceptions', _data: [{}, {}] }
expect(severityTotals([bare]).info).toBe(2)
})
})
describe('singularise', () => {
it('drops the s at exactly one', () => {
expect(singularise(' days', 1)).toBe(' day')
expect(singularise(' events', 1)).toBe(' event')
})
it('leaves plurals alone', () => {
expect(singularise(' days', 0)).toBe(' days')
expect(singularise(' days', 2)).toBe(' days')
expect(singularise(' days', 19)).toBe(' days')
})
it('singularises the first word and keeps what follows', () => {
// The card that made this matter: ' events (48h)' read "1 events (48h)"
// five rows running once the queue merged.
expect(singularise(' events (48h)', 1)).toBe(' event (48h)')
expect(singularise(' events (48h)', 2)).toBe(' events (48h)')
})
it('handles a trailing word rather than refusing it', () => {
// "1 day ago" is correct; an earlier version left this plural out of
// caution about chopping the wrong token.
expect(singularise(' days ago', 1)).toBe(' day ago')
})
it('leaves a unit that merely ends in s', () => {
expect(singularise(' ms', 1)).toBe(' ms')
expect(singularise('%', 1)).toBe('%')
})
it('handles no suffix', () => {
expect(singularise('', 1)).toBe('')
expect(singularise(undefined, 1)).toBe('')
})
})
describe('cardUnavailable', () => {
// "Nothing is low" and "we cannot tell whether anything is low" are different
// answers. The printer supplies card returned the same empty for both, so a
// dead Zabbix rendered as "Low Supplies 0" - reassurance, not information.
it('reads the reason a card gives for having no rows', () => {
const card = { _data: { rows: [], unavailable: 'Supply monitoring is not reachable' } }
expect(cardUnavailable(card)).toBe('Supply monitoring is not reachable')
})
it('is empty for a card that simply has nothing to report', () => {
expect(cardUnavailable({ _data: { rows: [] } })).toBe('')
expect(cardUnavailable({ _data: [] })).toBe('')
expect(cardUnavailable({ _data: null })).toBe('')
})
it('ignores a non-string, so bad data cannot render as a reason', () => {
expect(cardUnavailable({ _data: { rows: [], unavailable: true } })).toBe('')
})
it('shows an unavailable card even when its empty rule says hide', () => {
const card = {
empty: 'hide', render: 'exceptions',
_data: { rows: [], unavailable: 'Supply monitoring is not reachable' },
}
expect(cardVisible(card)).toBe(true)
})
it('still hides a card that is merely empty', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: { rows: [] } })).toBe(false)
})
it('still hides a card whose fetch failed, which is not the same thing', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: null })).toBe(false)
})
it('picks out the unavailable ones', () => {
const blind = { id: 'a', _data: { rows: [], unavailable: 'not reachable' } }
const fine = { id: 'b', _data: { rows: [{}] } }
expect(unavailableCards([blind, fine]).map((c) => c.id)).toEqual(['a'])
})
})
describe('sourceFacets', () => {
it('is one chip per contributing check, worst first', () => {
const facets = sourceFacets([warnA, critA, critB])
expect(facets.map((f) => f.key)).toEqual(
['geenforce-failures', 'endpoints-stability', 'computers-quiet'])
})
it('labels with the shortname, since the title is a heading', () => {
const card = { ...critA, shortname: 'Crashes' }
expect(sourceFacets([card])[0].label).toBe('Crashes')
})
it('falls back to the title when no shortname is declared', () => {
expect(sourceFacets([critA])[0].label).toBe('Endpoints with crashes')
})
it('counts every row, not just the ones the queue had room for', () => {
// The chip says how many there are; the queue caps what it shows. A chip
// reading 5 above a "3 more" line would be its own small lie.
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
expect(sourceFacets([noisy])[0].count).toBe(12)
})
it('leaves out checks with nothing to show', () => {
const empty = { ...critA, id: 'empty', _data: [] }
expect(sourceFacets([empty])).toEqual([])
})
it('leaves out info cards, which are not in the queue', () => {
expect(sourceFacets([infoA])).toEqual([])
})
})

View File

@@ -45,6 +45,73 @@ export function rows(panel) {
return list
}
// Optional in-card filter chips. A list panel declares facets: {key, order,
// labels} and the renderer offers one chip per distinct value of that key,
// with a count. Filtering is client-side over rows the panel already fetched -
// no extra request, and the counts stay honest because they are computed from
// the same array.
//
// Declared rather than hardcoded because the reason it exists is generic: the
// activity feed mixes info, warning and critical, and somebody triaging wants
// the criticals alone. Any list panel with a category column has that problem.
// Colour names carry no meaning to a reader. A panel keyed on statecolor
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
// a word (info/warning/critical) is labelled by the word itself.
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
// A panel that shows a health dot has already declared what it is dotting on.
// Reuse that instead of asking every panel to declare filters a second time:
// any card with a status rollup gets chips, and the two cannot disagree.
function facetSpec(panel) {
if (panel.facets && panel.facets.key) return panel.facets
const status = panel.status
if (!status || !status.key) return null
const levels = status.levels || {}
const order = Object.keys(levels).sort(
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
)
const labels = {}
Object.keys(levels).forEach((value) => {
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
? humanize(levels[value])
: humanize(value)
})
return { key: status.key, order, labels }
}
export function facets(panel) {
const spec = facetSpec(panel)
if (!spec || !spec.key) return []
const all = rows(panel)
const counts = new Map()
all.forEach((item) => {
const value = item[spec.key]
if (value === null || value === undefined || value === '') return
const key = String(value)
counts.set(key, (counts.get(key) || 0) + 1)
})
// Declared order first, so severity reads critical-warning-info rather than
// alphabetically; anything unexpected in the data still gets a chip.
const declared = (spec.order || []).filter((value) => counts.has(value))
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
return [...declared, ...extra].map((value) => ({
value,
label: (spec.labels && spec.labels[value]) || humanize(value),
count: counts.get(value)
}))
}
// rows() after the viewer's chip selection. An empty selection means "no filter
// applied", NOT "hide everything": a card that vanished when you deselected the
// last chip would read as broken.
export function facetedRows(panel, selected) {
const all = rows(panel)
const spec = facetSpec(panel)
if (!spec || !spec.key || !selected || !selected.length) return all
return all.filter((item) => selected.includes(String(item[spec.key])))
}
export function mapTitle(panel, item) {
const key = panel.map && panel.map.title
return key ? item[key] : ''

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -228,3 +228,102 @@ describe('rowAction (inline MTConnect toggle)', () => {
expect(info.toggleLabel).toBe('Start')
})
})
describe('facet chips on a card that shows a health dot', () => {
// The case that prompted this: the Activity card shows a RED dot because a
// critical event is in the data, while the visible rows are all info because
// the critical is older and sits further down. The dot and the list appear to
// disagree. Chips resolve it: the count sits under the dot, and one click
// brings the critical rows to the surface.
const activity = {
id: 'endpoints-activity',
render: 'list',
status: {
key: 'severity',
levels: { info: 'ok', warning: 'warning', critical: 'critical' },
},
_data: [
{ title: 'Successful logon', severity: 'info' },
{ title: 'Successful logon', severity: 'info' },
{ title: 'Scheduled task created', severity: 'warning' },
{ title: 'Unexpected shutdown', severity: 'critical' },
],
}
it('derives chips from the status declaration, so no panel declares filters twice', () => {
const chips = facets(activity)
expect(chips.map((c) => c.value)).toEqual(['critical', 'warning', 'info'])
})
it('orders worst first rather than alphabetically', () => {
expect(facets(activity)[0].value).toBe('critical')
})
it('counts from the same rows the dot is computed from, so they cannot disagree', () => {
const chips = facets(activity)
expect(chips.find((c) => c.value === 'critical').count).toBe(1)
expect(chips.find((c) => c.value === 'info').count).toBe(2)
expect(rollupStatus(activity).level).toBe('critical')
})
it('labels a colour-keyed panel by its level, not by the colour name', () => {
const services = {
id: 'svc',
render: 'list',
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
_data: [{ statecolor: 'green' }, { statecolor: 'red' }],
}
const labels = facets(services).map((c) => c.label)
expect(labels).toContain('Critical')
expect(labels).not.toContain('Red')
})
it('shows everything when nothing is selected, rather than hiding the card', () => {
expect(facetedRows(activity, []).length).toBe(4)
expect(facetedRows(activity, undefined).length).toBe(4)
})
it('filters to the selected severities, and allows more than one', () => {
expect(facetedRows(activity, ['critical']).map((r) => r.title))
.toEqual(['Unexpected shutdown'])
expect(facetedRows(activity, ['critical', 'warning']).length).toBe(2)
})
it('offers no chips for a panel with no status rollup', () => {
expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([])
})
it('prefers an explicit facets declaration over the status rollup', () => {
// Services carry BOTH: a statecolor status dot and a statelabel facet.
// Chips must follow the declaration, because a service is Running or
// Stopped - "OK" and "Critical" is not how anybody describes one.
const services = {
id: 'endpoints-services',
render: 'list',
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
facets: { key: 'statelabel', order: ['Running', 'Stopped', 'Paused'] },
_data: [
{ statelabel: 'Running', statecolor: 'green' },
{ statelabel: 'Stopped', statecolor: 'red' },
{ statelabel: 'Paused', statecolor: 'red' },
],
}
const chips = facets(services)
expect(chips.map((c) => c.label)).toEqual(['Running', 'Stopped', 'Paused'])
expect(chips.map((c) => c.label)).not.toContain('Critical')
})
it('gives an unlisted state its own chip rather than dropping the row', () => {
// Windows has transitional states, and an agent can report something the
// order list never anticipated. Those rows must stay reachable.
const services = {
id: 'svc2',
render: 'list',
facets: { key: 'statelabel', order: ['Running', 'Stopped'] },
_data: [{ statelabel: 'Running' }, { statelabel: 'Start Pending' }],
}
const chips = facets(services)
expect(chips.map((c) => c.value)).toEqual(['Running', 'Start Pending'])
expect(facetedRows(services, ['Start Pending']).length).toBe(1)
})
})

View File

@@ -0,0 +1,43 @@
/**
* Where an asset's detail page lives.
*
* Extracted from AssetRelationships.vue because a second caller appeared (the
* triage band) and got it wrong: it linked to `/assets/<assetid>`, which is not
* a route. There is no `/assets/:id` page and no catch-all, so every chip was a
* dead link. One copy, so a third caller cannot repeat it.
*
* Two ids are in play and they are not interchangeable. `assetid` is the core
* row; the detail routes key on `pluginid`, the id of the extension-table row
* (machineid, computerid, printerid). Sending assetid to /machines/:id opens
* the wrong machine, or none.
*/
// Asset type NAME to route base. The names are seed data, and seeding is not
// consistent about case: shopdb/core/services/csvimport.py seeds 'Computer'
// and 'Machine' capitalised, while this site's database holds them lowercase.
// The lookup is therefore lowercased on both sides - a case-sensitive match
// silently produced dead links at whichever site seeded the other way.
const ROUTE_BY_TYPE = {
machine: '/machines',
computer: '/pcs',
printer: '/printers',
network_device: '/network',
'network device': '/network',
measuring_tool: '/measuringtools',
'measuring tool': '/measuringtools'
}
export function assetRoute(asset) {
if (!asset) return '#'
const typename = String(asset.assettypename || asset.assettype || '').toLowerCase()
const basePath = ROUTE_BY_TYPE[typename] || '/assets'
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
export default assetRoute

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { assetRoute } from './assetRoute'
describe('assetRoute', () => {
// The bug this file exists to prevent: the triage band linked to
// /assets/<assetid>, and there is no /assets/:id route and no catch-all,
// so every chip was a dead link on five detail pages.
it('routes by asset type, never to /assets', () => {
expect(assetRoute({ assettypename: 'machine', pluginid: 223, assetid: 313 }))
.toBe('/machines/223')
expect(assetRoute({ assettypename: 'computer', pluginid: 279, assetid: 304 }))
.toBe('/pcs/279')
expect(assetRoute({ assettypename: 'printer', pluginid: 1, assetid: 594 }))
.toBe('/printers/1')
})
it('uses pluginid, not assetid - they are different rows', () => {
// /machines/:id keys on machineid. Passing assetid opens the wrong
// machine, or none, and looks like a data problem rather than a link bug.
const route = assetRoute({ assettypename: 'machine', pluginid: 223, assetid: 313 })
expect(route).toBe('/machines/223')
expect(route).not.toContain('313')
})
it('matches the type name whatever case the site seeded it in', () => {
// csvimport.py seeds 'Computer' and 'Machine' capitalised; this site's
// database holds them lowercase. A case-sensitive lookup produced dead
// links at whichever site seeded the other way.
expect(assetRoute({ assettypename: 'Computer', pluginid: 7 })).toBe('/pcs/7')
expect(assetRoute({ assettypename: 'MACHINE', pluginid: 8 })).toBe('/machines/8')
})
it('accepts the older assettype key as well as assettypename', () => {
expect(assetRoute({ assettype: 'printer', pluginid: 4 })).toBe('/printers/4')
})
it('falls back through typedata when pluginid is absent', () => {
expect(assetRoute({ assettypename: 'network_device', typedata: { networkdeviceid: 12 } }))
.toBe('/network/12')
expect(assetRoute({ assettypename: 'machine', typedata: { machineid: 30 } }))
.toBe('/machines/30')
})
it('returns a harmless anchor for nothing', () => {
expect(assetRoute(null)).toBe('#')
expect(assetRoute(undefined)).toBe('#')
})
it('handles both spellings of the multiword type names', () => {
expect(assetRoute({ assettypename: 'network device', pluginid: 2 })).toBe('/network/2')
expect(assetRoute({ assettypename: 'measuring_tool', pluginid: 3 })).toBe('/measuringtools/3')
expect(assetRoute({ assettypename: 'measuring tool', pluginid: 3 })).toBe('/measuringtools/3')
})
})

View File

@@ -19,10 +19,33 @@ export const PALETTE = [
'#6c757d', // gray
]
// Plugin panels emit colour NAMES, not hex - a services row says 'yellow', an
// activity row says 'gray'. Without this the brightness test cannot parse them
// and falls through to white, which is why an amber badge rendered white text
// on yellow and could not be read. Green, red and grey are dark enough that
// white worked by luck, so the fault only showed when amber was introduced.
const NAMED_COLORS = {
red: '#dc3545',
green: '#28a745',
yellow: '#ffc107',
amber: '#ffc107',
orange: '#fd7e14',
blue: '#4181ff',
grey: '#6c757d',
gray: '#6c757d',
black: '#000000',
white: '#ffffff',
}
export function resolveColor(color) {
if (!color || typeof color !== 'string') return color
return NAMED_COLORS[color.trim().toLowerCase()] || color
}
// Black or white text for a given background, by perceived brightness.
export function readableText(bg) {
if (!bg || typeof bg !== 'string') return '#ffffff'
let hex = bg.trim().replace('#', '')
let hex = resolveColor(bg).trim().replace('#', '')
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
if (hex.length !== 6) return '#ffffff'
const r = parseInt(hex.slice(0, 2), 16)
@@ -34,6 +57,8 @@ export function readableText(bg) {
// Style object for a badge/pill from a stored color (with a neutral fallback).
export function colorStyle(color, fallback = '#6c757d') {
const backgroundColor = color || fallback
// Resolve the name for the BACKGROUND too, so the swatch and the text
// contrast are computed from the same value.
const backgroundColor = resolveColor(color) || fallback
return { backgroundColor, color: readableText(backgroundColor) }
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { readableText, colorStyle } from './colorStyle'
describe('named colours', () => {
// Panels emit names, not hex. Before these resolved, the brightness test
// could not parse them and returned white for everything - which is how an
// amber badge shipped as white-on-yellow.
it('gives yellow dark text, not white', () => {
expect(readableText('yellow')).toBe('#1a1a1a')
expect(colorStyle('yellow').color).toBe('#1a1a1a')
})
it('still gives dark colours white text', () => {
expect(readableText('red')).toBe('#ffffff')
expect(readableText('green')).toBe('#ffffff')
expect(readableText('gray')).toBe('#ffffff')
})
it('resolves the name for the background as well as the text', () => {
expect(colorStyle('yellow').backgroundColor).toBe('#ffc107')
})
it('leaves hex and unknown values alone', () => {
expect(colorStyle('#ffffff').color).toBe('#1a1a1a')
expect(colorStyle('rebeccapurple').backgroundColor).toBe('rebeccapurple')
})
})

View File

@@ -7,147 +7,228 @@
<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 />
<!-- The board: a severity rollup, one worst-first queue of what needs a
person, and a rail of context. 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.
<!-- Inventory context, below the exceptions.
These used to mix two different things without saying so: two tiles
counted asset TYPES and two counted asset STATUSES, and the status
one was labelled "Active", which reads as "not deleted" when it
actually meant status = In Use across every type. Now every tile
counts one thing and its label says which. -->
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Machines</div>
<div class="value">{{ stats.totalmachines || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">PCs</div>
<div class="value">{{ stats.totalpc || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Printers</div>
<div class="value">{{ stats.totalprinter || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Network devices</div>
<div class="value">{{ stats.totalnetwork || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">All assets</div>
<div class="value">{{ stats.totalassets || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">All assets in use</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">All assets in repair</div>
<div class="value">{{ stats.inrepair || 0 }}</div>
</div>
</div>
Inventory goes in the rail through the slot, because a count is not
an exception and DashboardCards has no business declaring one. -->
<DashboardCards>
<template #rail>
<!-- Only when the totals actually loaded. /api/dashboard/summary
needs assets.view, and a caller without it got an empty object
that rendered as a tidy column of zeros - a site with nothing
in it, stated with total confidence. Same fault as reporting Low
Supplies 0 while the monitoring is down. -->
<section v-if="summaryloaded" class="dc-panel assets">
<header class="assets-head">
<h3>Assets</h3>
<router-link to="/reports" class="assets-link">Reports</router-link>
</header>
<!-- Printer Stats -->
<div class="card">
<div class="card-header">
<h3>Printers</h3>
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<!-- One line per asset type, then the total under a rule.
These used to be seven equal tiles in which some of them summed
to another: the type counts ARE all assets, shown beside their
own total at the same weight. A total that looks like another
category invites people to add it in again. Two of the seven
also counted STATUS rather than type without saying so.
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Total Printers</div>
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">Online</div>
<div class="value">{{ printerStats.online || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">Low Supplies</div>
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
</div>
<div class="stat-card danger">
<div class="label">Critical</div>
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
</div>
</div>
</div>
EVERY counted type must appear here. The endpoint's grand total
is machines + PCs + network + printers + measuring tools, and
omitting one leaves a total nothing on screen explains. -->
<div class="assets-row">
<router-link to="/machines" class="assets-k">Machines</router-link>
<span class="assets-v">{{ stats.totalmachines || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/pcs" class="assets-k">PCs</router-link>
<span class="assets-v">{{ stats.totalpc || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/printers" class="assets-k">Printers</router-link>
<span class="assets-v">{{ stats.totalprinter || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/network" class="assets-k">Network devices</router-link>
<span class="assets-v">{{ stats.totalnetwork || 0 }}</span>
</div>
<!-- Measuring tools are IN the grand total the endpoint returns, so
they have to be visible here. Left out, the total silently
stopped matching the rows above it - 610 of rows over a stated
729 on an instance with gages - which is the exact fault this
panel was built to remove. Dev has none, so it looked correct
here and was wrong everywhere the import had run. -->
<div class="assets-row">
<router-link to="/measuringtools" class="assets-k">Measuring tools</router-link>
<span class="assets-v">{{ stats.totalmeasuringtool || 0 }}</span>
</div>
<!-- Recent Devices -->
<div class="card">
<div class="card-header">
<h3>Recent Devices</h3>
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<div class="assets-row assets-total">
<span class="assets-k">All assets</span>
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Type</th>
<th>Status</th>
<th>Business Unit</th>
</tr>
</thead>
<tbody>
<tr v-for="machine in recentMachines" :key="machine.assetid">
<td>{{ machine.assetnumber || machine.name || '-' }}</td>
<td>{{ machine.assettypename || '-' }}</td>
<td>{{ machine.statusname || '-' }}</td>
<td>{{ machine.businessunitname || '-' }}</td>
</tr>
<tr v-if="recentMachines.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No devices found
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="assets-status">
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'assets-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
</span>
</p>
</section>
<!-- Never leave the page blank, and do not apologise either: show
what this reader CAN open. A guest who can see none of the cards
and cannot read the totals previously got a heading and nothing
else, which reads as a broken site rather than a closed door.
The list is the same permission-filtered navigation the sidebar
uses - server-filtered for a signed-out visitor - so it can never
offer a link that dead-ends at the router guard, and it names no
plugin this page had to know about. -->
<section v-else-if="openable.length" class="dc-panel assets">
<header class="assets-head">
<h3>Where you can go</h3>
</header>
<div v-for="item in openable" :key="item.route" class="assets-row">
<router-link :to="item.route" class="assets-k">{{ item.name }}</router-link>
</div>
</section>
</template>
</DashboardCards>
</template>
</div>
</template>
<script setup>
// WHAT THIS PAGE DROPPED, and why, so it does not come back by accident:
//
// The printer strip (Total / Online / Low Supplies / Critical). It repeated the
// printer count a third time - the tile row and "Total Printers" were already
// the same number - and "Online" equalled total, so three tiles carried one
// fact. Supplies are already a dashboard card the printers plugin declares
// (printers-supplies, severity warning), so a genuinely low cartridge reaches
// the queue where it can be acted on, instead of sitting in a tile reading 0.
//
// Recent Devices. It was the largest block on the page and listed five machines
// that were all "In Use" with business unit "TBD". Recency is not a reason to
// look at something, and it cost more vertical space than everything that
// needed a person put together. A filtered "recently changed" view belongs in
// Reports.
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue'
import { dashboardApi, assetsApi, printersApi } from '../api'
import { ref, computed, onMounted } from 'vue'
import { dashboardApi } from '../api'
const loading = ref(true)
const stats = ref({})
const printerStats = ref({})
const recentMachines = ref([])
// Whether the totals actually came back. Swallowing the error and rendering
// stats as {} turned a 403 into a column of confident zeros.
const summaryloaded = ref(false)
// Where this reader can actually go, for the case where the board has nothing
// for them. Same source as the sidebar, so it is already filtered to what they
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
onMounted(async () => {
try {
const [dashRes, machinesRes, printersRes] = await Promise.all([
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
])
stats.value = dashRes.data.data || {}
recentMachines.value = machinesRes.data.data || []
printerStats.value = printersRes.data.data || {}
const response = await dashboardApi.summary()
stats.value = response.data.data || {}
summaryloaded.value = Object.keys(stats.value).length > 0
} catch (error) {
console.error('Dashboard load error:', error)
} finally {
loading.value = false
// A caller without assets.view is restricted, not broken, so this is not
// logged as an error - the panel offers them somewhere to go instead.
summaryloaded.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
if (!summaryloaded.value) {
// Only fetched when it is needed, so the common case costs nothing.
try {
const response = await dashboardApi.navigation()
navitems.value = response.data.data || []
} catch (error) {
navitems.value = []
}
}
loading.value = false
})
</script>
<style scoped>
/* Matches the panels in DashboardCards; the rail reads as one column rather
than as a page card that happens to sit beside them. */
.assets {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
min-width: 0;
}
.assets-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.assets-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;
}
.assets-link {
font-size: 0.78rem;
color: var(--link);
text-decoration: none;
}
.assets-link:hover { text-decoration: underline; }
.assets-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
}
.assets-row:last-of-type { border-bottom: none; }
.assets-k {
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
a.assets-k:hover { text-decoration: underline; }
span.assets-k { color: var(--text); font-weight: 600; }
.assets-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.assets-total {
margin-top: 0.35rem;
border-top: 2px solid var(--border);
border-bottom: none;
}
.assets-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--text-light);
}
.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.assets-zero { color: var(--text-light); }
</style>