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