261 lines
9.0 KiB
Vue
261 lines
9.0 KiB
Vue
<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>
|
|
|
|
<!-- metric: the count IS the story -->
|
|
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
|
|
|
<!-- 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"
|
|
: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>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
</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.
|
|
//
|
|
// 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 LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import {
|
|
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
|
|
permittedCards, renderableCards, rows as cardData,
|
|
} from './dashboardCards'
|
|
|
|
const auth = useAuthStore()
|
|
const cards = ref([])
|
|
|
|
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
|
|
|
|
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)))
|
|
|
|
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-grid {
|
|
display: grid;
|
|
/* Four across on a wide screen, not five. A card holds a hostname, a machine
|
|
number and a state on one line; at five columns those lines truncate on
|
|
the very rows that matter most. auto-fit rather than auto-fill so two
|
|
cards fill the width instead of leaving three empty tracks. */
|
|
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
|
max-width: 120rem;
|
|
gap: 1rem;
|
|
margin-bottom: 1.75rem;
|
|
align-items: start;
|
|
}
|
|
.dc-card {
|
|
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;
|
|
}
|
|
|
|
/* 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 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding-bottom: 0.55rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.dc-title {
|
|
margin: 0;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
color: var(--text-light);
|
|
flex: 1;
|
|
}
|
|
.dc-count {
|
|
font-size: 0.8rem;
|
|
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;
|
|
color: var(--text-light);
|
|
}
|
|
.dc-more-link { color: var(--link); text-decoration: none; }
|
|
.dc-more-link:hover { text-decoration: underline; }
|
|
</style>
|