Dashboard cards, backup last-seen
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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
|
||||
|
||||
127
frontend/src/components/DashboardCards.vue
Normal file
127
frontend/src/components/DashboardCards.vue
Normal 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>
|
||||
137
frontend/src/components/dashboardCards.js
Normal file
137
frontend/src/components/dashboardCards.js
Normal 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))
|
||||
}
|
||||
146
frontend/src/components/dashboardCards.spec.js
Normal file
146
frontend/src/components/dashboardCards.spec.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
85
plugins/backups/services/staleness.py
Normal file
85
plugins/backups/services/staleness.py
Normal 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]
|
||||
@@ -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])
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
120
tests/test_plugins/test_computers_dashboard.py
Normal file
120
tests/test_plugins/test_computers_dashboard.py
Normal 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'
|
||||
123
tests/test_plugins/test_geenforce_dashboard.py
Normal file
123
tests/test_plugins/test_geenforce_dashboard.py
Normal 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}'
|
||||
Reference in New Issue
Block a user