Show a guest what they can see, not a blank page

A guest got a heading and nothing else.

THE STRUCTURAL FAULT WAS MINE

Moving the Estate panel into DashboardCards' rail slot put it behind that
component's root v-if, which was `liveCards.length > 0`. A guest is permitted
zero cards, so the whole component disappeared and took the slot with it. Before
that move the tiles were top-level and always rendered. The wrapper no longer
vanishes with the cards.

TWO LIES THE EMPTY STATE WAS TELLING

"Nothing needs attention" is now shown only to somebody who can see the checks.
A reader permitted none of them is not looking at a healthy fleet, they are
looking at nothing, and saying otherwise is the same fault as reporting Low
Supplies 0 while the monitoring is down. The count of PERMITTED cards is now
tracked separately from the count that currently hold rows.

The Estate panel no longer renders zeros for a caller who cannot read totals.
/api/dashboard/summary needs assets.view, and the failure was swallowed into an
empty object that drew a tidy column of zeros - an estate of nothing, stated
with confidence.

WHAT A GUEST GETS INSTEAD

Where they can go: the same permission-filtered navigation the sidebar uses,
which the server already filters to the guest role for a signed-out visitor. So
it cannot offer a link that dead-ends at the router guard, and this page names no
plugin it had to know about. Verified against the running instance - a guest is
offered Guides and Tech Tools, matching guides.view and tools.view.

ALSO: the measuring tools row

totalassets counts five types - machines, PCs, network, printers AND measuring
tools - but only four had rows, so the total stopped matching the addends above
it wherever gages exist: 610 of visible rows under a stated 729. That is exactly
the fault this panel was built to remove, and it looked right here only because
this database has no measuring tools. Every counted type now has a row.

