Dashboard cards, backup last-seen

This commit is contained in:
2026-08-11 14:01:57 -04:00
parent c30195a92b
commit 0ed8be75d9
18 changed files with 1167 additions and 9 deletions

View File

@@ -10,6 +10,22 @@ ADR-007 and ADR-002.
## [Unreleased]
### Added
- A backup now records that it was CHECKED, not only that it changed. Dedup
means an unchanged configuration writes no revision, so the stored timestamps
moved only on a change: a machine whose settings had been stable for six
months was indistinguishable from a machine whose backup died six months ago.
The only evidence a backup still ran was a line in a log file on the PC.
`lastseenat` is touched on every matching post, including the no-op, and is
backfilled from the timestamps that already exist.
- Dashboard cards for enforcement failures, PCs not reporting, and backups that
have stopped. The dashboard now renders cards plugins declare, which it never
did before - five plugins had been declaring widgets into a void, pointing at
components nobody wrote. Cards are ordered by severity, hide themselves when
there is nothing to report, gate on a permission, and each fetches
independently so one broken endpoint cannot blank the board.
## [0.9.0] - 2026-08-11
Driven by a fleet that had been failing quietly. A bay had been returning 500

View File

@@ -0,0 +1,127 @@
<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">
<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: things that need a person, each linking to itself -->
<ul v-else class="dc-rows">
<li v-for="(row, index) in cardRows(card)" :key="index" class="dc-row">
<router-link v-if="row.link" :to="row.link" class="dc-row-title">
{{ row.title }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink">{{ row.title }}</span>
<span v-if="row.detail" class="dc-row-detail">{{ row.detail }}</span>
<span v-for="(meta, m) in row.meta" :key="m" class="dc-row-meta"
:class="{ mono: meta.mono }">{{ meta.text }}</span>
</li>
</ul>
<p v-if="!hasRows(card)" class="dc-clear">Nothing to action.</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 {
toApiPath, cardRows, 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 hasRows(card) {
return card.render === 'metric' ? metricValue(card) > 0 : cardData(card).length > 0
}
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;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.dc-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: 8px;
padding: 0.9rem 1rem;
}
/* Severity is carried by the left edge only. A fully coloured card reads as an
alert even when it holds one minor row, and six of them read as a crisis. */
.dc-critical { border-left-color: var(--danger); }
.dc-warning { border-left-color: var(--warning); }
.dc-info { border-left-color: var(--primary); }
.dc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
.dc-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); }
.dc-count { font-size: 0.8rem; color: var(--text-light); }
.dc-metric { margin: 0.4rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
.dc-rows { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.dc-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.85rem; }
.dc-row-title { font-weight: 600; color: var(--link); text-decoration: none; }
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
.dc-row-detail { color: var(--text); }
.dc-row-meta { color: var(--text-light); }
.dc-row-meta.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
.dc-clear { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--text-light); }
</style>

View File

