Rebuild the dashboard around what needs a person
The board was 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. Warranties showed 5 of 50 behind "and 45 more"; the crash card truncated at 4; a one-row card sat beside a four-row card and left a hole. Nothing had room, which is the complaint that started this: not enough space to relay the data. And a box per source flattened priority. An enforcement failure and a warranty notice looked equally urgent because each owned a card, while the largest type on the page belonged to counts that are true every day and need nobody. WHAT THE PAGE IS NOW A severity rollup, one worst-first queue of everything that needs a person, and a row of context beneath it. The queue merges every critical and warning row and sorts them, so reading order is work order. It is full width, which is where the room came from. Rows are capped per source, because fifty expiring warranties must not bury two crashed PCs, and what did not fit is named per source with a link to it - a bare "and 45 more" that leads nowhere is worse than saying nothing. The rollup counts ROWS, not cards, and filters the board in place rather than navigating away, so the counts and the rows cannot disagree. Severity, position and permission were already declared per card and already sorted on. The old layout threw that away; this only stops it. WHAT WENT, AND WHY IT SHOULD NOT COME BACK Four asset tiles plus an "All assets" tile: machines + PCs + printers + network IS all assets, so the page showed a total beside its own addends at the same weight. It is now a line under a rule, where a total belongs. The printer strip. It carried the printer count a third time - the tile row and "Total Printers" were already the same number - and "Online" equalled total, so three tiles held one fact. A genuinely low cartridge already reaches the queue through the printers-supplies card, which is the moment anyone wants it. Three cards whose whole job was to display 0. Recent Devices: the largest block on the page, listing five machines that were all "In Use" with business unit "TBD". Recency is not a reason to look at something. CONTRACT 0.26.0: 'shortname' Cards may declare a short label for their rows in the merged queue. Their 'title' is written as a heading - "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. CSS-truncating the title was tried first and was worse: four consecutive rows reading "ENDPOINTS WITH CR..." say nothing at all. Optional and defaults to the title, so a plugin that does not declare one is unaffected. Additive field, minor bump. ALSO: "silent 1 days" A meta suffix is written for the plural case and the card cannot know the value. One row saying it was a shrug; the merged queue puts every quiet endpoint on one screen, so it said it five times in a column and read as broken. Now singularised at exactly 1, on the first word, keeping what follows: "1 event (48h)", "silent 1 day". The test asserting ' ms' survives is what caught the first version turning it into ' m'. DashboardRow.vue is extracted because the queue and the context panels render the same row, and the map tooltip, link fallbacks and chips are too easy to get subtly different in a second copy. Verified on screen against live data. Full suite 2178 passed, frontend 301.
This commit is contained in:
@@ -12,7 +12,7 @@ never by editing this file.
|
|||||||
| series | value | governed by |
|
| series | value | governed by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| product `__version__` | `0.12.0` | ADR-007 |
|
| product `__version__` | `0.12.0` | ADR-007 |
|
||||||
| plugin contract `__contract_version__` | `0.25.0` | ADR-002 |
|
| plugin contract `__contract_version__` | `0.26.0` | ADR-002 |
|
||||||
|
|
||||||
They move independently. A contract bump is not a release.
|
They move independently. A contract bump is not a release.
|
||||||
|
|
||||||
@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
|||||||
|
|
||||||
## Size
|
## Size
|
||||||
|
|
||||||
- test functions defined: **1486** (parametrised cases collect higher)
|
- test functions defined: **1503** (parametrised cases collect higher)
|
||||||
- documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
- documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1,108 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="visibleCards.length" class="dc-grid">
|
<div v-if="hasBoard" class="dc-board">
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- metric: the count IS the story -->
|
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
|
||||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
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" type="button" class="dc-att-clear" @click="filter = ''">
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- exceptions / list: one line per thing, each linking to itself -->
|
<div class="dc-cols">
|
||||||
<ul v-else class="dc-rows">
|
|
||||||
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
|
<!-- ONE queue, worst first, instead of a card per source. Reading order
|
||||||
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
|
is work order. -->
|
||||||
<!-- With coordinates, the name carries the same floor-plan preview
|
<section v-if="queue.length || overflow.length" class="dc-panel dc-queue">
|
||||||
the asset's own page uses. Without them, a plain link. -->
|
<header class="dc-panel-head">
|
||||||
<LocationMapTooltip v-if="row.link && row.maphover"
|
<h3>Needs attention</h3>
|
||||||
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
|
<span class="dc-panel-count">{{ queueTotal }}</span>
|
||||||
:machineName="row.maphover.label">
|
</header>
|
||||||
<router-link :to="row.link" class="dc-row-title"
|
<ul class="dc-rows">
|
||||||
:title="row.titletip || undefined">
|
<DashboardRow
|
||||||
{{ row.title }}
|
v-for="row in queue"
|
||||||
</router-link>
|
:key="row.key"
|
||||||
</LocationMapTooltip>
|
:row="row"
|
||||||
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
|
:severity="row.severity"
|
||||||
:title="row.titletip || undefined">
|
showsource
|
||||||
{{ row.title }}
|
/>
|
||||||
|
</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>
|
</router-link>
|
||||||
<span v-else class="dc-row-title dc-row-nolink"
|
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
|
||||||
:title="row.titletip || undefined">{{ row.title }}</span>
|
</p>
|
||||||
<span v-if="row.detail" class="dc-row-detail"
|
</section>
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
|
<section v-else class="dc-panel dc-clear">
|
||||||
quiet and leaving them to find the list is worse than not saying it.
|
<p class="dc-clear-line">Nothing needs attention.</p>
|
||||||
A card without a viewall still says the count, because the number
|
</section>
|
||||||
itself is information. -->
|
|
||||||
<router-link v-if="overflowCount(card) && card.viewall"
|
<aside class="dc-rail">
|
||||||
:to="card.viewall" class="dc-more dc-more-link">
|
<!-- The page owns what sits above the context cards: inventory is not
|
||||||
and {{ overflowCount(card) }} more
|
an exception, so it is not this component's to declare. -->
|
||||||
</router-link>
|
<slot name="rail"></slot>
|
||||||
<p v-else-if="overflowCount(card)" class="dc-more">
|
|
||||||
and {{ overflowCount(card) }} more
|
<section v-for="card in context" :key="card.id" class="dc-panel">
|
||||||
</p>
|
<header class="dc-panel-head">
|
||||||
</section>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// Renders the dashboard cards plugins declare via get_dashboard_widgets
|
// The fleet dashboard. Plugins declare cards via get_dashboard_widgets
|
||||||
// (GET /api/dashboard/widgets). Core owns the renderers; a plugin declares data
|
// (GET /api/dashboard/widgets); core owns the drawing. See
|
||||||
// and shape. See docs/proposals/dashboard-live-fleet.md.
|
// 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
|
// 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
|
// slow or broken endpoint - a hung Zabbix call, a plugin mid-upgrade - cannot
|
||||||
@@ -74,16 +111,53 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import DashboardRow from '@/components/DashboardRow.vue'
|
||||||
import {
|
import {
|
||||||
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
|
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
|
||||||
permittedCards, renderableCards, rows as cardData,
|
permittedCards, renderableCards, rows as cardData,
|
||||||
|
queueRows, queueOverflow, contextCards, severityTotals,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const cards = ref([])
|
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))
|
||||||
|
|
||||||
|
const queue = computed(() => queueRows(filteredCards.value))
|
||||||
|
const overflow = computed(() => queueOverflow(filteredCards.value))
|
||||||
|
const context = computed(() => contextCards(filteredCards.value))
|
||||||
|
|
||||||
|
const queueTotal = computed(
|
||||||
|
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
|
||||||
|
|
||||||
|
const hasBoard = computed(() => liveCards.value.length > 0)
|
||||||
|
|
||||||
|
function toggleFilter(level) {
|
||||||
|
filter.value = filter.value === level ? '' : level
|
||||||
|
}
|
||||||
|
|
||||||
function countOf(card) {
|
function countOf(card) {
|
||||||
return cardData(card).length
|
return cardData(card).length
|
||||||
@@ -118,154 +192,133 @@ defineExpose({ load })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dc-grid {
|
.dc-board { margin-bottom: 1.75rem; }
|
||||||
display: grid;
|
|
||||||
/* Four across on a wide screen. A card holds a hostname, a machine number
|
/* ---- rollup ---- */
|
||||||
and a state on one line, so the track cannot go much below this without
|
.dc-attention {
|
||||||
truncating the rows that matter most - but 28rem only fits three once the
|
display: flex;
|
||||||
sidebar takes its share at 1920, which is the common case here. auto-fit
|
flex-wrap: wrap;
|
||||||
rather than auto-fill so two cards fill the width instead of leaving
|
gap: 0.6rem;
|
||||||
empty tracks. */
|
margin-bottom: 1rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
|
||||||
max-width: 120rem;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
.dc-att {
|
||||||
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
display: flex;
|
||||||
the cards get narrower and the rows they hold start truncating again. */
|
align-items: center;
|
||||||
@media (min-width: 96rem) {
|
gap: 0.55rem;
|
||||||
.dc-grid {
|
background: var(--bg-card);
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 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);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.85rem 1rem 0.9rem;
|
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;
|
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
|
.dc-panel-head {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding-bottom: 0.55rem;
|
margin-bottom: 0.35rem;
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
.dc-title {
|
.dc-panel-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.dc-count {
|
.dc-panel-count {
|
||||||
font-size: 0.8rem;
|
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;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-variant-numeric: tabular-nums;
|
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-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 {
|
.dc-more {
|
||||||
display: block;
|
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
.dc-more-link { color: var(--link); text-decoration: none; }
|
.dc-more-link { color: var(--link); text-decoration: none; }
|
||||||
.dc-more-link:hover { text-decoration: underline; }
|
.dc-more-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.dc-clear-line {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -51,6 +51,26 @@ export function mapDetailTip(card, item) {
|
|||||||
return full && full !== mapDetail(card, item) ? full : ''
|
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) {
|
export function mapMeta(card, item) {
|
||||||
const meta = (card.map && card.map.meta) || []
|
const meta = (card.map && card.map.meta) || []
|
||||||
return meta
|
return meta
|
||||||
@@ -59,7 +79,7 @@ export function mapMeta(card, item) {
|
|||||||
if (value === '') return null
|
if (value === '') return null
|
||||||
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
|
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
|
||||||
// where 'quiet for 3' reads as a truncation.
|
// 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 }
|
return { text, mono: !!spec.mono }
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -202,3 +222,114 @@ export const RENDERERS = ['exceptions', 'metric', 'list']
|
|||||||
export function renderableCards(cards) {
|
export function renderableCards(cards) {
|
||||||
return cards.filter((card) => RENDERERS.includes(card.render))
|
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.
|
||||||
|
*/
|
||||||
|
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,8 @@ import {
|
|||||||
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
||||||
cardVisible, sortCards, permittedCards, renderableCards,
|
cardVisible, sortCards, permittedCards, renderableCards,
|
||||||
visibleRows, overflowCount,
|
visibleRows, overflowCount,
|
||||||
|
queueRows, queueOverflow, contextCards, severityTotals,
|
||||||
|
QUEUEPERSOURCE, QUEUEMAX, singularise,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
const failuresCard = {
|
const failuresCard = {
|
||||||
@@ -226,3 +228,167 @@ describe('stacked rows', () => {
|
|||||||
.toBe('Start of a long message that continues well past the card')
|
.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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,147 +7,168 @@
|
|||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- What needs a person, before the totals that are true every day.
|
<!-- The board: a severity rollup, one worst-first queue of what needs a
|
||||||
Cards are declared by plugins (get_dashboard_widgets) and rendered
|
person, and a rail of context. Cards are declared by plugins
|
||||||
generically; an empty or failed card hides itself. See
|
(get_dashboard_widgets) and rendered generically; an empty or failed
|
||||||
docs/proposals/dashboard-live-fleet.md. -->
|
card hides itself. See docs/proposals/dashboard-live-fleet.md.
|
||||||
<DashboardCards />
|
|
||||||
|
|
||||||
<!-- Inventory context, below the exceptions.
|
Inventory goes in the rail through the slot, because a count is not
|
||||||
These used to mix two different things without saying so: two tiles
|
an exception and DashboardCards has no business declaring one. -->
|
||||||
counted asset TYPES and two counted asset STATUSES, and the status
|
<DashboardCards>
|
||||||
one was labelled "Active", which reads as "not deleted" when it
|
<template #rail>
|
||||||
actually meant status = In Use across every type. Now every tile
|
<section class="dc-panel estate">
|
||||||
counts one thing and its label says which. -->
|
<header class="estate-head">
|
||||||
<div class="dashboard-grid">
|
<h3>Estate</h3>
|
||||||
<div class="stat-card">
|
<router-link to="/reports" class="estate-link">Reports</router-link>
|
||||||
<div class="label">Machines</div>
|
</header>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Printer Stats -->
|
<!-- One line per asset type, then the total under a rule.
|
||||||
<div class="card">
|
These used to be seven equal tiles in which four of them summed
|
||||||
<div class="card-header">
|
to a fifth: machines + PCs + printers + network IS all assets,
|
||||||
<h3>Printers</h3>
|
shown beside its own addends at the same weight. A total that
|
||||||
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
|
looks like a sixth category invites people to add it in again.
|
||||||
</div>
|
Two of the seven also counted STATUS rather than type without
|
||||||
|
saying so. -->
|
||||||
|
<div class="estate-row">
|
||||||
|
<router-link to="/machines" class="estate-k">Machines</router-link>
|
||||||
|
<span class="estate-v">{{ stats.totalmachines || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="estate-row">
|
||||||
|
<router-link to="/pcs" class="estate-k">PCs</router-link>
|
||||||
|
<span class="estate-v">{{ stats.totalpc || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="estate-row">
|
||||||
|
<router-link to="/printers" class="estate-k">Printers</router-link>
|
||||||
|
<span class="estate-v">{{ stats.totalprinter || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="estate-row">
|
||||||
|
<router-link to="/network" class="estate-k">Network devices</router-link>
|
||||||
|
<span class="estate-v">{{ stats.totalnetwork || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-grid">
|
<div class="estate-row estate-total">
|
||||||
<div class="stat-card">
|
<span class="estate-k">All assets</span>
|
||||||
<div class="label">Total Printers</div>
|
<span class="estate-v">{{ stats.totalassets || 0 }}</span>
|
||||||
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
|
</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>
|
|
||||||
|
|
||||||
<!-- Recent Devices -->
|
<!-- Status, on one line, and explicitly labelled as status. In
|
||||||
<div class="card">
|
repair is muted at zero rather than given a card of its own to
|
||||||
<div class="card-header">
|
announce that nothing is wrong. -->
|
||||||
<h3>Recent Devices</h3>
|
<p class="estate-status">
|
||||||
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
|
<span class="estate-inuse">{{ stats.activeassets || 0 }}</span> in use
|
||||||
</div>
|
<span aria-hidden="true"> / </span>
|
||||||
|
<span :class="{ 'estate-zero': !stats.inrepair }">
|
||||||
<div class="table-container">
|
{{ stats.inrepair || 0 }} in repair
|
||||||
<table>
|
</span>
|
||||||
<thead>
|
</p>
|
||||||
<tr>
|
</section>
|
||||||
<th>Asset #</th>
|
</template>
|
||||||
<th>Type</th>
|
</DashboardCards>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 DashboardCards from '@/components/DashboardCards.vue'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { dashboardApi, assetsApi, printersApi } from '../api'
|
import { dashboardApi } from '../api'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const stats = ref({})
|
const stats = ref({})
|
||||||
const printerStats = ref({})
|
|
||||||
const recentMachines = ref([])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [dashRes, machinesRes, printersRes] = await Promise.all([
|
const response = await dashboardApi.summary().catch(() => ({ data: { data: {} } }))
|
||||||
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
|
stats.value = response.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 || {}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Dashboard load error:', error)
|
console.error('Dashboard load error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.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'
|
|
||||||
}
|
|
||||||
</script>
|
</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. */
|
||||||
|
.estate {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.85rem 1rem 0.9rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estate-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.estate-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;
|
||||||
|
}
|
||||||
|
.estate-link {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.estate-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.estate-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.estate-row:last-of-type { border-bottom: none; }
|
||||||
|
|
||||||
|
.estate-k {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
a.estate-k:hover { text-decoration: underline; }
|
||||||
|
span.estate-k { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
|
.estate-v {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estate-total {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estate-status {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.estate-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.estate-zero { color: var(--text-light); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
|
|||||||
{
|
{
|
||||||
'id': 'backups-stale',
|
'id': 'backups-stale',
|
||||||
'title': 'Backups stopped',
|
'title': 'Backups stopped',
|
||||||
|
'shortname': 'Backups',
|
||||||
'endpoint': '/api/backups/dashboard/stale',
|
'endpoint': '/api/backups/dashboard/stale',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
|
|||||||
@@ -1934,6 +1934,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'id': 'computers-quiet',
|
'id': 'computers-quiet',
|
||||||
'viewall': '/pcs',
|
'viewall': '/pcs',
|
||||||
'title': 'PCs gone silent',
|
'title': 'PCs gone silent',
|
||||||
|
'shortname': 'PC silent',
|
||||||
'endpoint': '/api/computers/dashboard/quiet',
|
'endpoint': '/api/computers/dashboard/quiet',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
@@ -1952,6 +1953,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'id': 'computers-sharedmachines',
|
'id': 'computers-sharedmachines',
|
||||||
'viewall': '/machines',
|
'viewall': '/machines',
|
||||||
'title': 'Machine numbers on two PCs',
|
'title': 'Machine numbers on two PCs',
|
||||||
|
'shortname': 'Duplicate machine no.',
|
||||||
'endpoint': '/api/computers/dashboard/sharedmachines',
|
'endpoint': '/api/computers/dashboard/sharedmachines',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'critical',
|
'severity': 'critical',
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'id': 'endpoints-quiet',
|
'id': 'endpoints-quiet',
|
||||||
'viewall': '/pcs',
|
'viewall': '/pcs',
|
||||||
'title': 'Endpoints gone quiet',
|
'title': 'Endpoints gone quiet',
|
||||||
|
'shortname': 'Agent quiet',
|
||||||
'endpoint': '/api/endpoints/dashboard/quiet',
|
'endpoint': '/api/endpoints/dashboard/quiet',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
@@ -405,6 +406,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'id': 'endpoints-stability',
|
'id': 'endpoints-stability',
|
||||||
'viewall': '/pcs',
|
'viewall': '/pcs',
|
||||||
'title': 'Endpoints with crashes or shutdowns',
|
'title': 'Endpoints with crashes or shutdowns',
|
||||||
|
'shortname': 'Crashes',
|
||||||
'endpoint': '/api/endpoints/dashboard/stability',
|
'endpoint': '/api/endpoints/dashboard/stability',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'critical',
|
'severity': 'critical',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
'id': 'geenforce-failures',
|
'id': 'geenforce-failures',
|
||||||
'viewall': '/geenforce',
|
'viewall': '/geenforce',
|
||||||
'title': 'Enforcement failures',
|
'title': 'Enforcement failures',
|
||||||
|
'shortname': 'Enforcement',
|
||||||
'endpoint': '/api/geenforce/dashboard/failures',
|
'endpoint': '/api/geenforce/dashboard/failures',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'critical',
|
'severity': 'critical',
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class MachinesPlugin(BasePlugin):
|
|||||||
'id': 'machines-outofservice',
|
'id': 'machines-outofservice',
|
||||||
'viewall': '/machines',
|
'viewall': '/machines',
|
||||||
'title': 'Machines out of service',
|
'title': 'Machines out of service',
|
||||||
|
'shortname': 'Out of service',
|
||||||
'endpoint': '/api/machines/dashboard/outofservice',
|
'endpoint': '/api/machines/dashboard/outofservice',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class NotificationsPlugin(BasePlugin):
|
|||||||
'id': 'notifications-active',
|
'id': 'notifications-active',
|
||||||
'viewall': '/notifications',
|
'viewall': '/notifications',
|
||||||
'title': 'Active notifications',
|
'title': 'Active notifications',
|
||||||
|
'shortname': 'Notification',
|
||||||
'endpoint': '/api/notifications/dashboard/active',
|
'endpoint': '/api/notifications/dashboard/active',
|
||||||
'render': 'list',
|
'render': 'list',
|
||||||
'severity': 'info',
|
'severity': 'info',
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ class PrintersPlugin(BasePlugin):
|
|||||||
'id': 'printers-supplies',
|
'id': 'printers-supplies',
|
||||||
'viewall': '/reports/toner',
|
'viewall': '/reports/toner',
|
||||||
'title': 'Printer supplies',
|
'title': 'Printer supplies',
|
||||||
|
'shortname': 'Supplies',
|
||||||
'endpoint': '/api/printers/dashboard/supplies',
|
'endpoint': '/api/printers/dashboard/supplies',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class WarrantyPlugin(BasePlugin):
|
|||||||
'id': 'warranty-expiring',
|
'id': 'warranty-expiring',
|
||||||
'viewall': '/reports/warranty',
|
'viewall': '/reports/warranty',
|
||||||
'title': 'Warranties expiring',
|
'title': 'Warranties expiring',
|
||||||
|
'shortname': 'Warranty',
|
||||||
'endpoint': '/api/warranty/dashboard/expiring',
|
'endpoint': '/api/warranty/dashboard/expiring',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'info',
|
'severity': 'info',
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ from .plugins import plugin_manager
|
|||||||
# emission never consulted the caller, so a read-only viewer was shown controls
|
# emission never consulted the caller, so a read-only viewer was shown controls
|
||||||
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
|
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
|
||||||
# visitor holds whatever the guest role was granted. Additive name, minor bump.
|
# visitor holds whatever the guest role was granted. Additive name, minor bump.
|
||||||
__contract_version__ = '0.25.0'
|
# 0.26.0: dashboard cards may declare 'shortname'. The dashboard merged its
|
||||||
|
# per-source cards into one worst-first queue, and a card's 'title' is written
|
||||||
|
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
|
||||||
|
# ran wider than the hostname it qualified. Optional, defaults to the title, so
|
||||||
|
# a plugin that does not declare one is unaffected. Additive field, minor bump.
|
||||||
|
__contract_version__ = '0.26.0'
|
||||||
|
|
||||||
# Product release version (see ADR-007). The product version and the
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ class BasePlugin(ABC):
|
|||||||
Each card: {
|
Each card: {
|
||||||
'id': str, # stable, unique across plugins
|
'id': str, # stable, unique across plugins
|
||||||
'title': str, # heading
|
'title': str, # heading
|
||||||
|
'shortname': str, # optional: label for this card's rows in the
|
||||||
|
# merged queue. A title reads as a header and is
|
||||||
|
# too long as a row badge; defaults to title.
|
||||||
'endpoint': str, # API path the card fetches its own data from
|
'endpoint': str, # API path the card fetches its own data from
|
||||||
'render': str, # core renderer: 'exceptions' | 'list' | 'stat'
|
'render': str, # core renderer: 'exceptions' | 'list' | 'stat'
|
||||||
'severity': str, # 'critical' | 'warning' | 'info' - orders cards
|
'severity': str, # 'critical' | 'warning' | 'info' - orders cards
|
||||||
|
|||||||
Reference in New Issue
Block a user