Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours
This commit is contained in:
@@ -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">▾</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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
frontend/src/components/DashboardRow.vue
Normal file
154
frontend/src/components/DashboardRow.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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] : ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
43
frontend/src/utils/assetRoute.js
Normal file
43
frontend/src/utils/assetRoute.js
Normal 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
|
||||
54
frontend/src/utils/assetRoute.spec.js
Normal file
54
frontend/src/utils/assetRoute.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
27
frontend/src/utils/colorStyle.spec.js
Normal file
27
frontend/src/utils/colorStyle.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user