@@ -0,0 +1,137 @@
// Pure render helpers for DashboardCards.vue - the fleet dashboard's generic
// renderers. Same shape as pluginAssetPanels.js: the mapping logic that turns a
// plugin's JSON card declaration plus its endpoint data into rendered rows
// lives here so it can be unit tested without mounting a component.
//
// WHY GENERIC RENDERERS AT ALL: the older widget contract named a Vue component
// per widget. That cannot survive a lean build, where a plugin's component may
// never be staged into the frontend bundle - which is exactly why five plugins
// declared widgets pointing at components nobody ever wrote. A plugin declares
// data and shape; core owns the drawing.
export function toApiPath(endpoint) {
// Cards declare absolute endpoints (/api/...); the api instance already
// carries the /api base, so strip it before fetching through the instance.
return String(endpoint).replace(/^\/api(?=\/)/, '')
}
export function rows(card) {
const data = card._data
if (Array.isArray(data)) return data
if (data && Array.isArray(data.rows)) return data.rows
if (data && Array.isArray(data.items)) return data.items
return []
}
export function formatValue(value, spec = {}) {
if (value === null || value === undefined || value === '') return ''
if (spec.format === 'date') {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
export function mapTitle(card, item) {
const key = card.map && card.map.title
return key ? item[key] : ''
}
export function mapDetail(card, item) {
const key = card.map && card.map.detail
return key ? item[key] : ''
}
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
.map((spec) => {
const value = formatValue(item[spec.key], spec)
if (value === '') return null
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
// where 'quiet for 3' reads as a truncation.
const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}`
return { text, mono: !!spec.mono }
})
.filter(Boolean)
}
// A link template is '/pcs/{computerid}'. A row whose substitution value is
// missing gets NO link rather than a link to '/pcs/undefined' - the row still
// shows, because the finding is real even when the inventory is behind.
export function mapLink(card, item) {
const template = card.map && card.map.link
if (!template) return null
let missing = false
const href = String(template).replace(/\{(\w+)\}/g, (_m, key) => {
const value = item[key]
if (value === null || value === undefined || value === '') {
missing = true
return ''
}
return value
})
return missing ? null : href
}
export function cardRows(card) {
return rows(card).map((item) => ({
title: mapTitle(card, item),
detail: mapDetail(card, item),
meta: mapMeta(card, item),
link: mapLink(card, item),
timestamp: card.map && card.map.timestamp ? item[card.map.timestamp] : null,
}))
}
// A metric card is the count, for the cases where the count IS the story.
export function metricValue(card) {
const data = card._data
if (typeof data === 'number') return data
if (data && typeof data.value === 'number') return data.value
return rows(card).length
}
// EMPTY HANDLING is the part that keeps the board readable. A card reporting
// "nothing wrong" every day teaches people to stop reading the page - the same
// way a fleet log reached 3,234 lines of which 3,217 were one repeated line.
// Default is to disappear; 'line' is for the few where absence is itself news.
export function cardVisible(card) {
if (card._data === null || card._data === undefined) return false // fetch failed
const populated = card.render === 'metric'
? metricValue(card) > 0
: rows(card).length > 0
if (populated) return true
return card.empty === 'line'
}
// Sorted by how much it wants a person: critical, then warning, then info, and
// by declared position within a severity. Ordering by position alone would let
// an info card sit above a failure.
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 }
export function sortCards(cards) {
return [...cards].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity] ?? 3
const sb = SEVERITY_ORDER[b.severity] ?? 3
if (sa !== sb) return sa - sb
return (a.position ?? 99) - (b.position ?? 99)
})
}
// A card declares the permission its endpoint requires. Filtering here keeps
// the dashboard from becoming a way around RBAC, and avoids firing a request
// that would only 403. `hasPermission` comes from the auth store.
export function permittedCards(cards, hasPermission) {
return cards.filter((card) => !card.permission || hasPermission(card.permission))
}
// Only cards using a renderer core actually has. An unknown render mode is
// skipped rather than drawn blank, so a plugin built against a newer core
// degrades instead of leaving a hole on the page.
export const RENDERERS = ['exceptions', 'metric', 'list']
export function renderableCards(cards) {
return cards.filter((card) => RENDERERS.includes(card.render))
}

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
cardVisible, sortCards, permittedCards, renderableCards,
} from './dashboardCards'
const failuresCard = {
id: 'geenforce-failures',
render: 'exceptions',
severity: 'critical',
position: 10,
map: {
title: 'hostname',
detail: 'entryname',
meta: [{ key: 'message' }, { key: 'exitcode', label: 'exit' }],
link: '/pcs/{computerid}',
},
}
describe('endpoint paths', () => {
it('strips the api prefix the instance already carries', () => {
expect(toApiPath('/api/geenforce/dashboard/failures'))
.toBe('/geenforce/dashboard/failures')
})
it('leaves a relative endpoint alone', () => {
expect(toApiPath('/geenforce/x')).toBe('/geenforce/x')
})
})
describe('reading rows from whatever shape the endpoint returns', () => {
it('accepts a bare array, {rows} or {items}', () => {
expect(rows({ _data: [1, 2] })).toEqual([1, 2])
expect(rows({ _data: { rows: [1] } })).toEqual([1])
expect(rows({ _data: { items: [1, 2, 3] } })).toEqual([1, 2, 3])
})
it('treats a failed fetch as no rows rather than throwing', () => {
expect(rows({ _data: null })).toEqual([])
})
})
describe('mapping a row', () => {
it('builds title, detail, meta and link from the declaration', () => {
const card = { ...failuresCard, _data: [{
hostname: 'WJSF1234', entryname: 'Install OpenText',
exitcode: 1603, message: 'Fatal error', computerid: 42,
}] }
const [row] = cardRows(card)
expect(row.title).toBe('WJSF1234')
expect(row.detail).toBe('Install OpenText')
expect(row.meta.map((m) => m.text)).toEqual(['Fatal error', 'exit 1603'])
expect(row.link).toBe('/pcs/42')
})
it('supports a trailing unit so the text reads as a sentence', () => {
const card = {
render: 'exceptions',
map: { title: 'hostname', meta: [{ key: 'quietdays', label: 'quiet for', suffix: ' days' }] },
_data: [{ hostname: 'QUIETPC', quietdays: 3 }],
}
expect(cardRows(card)[0].meta[0].text).toBe('quiet for 3 days')
})
it('drops empty meta values instead of rendering a stray label', () => {
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
expect(cardRows(card)[0].meta).toEqual([])
})
it('omits the link when the substitution value is missing, keeping the row', () => {
// A PC shopdb does not know still reports its failure - that is the bay
// most likely to be misconfigured. It must not link to /pcs/undefined.
const card = { ...failuresCard, _data: [{ hostname: 'GHOSTPC', computerid: null }] }
const [row] = cardRows(card)
expect(row.title).toBe('GHOSTPC')
expect(row.link).toBeNull()
})
})
describe('empty handling', () => {
it('hides a card with nothing to report by default', () => {
// The whole point: a card saying "nothing wrong" daily trains people to
// stop reading the page.
expect(cardVisible({ render: 'exceptions', _data: [] })).toBe(false)
})
it('keeps a card whose absence is itself news when it opts in', () => {
expect(cardVisible({ render: 'exceptions', _data: [], empty: 'line' })).toBe(true)
})
it('shows a card that has findings', () => {
expect(cardVisible({ render: 'exceptions', _data: [{ a: 1 }] })).toBe(true)
})
it('hides a card whose fetch failed rather than drawing it empty', () => {
expect(cardVisible({ render: 'exceptions', _data: null })).toBe(false)
})
it('hides a zero metric but shows a non-zero one', () => {
expect(cardVisible({ render: 'metric', _data: { value: 0 } })).toBe(false)
expect(cardVisible({ render: 'metric', _data: { value: 3 } })).toBe(true)
expect(metricValue({ render: 'metric', _data: { value: 3 } })).toBe(3)
})
})
describe('ordering', () => {
it('puts severity before position, so info never sits above a failure', () => {
const ordered = sortCards([
{ id: 'info-recent', severity: 'info', position: 1 },
{ id: 'crit-failures', severity: 'critical', position: 90 },
{ id: 'warn-toner', severity: 'warning', position: 50 },
])
expect(ordered.map((c) => c.id))
.toEqual(['crit-failures', 'warn-toner', 'info-recent'])
})
it('falls back to position within one severity', () => {
const ordered = sortCards([
{ id: 'b', severity: 'critical', position: 20 },
{ id: 'a', severity: 'critical', position: 10 },
])
expect(ordered.map((c) => c.id)).toEqual(['a', 'b'])
})
})
describe('gating', () => {
it('drops a card the user lacks permission for', () => {
// The dashboard must not become a way around RBAC.
const has = (name) => name === 'printers.view'
const kept = permittedCards([
{ id: 'toner', permission: 'printers.view' },
{ id: 'enforce', permission: 'geenforce.manage' },
{ id: 'open', permission: null },
], has)
expect(kept.map((c) => c.id)).toEqual(['toner', 'open'])
})
it('skips a render mode this core does not have', () => {
// A plugin built against a newer core degrades instead of leaving a hole.
const kept = renderableCards([
{ id: 'ok', render: 'exceptions' },
{ id: 'future', render: 'sparkline' },
])
expect(kept.map((c) => c.id)).toEqual(['ok'])
})
})

View File

@@ -7,6 +7,12 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- What needs a person, before the totals that are true every day.
Cards are declared by plugins (get_dashboard_widgets) and rendered
generically; an empty or failed card hides itself. See
docs/proposals/dashboard-live-fleet.md. -->
<DashboardCards />
<!-- Main Stats -->
<div class="dashboard-grid">
<div class="stat-card">
@@ -92,6 +98,7 @@
</template>
<script setup>
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue'
import { dashboardApi, assetsApi, printersApi } from '../api'

View File

@@ -344,3 +344,19 @@ def _diffprojections(old, new):
'aftertype': None if after is None else after[0],
})
return changes
@backups_bp.route('/dashboard/stale', methods=['GET'])
@jwt_required()
@require_permission('backups.view')
def dashboard_stale():
"""Chains whose backup has stopped running.
Thin: the rule lives in services/staleness.py, where it is testable without
an auth layer in the way. Keyed on the last CONFIRMED check, never on the
last change - dedup means an unchanged config writes no revision, so a card
keyed on revision age would flag most of a healthy fleet.
"""
from ..services.staleness import stalechains
return success_response(stalechains())

View File

@@ -0,0 +1,43 @@
"""backups: record when a configuration was last confirmed unchanged.
Dedup means an unchanged config writes NO revision, so `collectedat` moves only
when something changes. A machine whose settings have been stable for six months
therefore has a six-month-old newest revision and is entirely healthy - and
ShopDB had no way to tell it apart from a machine whose backup stopped running
six months ago. The evidence that a check happened existed only in a log file on
the PC.
`lastseenat` records the check rather than the change. It is touched on every
matching post, including the no-op that writes nothing else.
Backfilled from `collectedat` (falling back to `createdat`) so existing rows
start from the last moment we can actually prove the config was current, rather
than from now - claiming a fresh check that never happened would be worse than
saying nothing.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'backups0002lastseenat'
down_revision = 'backups0001baseline'
branch_labels = None
depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
op.execute('UPDATE backuprevisions '
'SET lastseenat = COALESCE(collectedat, createdat)')
def downgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')

View File

@@ -89,6 +89,16 @@ class BackupRevision(db.Model):
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# When this configuration was last CONFIRMED still current, which is not the
# same as when it last changed. Dedup means an unchanged config writes no
# revision, so collectedat only ever moves on a change - a machine whose
# settings have been stable for six months has a six-month-old newest
# revision and is perfectly healthy. Without this column ShopDB cannot tell
# that machine from one whose backup stopped running six months ago, which
# is the one question a backup system has to be able to answer. Touched on
# every matching post, including the no-op that writes nothing else.
lastseenat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('ixbackuprevisionsassetkind', 'assetid', 'backupkind'),
)
@@ -138,6 +148,10 @@ class BackupRevision(db.Model):
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
# offset before any site-timezone formatting is applied.
'collectedat': _utciso(self.collectedat),
# When the config was last CONFIRMED current, versus when it last
# changed. The history view needs both or a stable machine looks
# abandoned.
'lastseenat': _utciso(self.lastseenat),
'createdat': _utciso(self.createdat),
}
if includepayload:

View File

@@ -161,6 +161,17 @@ class BackupsPlugin(BasePlugin):
'description': 'UNC root that opaque (non-JSON) backups are '
'written under by the collecting PC. Site-specific.',
},
{
'key': 'backups_staledays',
'value': '3',
'valuetype': 'integer',
'category': 'backups',
'description': 'Days without a CONFIRMED check before a backup '
'is listed as stopped on the dashboard. Measured '
'from the last check, not the last change - an '
'unchanged config writes no revision. 0 disables '
'the card.',
},
{
'key': 'backups_retentioncount',
'value': '50',
@@ -180,6 +191,36 @@ class BackupsPlugin(BasePlugin):
},
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: backups that have stopped running.
Keyed on the last CONFIRMED check, never on the last change. Dedup means
an unchanged config writes no revision, so a card keyed on revision age
would flag most of a healthy fleet.
"""
return [
{
'id': 'backups-stale',
'title': 'Backups stopped',
'endpoint': '/api/backups/dashboard/stale',
'render': 'exceptions',
'severity': 'warning',
'permission': 'backups.view',
'empty': 'hide',
'position': 30,
'map': {
'title': 'assetnumber',
'detail': 'backupkind',
'meta': [
{'key': 'sourcehostname', 'label': 'from'},
{'key': 'quietdays', 'label': 'last checked',
'suffix': ' days ago'},
],
'link': '/backups/asset/{assetid}',
},
},
]
# ---- ADR-006 collector contract -------------------------------------
def get_collector_schema(self) -> Optional[dict]:
@@ -299,6 +340,13 @@ class BackupsPlugin(BasePlugin):
.first())
if latest is not None and latest.contenthash == contenthash:
# Unchanged, so no revision - but RECORD THE CHECK. Without this the
# only evidence a backup still runs is a line in a log on the PC,
# and a config stable for six months is indistinguishable from a
# backup that died six months ago. Cheap: one column on a row that
# already exists, no new history.
latest.lastseenat = datetime.utcnow()
db.session.flush()
return {
'action': 'noop',
'assetid': assetid,
@@ -330,6 +378,7 @@ class BackupsPlugin(BasePlugin):
bytesize=bytesize,
sourcehostname=payload.get('sourcehostname'),
collectedat=collectedat or datetime.utcnow(),
lastseenat=datetime.utcnow(),
)
revision.payload = projection
db.session.add(revision)

View File

@@ -0,0 +1,85 @@
"""Which backup chains have stopped running.
Split from the route for the same reason retention.py is: the rule here is the
interesting part and it should be testable without an auth layer in the way.
THE RULE, and it is not the obvious one. Dedup means an unchanged configuration
writes no revision, so `collectedat` moves only when something CHANGES. A
machine whose settings have been stable for six months has a six-month-old
newest revision and is entirely healthy. Staleness is therefore measured from
`lastseenat` - when the config was last CONFIRMED current, recorded on every
matching post including the no-op that writes nothing else.
Keyed on the CHAIN (asset, kind, source PC), because that is the unit that fails
independently. A machine with two part markers can have one still reporting
while the other stopped, and a per-asset view would report the machine as fine.
"""
from datetime import datetime, timedelta
from shopdb.api import db, Asset
from ..models import BackupRevision
DEFAULTSTALEDAYS = 3
def staledays():
"""Configured threshold; 0 or negative disables the card entirely."""
from shopdb.api import Setting
setting = Setting.query.filter_by(key='backups_staledays').first()
if setting and (setting.value or '').strip():
try:
return int(setting.value)
except (TypeError, ValueError):
pass
return DEFAULTSTALEDAYS
def latestperchain():
"""Newest revision of every (asset, kind, source) chain."""
latest = {}
for revision in (BackupRevision.query
.order_by(BackupRevision.backuprevisionid.asc()).all()):
latest[(revision.assetid, revision.backupkind,
revision.sourcehostname)] = revision
return latest
def stalechains(days=None, limit=50):
"""Chains whose last confirmed check is older than the threshold.
Deliberately silent about assets that have NEVER been backed up. Whether one
SHOULD be is a question only the manifest can answer - most machines carry
no NTLARS at all - and guessing would list a hundred healthy assets, which
is the noise that makes a board worth ignoring. That is the
desired-versus-observed card, which needs the manifest resolver.
"""
days = staledays() if days is None else days
if days <= 0:
return []
now = datetime.utcnow()
cutoff = now - timedelta(days=days)
rows = []
for revision in latestperchain().values():
# Rows written before lastseenat existed fall back to the timestamps
# that do exist, so an old install reports something sane on day one
# rather than every chain at once.
seen = revision.lastseenat or revision.collectedat or revision.createdat
if seen is None or seen >= cutoff:
continue
asset = db.session.get(Asset, revision.assetid)
rows.append({
'assetid': revision.assetid,
'assetnumber': asset.assetnumber if asset else str(revision.assetid),
'backupkind': revision.backupkind,
'sourcehostname': revision.sourcehostname,
'lastseenat': seen.isoformat() + 'Z',
'quietdays': (now - seen).days,
})
rows.sort(key=lambda r: -r['quietdays'])
return rows[:limit]

View File

@@ -934,3 +934,63 @@ def dashboard_summary():
'shopfloor': shopfloor_count,
'nonshopfloor': total - shopfloor_count
})
@computers_bp.route('/dashboard/quiet', methods=['GET'])
@jwt_required()
@require_permission('computers.view')
def dashboard_quiet():
"""PCs that have stopped reporting, and PCs that never started.
Two populations, deliberately distinguished rather than merged into one
count. A PC that reported and went quiet is probably switched off, moved or
broken. A PC that has NEVER reported is worse: it is either not enrolled, or
enrolled against the wrong pc-type, so nothing enforces anything on it and
no backup of it exists. That one hides indefinitely because nothing about it
is failing loudly - and it is exactly the shape of the bay that carried a
wrong machine number for weeks.
Silence is the only signal available here. There is no heartbeat separate
from the report, so 'stopped reporting' is measured against the collector's
own last write.
"""
from datetime import datetime, timedelta, timezone
hours = 24
setting = Setting.query.filter_by(key='computers_quietreporthours').first()
if setting and (setting.value or '').strip():
try:
hours = int(setting.value)
except (TypeError, ValueError):
pass
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=hours)
query = (db.session.query(Computer, Asset)
.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.isactive.is_(True)))
rows = []
for comp, asset in query.all():
last = comp.lastreporteddate
if last is None:
rows.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'state': 'never reported',
'lastreported': None,
'quietdays': None,
})
elif last < cutoff:
rows.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'state': 'stopped reporting',
'lastreported': last.isoformat() + 'Z',
'quietdays': (datetime.now(timezone.utc).replace(tzinfo=None)
- last).days,
})
# Never-reported first, then longest silence: the order someone should work
# down the list, not the order the rows came out of the table.
rows.sort(key=lambda r: (r['quietdays'] is not None, -(r['quietdays'] or 0)))
return success_response(rows[:50])

View File

@@ -163,6 +163,14 @@ class ComputersPlugin(BasePlugin):
site that installed an earlier one.
"""
return [
{
'key': 'computers_quietreporthours',
'value': '24',
'valuetype': 'integer',
'category': 'computers',
'description': 'Hours without a collector report before a PC '
'is listed as not reporting on the dashboard.',
},
{
'key': 'computers_machinelink_alerts',
'value': 'false',
@@ -1247,14 +1255,30 @@ class ComputersPlugin(BasePlugin):
return [computerscli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
"""Dashboard cards this plugin contributes.
REPLACES a declaration that named a Vue component which was never
written - as did four other plugins - so nothing rendered and nobody
noticed, because the frontend never called the widgets endpoint either.
The contract now carries data and a renderer; core draws it.
"""
return [
{
'name': 'Computer Status',
'component': 'ComputerStatusWidget',
'endpoint': '/api/computers/dashboard/summary',
'size': 'medium',
'position': 6,
'id': 'computers-quiet',
'title': 'PCs not reporting',
'endpoint': '/api/computers/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
'permission': 'computers.view',
'empty': 'hide',
'position': 20,
'map': {
'title': 'hostname',
'detail': 'state',
'meta': [{'key': 'quietdays', 'label': 'quiet for',
'suffix': ' days'}],
'link': '/pcs/{computerid}',
},
},
]

View File

@@ -26,7 +26,8 @@ SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ManifestPayload, ManifestBlob, ENTRY_TYPES, PHASES,
ManifestEnforcementReport, ManifestEnforcementResult, ManifestPayload,
ManifestBlob, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
@@ -954,3 +955,57 @@ def get_report(reportid):
'message': r.message,
} for r in report.results],
})
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def dashboard_failures():
"""Entries that FAILED on their PC's most recent enforcement cycle.
The dashboard card behind this answers a question nothing else does: what is
broken on the floor right now. The reports table has held it all along -
status, the failing entry, its exit code and the engine's message - and the
only way to see any of it was to open one PC's report modal, one PC at a
time. A bay returned 500 to every collector report for a day and a half
before anyone looked.
Per ENTRY, not per report: "three PCs failed" is a number, while "Install
OpenText failed with exit 1603 on WJSF1234" is something a person can act
on. Only current reports (iscurrent) are considered, so a failure that has
since been fixed disappears on its own rather than needing dismissing.
"""
rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport)
.join(ManifestEnforcementReport,
ManifestEnforcementResult.reportid ==
ManifestEnforcementReport.reportid)
.filter(ManifestEnforcementReport.iscurrent.is_(True),
ManifestEnforcementResult.action == 'failed')
.order_by(ManifestEnforcementReport.receivedat.desc())
.limit(50)
.all())
# Resolve hostnames to computerids in ONE query so the card can link to the
# PC rather than just naming it. A row whose PC is unknown to shopdb still
# shows - the failure is real even when the inventory is behind.
hostnames = {report.hostname for _result, report in rows}
idbyhost = {}
if hostnames:
try:
from plugins.computers.models import Computer
for computer in Computer.query.filter(
Computer.hostname.in_(hostnames)).all():
idbyhost[(computer.hostname or '').lower()] = computer.computerid
except ImportError:
pass
return success_response([{
'hostname': report.hostname,
'computerid': idbyhost.get((report.hostname or '').lower()),
'scopename': report.scopename,
'entryname': result.entryname,
'exitcode': result.exitcode,
'message': (result.message or '').strip()[:200],
'receivedat': (report.receivedat.isoformat() + 'Z'
if report.receivedat else None),
} for result, report in rows])

View File

@@ -66,6 +66,42 @@ class GeEnforcePlugin(BasePlugin):
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard cards this plugin contributes.
DATA AND SHAPE, not a component name. The older widget contract named a
Vue component per widget, which cannot survive a lean build - a
plugin's component may never be staged into the frontend bundle - and
is why five plugins declared widgets pointing at components nobody ever
wrote. Core owns a small set of generic renderers; a plugin says what
to show and how to link it. Same lesson ADR-010 already applied to
asset panels.
`empty: hide` matters as much as the data. A card that reports "nothing
wrong" every day teaches people to stop reading the page, which is
exactly how a fleet log reached 3,234 lines with 17 that mattered.
"""
return [
{
'id': 'geenforce-failures',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',
'permission': 'geenforce.manage',
'empty': 'hide',
'position': 10,
'map': {
'title': 'hostname',
'detail': 'entryname',
'meta': [{'key': 'message'}, {'key': 'exitcode',
'label': 'exit'}],
'link': '/pcs/{computerid}',
'timestamp': 'receivedat',
},
},
]
def get_permissions(self) -> List:
"""RBAC permissions this plugin owns (edit vs ship are split)."""
return [

View File

@@ -46,8 +46,10 @@ CUTOVER_PLUGINS = (
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# backups is also post-cutover: its 0001 really creates backuprevisions.
EXPECTED_HEAD_REVISION['backups'] = 'backups0001baseline'
# backups is also post-cutover: its 0001 really creates backuprevisions, and
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away.
EXPECTED_HEAD_REVISION['backups'] = 'backups0002lastseenat'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'

View File

@@ -13,6 +13,7 @@ Two layers:
"""
import base64
from datetime import datetime, timedelta
import json
import pytest
@@ -862,3 +863,100 @@ def test_the_collection_interval_is_readable_without_a_login(bk_app):
BackupsPlugin().get_settings_defaults()}
assert declared['backups_intervalhours'].get('public') is True
assert not declared['backups_shareroot'].get('public')
# =============================================================================
# Dashboard: backups that have STOPPED, which is not the same as unchanged
# =============================================================================
def test_an_unchanged_config_records_the_check(bk_app, bk_plugin):
"""The fix that makes the card possible at all. Dedup writes no revision on
a no-op, so without recording the check there is no evidence a backup still
runs - a config stable for six months looks identical to a backup that died
six months ago."""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
first = bk_plugin.apply_collector_payload(_payload())
revision = _db.session.get(BackupRevision, first['backuprevisionid'])
_db.session.query(BackupRevision).update(
{'lastseenat': datetime(2026, 1, 1)})
_db.session.commit()
second = bk_plugin.apply_collector_payload(_payload())
assert second['action'] == 'noop'
_db.session.refresh(revision)
assert revision.lastseenat > datetime(2026, 1, 2)
def test_a_stable_machine_is_not_called_stale(bk_app, bk_plugin):
"""A machine whose settings have not changed in months is HEALTHY. Keying
the card on revision age would have flagged most of the fleet."""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
bk_plugin.apply_collector_payload(_payload())
# Changed long ago, checked just now: the normal steady state.
_db.session.query(BackupRevision).update({
'collectedat': datetime.utcnow() - timedelta(days=180),
'lastseenat': datetime.utcnow(),
})
_db.session.commit()
from plugins.backups.services.staleness import stalechains
assert stalechains() == []
def test_a_machine_that_stopped_checking_in_is_listed(bk_app, bk_plugin):
from plugins.backups.models import BackupRevision
with bk_app.app_context():
bk_plugin.apply_collector_payload(_payload())
_db.session.query(BackupRevision).update(
{'lastseenat': datetime.utcnow() - timedelta(days=10)})
_db.session.commit()
from plugins.backups.services.staleness import stalechains
[row] = stalechains()
assert row['quietdays'] == 10
assert row['backupkind'] == 'ntlars'
def test_one_marker_stopping_is_not_hidden_by_its_healthy_sibling(bk_app,
bk_plugin):
"""Per CHAIN, not per asset. Two part markers share one machine number, and
a per-asset view would report the machine as fine while one device has been
silent for a week."""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
bk_plugin.apply_collector_payload(
_payload(reg=_markerreg('COM3'), sourcehostname='MARKERA'))
bk_plugin.apply_collector_payload(
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
(_db.session.query(BackupRevision)
.filter_by(sourcehostname='MARKERA')
.update({'lastseenat': datetime.utcnow() - timedelta(days=9)}))
(_db.session.query(BackupRevision)
.filter_by(sourcehostname='MARKERB')
.update({'lastseenat': datetime.utcnow()}))
_db.session.commit()
from plugins.backups.services.staleness import stalechains
rows = stalechains()
assert [r['sourcehostname'] for r in rows] == ['MARKERA']
def test_the_threshold_is_a_setting_and_zero_disables_the_card(bk_app, bk_plugin):
from shopdb.core.models import Setting
from plugins.backups.models import BackupRevision
with bk_app.app_context():
bk_plugin.apply_collector_payload(_payload())
_db.session.query(BackupRevision).update(
{'lastseenat': datetime.utcnow() - timedelta(days=2)})
_db.session.add(Setting(key='backups_staledays', value='1'))
_db.session.commit()
from plugins.backups.services.staleness import stalechains
assert len(stalechains()) == 1
_db.session.query(Setting).filter_by(key='backups_staledays').update(
{'value': '0'})
_db.session.commit()
assert stalechains() == []

View File

@@ -0,0 +1,120 @@
"""Dashboard card: PCs that stopped reporting, and PCs that never started.
Two populations, deliberately not merged. A PC that reported and went quiet is
probably off, moved or broken. A PC that has NEVER reported is worse - not
enrolled, or enrolled against the wrong pc-type, so nothing enforces anything on
it and no backup of it exists. That one hides indefinitely because nothing about
it fails loudly.
"""
from datetime import datetime, timedelta, timezone
from shopdb.core.models import Asset, AssetType, Setting
URL = '/api/computers/dashboard/quiet'
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pc(db, hostname, lastreported='now', isactive=True):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
statusid=1, isactive=isactive)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname)
if lastreported == 'now':
comp.lastreporteddate = _now()
elif lastreported is None:
comp.lastreporteddate = None
else:
comp.lastreporteddate = _now() - timedelta(hours=lastreported)
db.session.add(comp)
db.session.commit()
return comp
def test_a_reporting_pc_is_not_listed(client, db, auth_headers):
_pc(db, 'BUSYPC')
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
def test_a_pc_gone_quiet_is_listed_with_how_long(client, db, auth_headers):
_pc(db, 'QUIETPC', lastreported=72)
[row] = client.get(URL, headers=auth_headers).get_json()['data']
assert row['hostname'] == 'QUIETPC'
assert row['state'] == 'stopped reporting'
assert row['quietdays'] == 3
def test_a_pc_that_never_reported_is_called_out_separately(client, db,
auth_headers):
"""Never-reported is a different fault from gone-quiet: nothing is
enforcing anything on that PC and no backup of it exists."""
_pc(db, 'NEVERPC', lastreported=None)
[row] = client.get(URL, headers=auth_headers).get_json()['data']
assert row['state'] == 'never reported'
assert row['lastreported'] is None
def test_never_reported_sorts_above_the_merely_quiet(client, db, auth_headers):
"""The order someone should work down the list."""
_pc(db, 'QUIETPC', lastreported=48)
_pc(db, 'NEVERPC', lastreported=None)
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert [r['hostname'] for r in rows] == ['NEVERPC', 'QUIETPC']
def test_the_longest_silence_comes_first(client, db, auth_headers):
_pc(db, 'DAY2', lastreported=48)
_pc(db, 'DAY9', lastreported=216)
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert [r['hostname'] for r in rows] == ['DAY9', 'DAY2']
def test_the_window_is_a_setting_not_a_hardcode(client, db, auth_headers):
"""Every site will disagree with 24 hours (ADR-015)."""
_pc(db, 'OVERNIGHT', lastreported=10)
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
db.session.add(Setting(key='computers_quietreporthours', value='4'))
db.session.commit()
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert [r['hostname'] for r in rows] == ['OVERNIGHT']
def test_a_bad_setting_falls_back_rather_than_failing_the_card(client, db,
auth_headers):
db.session.add(Setting(key='computers_quietreporthours', value='soon'))
db.session.commit()
_pc(db, 'QUIETPC', lastreported=72)
assert client.get(URL, headers=auth_headers).status_code == 200
def test_a_retired_pc_is_not_nagged_about(client, db, auth_headers):
"""A decommissioned PC is silent on purpose. Listing it would train people
to ignore the card, which is the failure mode this whole board avoids."""
_pc(db, 'RETIRED', lastreported=None, isactive=False)
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
def test_the_card_is_permission_gated(client, db, member_headers):
assert client.get(URL, headers=member_headers).status_code == 403
def test_the_widget_uses_the_new_contract(app):
from plugins.computers.plugin import ComputersPlugin
widget = ComputersPlugin().get_dashboard_widgets()[0]
assert 'component' not in widget
assert widget['render'] == 'exceptions'
assert widget['permission'] == 'computers.view'
assert widget['empty'] == 'hide'

View File

@@ -0,0 +1,123 @@
"""GE-Enforce dashboard card: what is broken on the floor right now.
The reports table has always held enforcement failures - the failing entry, its
exit code, the engine's message - and the only way to see any of it was to open
one PC's report modal, one PC at a time. A bay returned 500 to every collector
report for a day and a half before anyone looked. This card is the fix, so
these tests pin the behaviour that makes it useful rather than decorative.
"""
from datetime import datetime
from plugins.geenforce.models import (ManifestEnforcementReport,
ManifestEnforcementResult)
URL = '/api/geenforce/dashboard/failures'
def _report(db, hostname, iscurrent=True, results=(), scopename='gea-shopfloor-cmm',
receivedat=None):
report = ManifestEnforcementReport(
hostname=hostname, scopename=scopename, phase='runtime',
status='failed', iscurrent=iscurrent,
receivedat=receivedat or datetime(2026, 8, 11, 12, 0, 0))
db.session.add(report)
db.session.flush()
for entryname, action, exitcode, message in results:
db.session.add(ManifestEnforcementResult(
reportid=report.reportid, entryname=entryname, action=action,
exitcode=exitcode, message=message))
db.session.commit()
return report
def test_a_failed_entry_is_listed_with_what_a_person_needs(client, db, auth_headers):
"""Per ENTRY, not per report. "Three PCs failed" is a number; "Install
OpenText failed with exit 1603 on WJSF1234" is something to act on."""
_report(db, 'WJSF1234', results=[
('Install OpenText', 'failed', 1603, 'Fatal error during installation')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert len(rows) == 1
assert rows[0]['hostname'] == 'WJSF1234'
assert rows[0]['entryname'] == 'Install OpenText'
assert rows[0]['exitcode'] == 1603
assert 'Fatal error' in rows[0]['message']
def test_only_failures_appear(client, db, auth_headers):
"""A cycle installs, skips and filters far more than it fails. Listing
anything but failures is how a card becomes wallpaper."""
_report(db, 'WJSF1234', results=[
('Install OpenText', 'failed', 1603, 'boom'),
('Install Acrobat', 'installed', 0, ''),
('Set FMS host', 'skipped', 0, ''),
('CMM settings', 'filtered', 0, 'PCTypes filter')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert [r['entryname'] for r in rows] == ['Install OpenText']
def test_a_fixed_failure_disappears_on_its_own(client, db, auth_headers):
"""Superseded reports are not current. A failure that has since been fixed
must clear itself rather than needing someone to dismiss it - otherwise the
card accumulates history and stops meaning "right now"."""
_report(db, 'WJSF1234', iscurrent=False, results=[
('Install OpenText', 'failed', 1603, 'boom')])
_report(db, 'WJSF1234', iscurrent=True, results=[
('Install OpenText', 'installed', 0, '')])
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
def test_the_row_links_to_the_pc_when_shopdb_knows_it(client, db, auth_headers):
"""A card that names a PC without linking to it is a worse report."""
from plugins.computers.models import Computer
from shopdb.core.models import Asset, AssetType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber='WJSF1234',
assettypeid=atype.assettypeid, statusid=1)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname='WJSF1234')
db.session.add(comp)
db.session.commit()
_report(db, 'WJSF1234', results=[('Install OpenText', 'failed', 1603, 'x')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert rows[0]['computerid'] == comp.computerid
def test_a_pc_shopdb_does_not_know_still_reports_its_failure(client, db,
auth_headers):
"""The failure is real even when the inventory is behind. Dropping the row
would hide exactly the bay most likely to be misconfigured."""
_report(db, 'GHOSTPC', results=[('Install OpenText', 'failed', 1603, 'x')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert len(rows) == 1
assert rows[0]['computerid'] is None
def test_the_card_is_permission_gated(client, db, member_headers):
"""A dashboard card must not become a way around RBAC."""
assert client.get(URL, headers=member_headers).status_code == 403
def test_the_widget_declares_data_and_a_renderer_not_a_component(app):
"""The old contract named a Vue component per widget, which cannot survive
a lean build - which is why five plugins declared widgets pointing at
components nobody wrote. A plugin declares data and shape; core renders."""
from plugins.geenforce.plugin import GeEnforcePlugin
widget = GeEnforcePlugin().get_dashboard_widgets()[0]
assert 'component' not in widget
assert widget['render'] == 'exceptions'
assert widget['permission'] == 'geenforce.manage'
# Empty cards must shrink, or the board becomes wallpaper.
assert widget['empty'] == 'hide'
assert widget['map']['link'] == '/pcs/{computerid}'