Frontend 314 passed.
This commit is contained in:
cproudlock
2026-08-30 09:52:29 -04:00
parent 1311dea113
commit 0f2da9beaf
2 changed files with 88 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="hasBoard" class="dc-board"> <div class="dc-board">
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue <!-- The rollup. Counts ROWS, not cards, and each count filters the queue
below rather than navigating away: landing on a number that tells you below rather than navigating away: landing on a number that tells you
@@ -54,7 +54,8 @@
<!-- ONE queue, worst first, instead of a card per source. Reading order <!-- ONE queue, worst first, instead of a card per source. Reading order
is work order. --> is work order. -->
<section v-if="queue.length || overflow.length" class="dc-panel dc-queue"> <section v-if="canseechecks && (queue.length || overflow.length)"
class="dc-panel dc-queue">
<header class="dc-panel-head"> <header class="dc-panel-head">
<h3>Needs attention</h3> <h3>Needs attention</h3>
<span class="dc-panel-count">{{ queueTotal }}</span> <span class="dc-panel-count">{{ queueTotal }}</span>
@@ -78,7 +79,12 @@
</p> </p>
</section> </section>
<section v-else class="dc-panel dc-clear"> <!-- 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> <p class="dc-clear-line">Nothing needs attention.</p>
</section> </section>
@@ -195,7 +201,11 @@ const context = computed(
const queueTotal = computed( const queueTotal = computed(
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0)) () => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
const hasBoard = computed(() => liveCards.value.length > 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) { function toggleFilter(level) {
filter.value = filter.value === level ? '' : level filter.value = filter.value === level ? '' : level
@@ -237,6 +247,8 @@ async function load() {
const wanted = renderableCards( const wanted = renderableCards(
permittedCards(declared, (name) => auth.hasPermission(name))) permittedCards(declared, (name) => auth.hasPermission(name)))
permittedcount.value = wanted.length
cards.value = await Promise.all(wanted.map(async (card) => { cards.value = await Promise.all(wanted.map(async (card) => {
try { try {
const response = await api.get(toApiPath(card.endpoint)) const response = await api.get(toApiPath(card.endpoint))

View File

@@ -16,19 +16,27 @@
an exception and DashboardCards has no business declaring one. --> an exception and DashboardCards has no business declaring one. -->
<DashboardCards> <DashboardCards>
<template #rail> <template #rail>
<section class="dc-panel estate"> <!-- Only when the totals actually loaded. /api/dashboard/summary
needs assets.view, and a caller without it got an empty object
that rendered as a tidy column of zeros - an estate of nothing,
stated with total confidence. Same fault as reporting Low
Supplies 0 while the monitoring is down. -->
<section v-if="summaryloaded" class="dc-panel estate">
<header class="estate-head"> <header class="estate-head">
<h3>Estate</h3> <h3>Estate</h3>
<router-link to="/reports" class="estate-link">Reports</router-link> <router-link to="/reports" class="estate-link">Reports</router-link>
</header> </header>
<!-- One line per asset type, then the total under a rule. <!-- One line per asset type, then the total under a rule.
These used to be seven equal tiles in which four of them summed These used to be seven equal tiles in which some of them summed
to a fifth: machines + PCs + printers + network IS all assets, to another: the type counts ARE all assets, shown beside their
shown beside its own addends at the same weight. A total that own total at the same weight. A total that looks like another
looks like a sixth category invites people to add it in again. category invites people to add it in again. Two of the seven
Two of the seven also counted STATUS rather than type without also counted STATUS rather than type without saying so.
saying so. -->
EVERY counted type must appear here. The endpoint's grand total
is machines + PCs + network + printers + measuring tools, and
omitting one leaves a total nothing on screen explains. -->
<div class="estate-row"> <div class="estate-row">
<router-link to="/machines" class="estate-k">Machines</router-link> <router-link to="/machines" class="estate-k">Machines</router-link>
<span class="estate-v">{{ stats.totalmachines || 0 }}</span> <span class="estate-v">{{ stats.totalmachines || 0 }}</span>
@@ -45,6 +53,16 @@
<router-link to="/network" class="estate-k">Network devices</router-link> <router-link to="/network" class="estate-k">Network devices</router-link>
<span class="estate-v">{{ stats.totalnetwork || 0 }}</span> <span class="estate-v">{{ stats.totalnetwork || 0 }}</span>
</div> </div>
<!-- Measuring tools are IN the grand total the endpoint returns, so
they have to be visible here. Left out, the total silently
stopped matching the rows above it - 610 of rows over a stated
729 on an instance with gages - which is the exact fault this
panel was built to remove. Dev has none, so it looked correct
here and was wrong everywhere the import had run. -->
<div class="estate-row">
<router-link to="/measuringtools" class="estate-k">Measuring tools</router-link>
<span class="estate-v">{{ stats.totalmeasuringtool || 0 }}</span>
</div>
<div class="estate-row estate-total"> <div class="estate-row estate-total">
<span class="estate-k">All assets</span> <span class="estate-k">All assets</span>
@@ -62,6 +80,23 @@
</span> </span>
</p> </p>
</section> </section>
<!-- Never leave the page blank, and do not apologise either: show
what this reader CAN open. A guest who can see none of the cards
and cannot read the totals previously got a heading and nothing
else, which reads as a broken site rather than a closed door.
The list is the same permission-filtered navigation the sidebar
uses - server-filtered for a signed-out visitor - so it can never
offer a link that dead-ends at the router guard, and it names no
plugin this page had to know about. -->
<section v-else-if="openable.length" class="dc-panel estate">
<header class="estate-head">
<h3>Where you can go</h3>
</header>
<div v-for="item in openable" :key="item.route" class="estate-row">
<router-link :to="item.route" class="estate-k">{{ item.name }}</router-link>
</div>
</section>
</template> </template>
</DashboardCards> </DashboardCards>
</template> </template>
@@ -84,21 +119,46 @@
// needed a person put together. A filtered "recently changed" view belongs in // needed a person put together. A filtered "recently changed" view belongs in
// Reports. // Reports.
import DashboardCards from '@/components/DashboardCards.vue' import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { dashboardApi } from '../api' import { dashboardApi } from '../api'
const loading = ref(true) const loading = ref(true)
const stats = ref({}) const stats = ref({})
// Whether the totals actually came back. Swallowing the error and rendering
// stats as {} turned a 403 into a column of confident zeros.
const summaryloaded = ref(false)
// Where this reader can actually go, for the case where the board has nothing
// for them. Same source as the sidebar, so it is already filtered to what they
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
onMounted(async () => { onMounted(async () => {
try { try {
const response = await dashboardApi.summary().catch(() => ({ data: { data: {} } })) const response = await dashboardApi.summary()
stats.value = response.data.data || {} stats.value = response.data.data || {}
summaryloaded.value = Object.keys(stats.value).length > 0
} catch (error) { } catch (error) {
console.error('Dashboard load error:', error) // A caller without assets.view is restricted, not broken, so this is not
} finally { // logged as an error - the panel offers them somewhere to go instead.
loading.value = false summaryloaded.value = false
} }
if (!summaryloaded.value) {
// Only fetched when it is needed, so the common case costs nothing.
try {
const response = await dashboardApi.navigation()
navitems.value = response.data.data || []
} catch (error) {
navitems.value = []
}
}
loading.value = false
}) })
</script> </script>