The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label.
272 lines
9.5 KiB
Vue
272 lines
9.5 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" :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>
|
|
<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. A card holds a hostname, a machine number
|
|
and a state on one line, so the track cannot go much below this without
|
|
truncating the rows that matter most - but 28rem only fits three once the
|
|
sidebar takes its share at 1920, which is the common case here. auto-fit
|
|
rather than auto-fill so two cards fill the width instead of leaving
|
|
empty tracks. */
|
|
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
|
max-width: 120rem;
|
|
gap: 1rem;
|
|
margin-bottom: 1.75rem;
|
|
align-items: start;
|
|
}
|
|
|
|
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
|
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
|
the cards get narrower and the rows they hold start truncating again. */
|
|
@media (min-width: 96rem) {
|
|
.dc-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
}
|
|
.dc-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>
|