Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours

This commit is contained in:
benjamin.baker
2026-08-30 14:41:56 -04:00
53 changed files with 3671 additions and 483 deletions

6
.gitattributes vendored
View File

@@ -4,3 +4,9 @@
*.sh text eol=lf
scripts/check-naming-and-style.sh text eol=lf
.githooks/pre-commit text eol=lf
# Batch files must be CRLF. cmd.exe parses a lone LF inconsistently around
# labels and goto, so an LF-only .cmd can run its happy path and then jump
# nowhere on the error path - which is the path that matters in a promote
# script.
*.cmd text eol=crlf

View File

@@ -151,6 +151,56 @@ Tests are required for:
- New plugin hooks (contract test)
- Schema migrations (before/after data integrity)
## Branches: what belongs on main, and what does not
`main` is the product every site runs and the publication mirror exports.
`<site>` branches hold work that is not ready for other sites to see. There are
two: `wj` here, and `wilm` on the corporate GitHub, where the other site
develops the .NET endpoint agent.
A site branch is not a fork. Both feed `main`, and the sooner they do it the
cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not
visible from here because this machine has no GitHub account, which is why
`fetch-wilm-branch.cmd` on the share exists to bring it back for review.
**Most work is product.** ADR-015 gives site differences three homes on `main`:
a setting with a neutral default, seed data, or a site-namespaced directory
(`scripts/site_imports/<site>/`, which the style gate exempts). That is the only
site-specific home in the tree, so a site branch is about TIMING, not locality:
things that are not ready yet, rather than things that are permanently ours.
Say which at commit time, with a trailer, because the decision does not survive
being deferred - months later it is a list of subject lines and a guess:
```bash
git commit -m "Add the thing" -m "Scope: product" # main should have it
git commit -m "Add the thing" -m "Scope: site" # this site alone
```
Then:
```bash
tools/wj-status.sh # what is unpromoted, and where it belongs
tools/promote-to-main.sh --dry-run <sha> # what files would reach main
tools/promote-to-main.sh <sha> [<sha> ...] # oldest first; runs main's gates
tools/git-push.sh main # you push, deliberately
```
Windows equivalents are `tools\git-push.cmd` and `tools\promote-to-main.cmd`.
Promotion cherry-picks NAMED commits rather than merging the branch. Merging
takes the site work along with the product work, which is how a site branch
quietly becomes the product.
**The failure to watch for is not what you think.** Promoting something
site-specific by accident is caught by the ADR-015 gate on the way into `main`.
The real risk is product work sitting on a site branch until bringing it back is
a port rather than a merge - one such round trip cost a MySQL dialect fix, a
contract violation and a plugin install.
So rebase the site branch on `main` often, and promote as you go rather than in
one batch at the end.
## Code review checklist
- [ ] No underscores in table or column names

View File

@@ -12,8 +12,8 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0",
"dompurify": "^3.4.11",
"axios": "^1.20.0",
"dompurify": "^3.4.14",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
@@ -1456,14 +1456,37 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/balanced-match": {
@@ -1701,7 +1724,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1754,9 +1776,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -1972,16 +1994,15 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -2008,16 +2029,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -2154,10 +2174,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -2469,20 +2488,18 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -2698,9 +2715,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@@ -2715,9 +2732,8 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2742,10 +2758,12 @@
"dev": true
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",

View File

@@ -19,8 +19,8 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"axios": "^1.6.0",
"dompurify": "^3.4.11",
"axios": "^1.20.0",
"dompurify": "^3.4.14",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",

View File

@@ -1,16 +1,32 @@
<template>
<div class="relationships-section">
<div class="section-header">
<h3 class="section-title">Relationships</h3>
<h3
class="section-title rel-header"
role="button"
tabindex="0"
:aria-expanded="!collapsed"
@click="collapsed = !collapsed"
@keydown.enter.prevent="collapsed = !collapsed"
@keydown.space.prevent="collapsed = !collapsed"
>
<span class="rel-caret" :class="{ collapsed }" aria-hidden="true">&#9662;</span>
<span>Relationships</span>
<!-- The count is what makes a collapsed card honest: without it, "no
relationships" and "four relationships you cannot see" look the
same from the outside. -->
<span v-if="!loading && relationshipCount" class="rel-count">{{ relationshipCount }}</span>
</h3>
<button
v-if="authStore.isAuthenticated"
class="btn btn-sm btn-primary"
@click="showAddModal = true"
@click.stop="openAdd"
>
Add Relationship
</button>
</div>
<div v-show="!collapsed">
<div v-if="loading" class="loading">Loading relationships...</div>
<div v-else-if="!hasRelationships" class="empty-state">
@@ -96,6 +112,7 @@
</div>
</div>
</template>
</div>
<!-- Add Relationship Modal -->
<div v-if="showAddModal" class="modal-overlay">
@@ -186,6 +203,7 @@
</template>
<script setup>
import { assetRoute } from '@/utils/assetRoute'
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
@@ -224,6 +242,22 @@ function colorForType(name) {
}
const showAddModal = ref(false)
// Collapsed by default. The triage band above answers the common case - what
// this asset serves or is driven by - so the full edge list is reference rather
// than the first thing to read. It is NOT redundant with the band and is not
// removed: it is the only surface with Add and delete, and it keeps each edge's
// exact type where the band deduplicates by asset. On a printer, four rows
// (usesprinter and defaultprinter, per machine) collapse to two band chips, and
// which machine DEFAULTS to it is a distinction the band deliberately drops.
const collapsed = ref(true)
function openAdd() {
// Adding from a collapsed card would otherwise drop the new row behind the
// caret, which reads as the add having failed.
collapsed.value = false
showAddModal.value = true
}
// New relationship form
const newRel = ref({
direction: 'outgoing',
@@ -304,6 +338,14 @@ const directionalItems = computed(() => {
return items
})
// Counts what the card RENDERS, not raw edges. A symmetric link (Dualpath) is
// stored in both directions in the legacy shape, so it is two rows in
// outgoing+incoming and one "Connected" entry on screen. Counting edges
// displayed 3 on a machine that shows 2 when you expand it, which is precisely
// the mismatch this badge exists to avoid.
const relationshipCount = computed(
() => connectedItems.value.length + directionalItems.value.length)
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
@@ -472,28 +514,7 @@ function getAssetIcon(assettype) {
return icons[assettype] || Package
}
function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// pluginid is the extension-table id the detail routes key on
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
const getAssetRoute = assetRoute
</script>
<style scoped>
@@ -522,6 +543,38 @@ function getAssetRoute(asset) {
font-weight: 600;
}
/* Matches the collapsible panels on the same page (PluginAssetPanels) so a
card does not gain a second, slightly different caret. */
.rel-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.rel-caret {
font-size: 0.7rem;
color: var(--text-light);
transition: transform 0.15s ease;
}
.rel-caret.collapsed { transform: rotate(-90deg); }
.rel-count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-light);
background: var(--bg-hover, rgba(127, 127, 127, 0.14));
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.rel-caret { transition: none; }
}
.empty-state {
color: var(--text-light);
font-style: italic;

View File

@@ -8,17 +8,31 @@
</div>
<div v-if="machines.length" class="triage-item">
<span class="triage-label">{{ machines.length === 1 ? 'Serves machine' : 'Serves machines' }}</span>
<span class="triage-label">{{ counterpartheading }}</span>
<span class="triage-values">
<router-link
v-for="machine in machines"
:key="machine.assetid"
class="triage-chip link"
:to="`/assets/${machine.assetid}`"
:to="assetRoute(machine)"
>{{ machine.assetnumber || machine.name }}</router-link>
</span>
</div>
<div v-for="item in extraitems" :key="item.label" class="triage-item">
<span class="triage-label">{{ item.label }}</span>
<span class="triage-values">
<component
v-for="link in item.links"
:key="link.text"
:is="link.to ? 'router-link' : 'span'"
:to="link.to || undefined"
class="triage-chip"
:class="{ link: Boolean(link.to) }"
>{{ link.text }}</component>
</span>
</div>
<div v-if="accessmethods && accessmethods.length" class="triage-item">
<span class="triage-label">Remote access</span>
<span class="triage-values">
@@ -63,6 +77,7 @@
*/
import { ref, computed, onMounted, watch, useSlots } from 'vue'
import api from '@/api'
import { assetRoute } from '@/utils/assetRoute'
const props = defineProps({
assetid: { type: Number, required: true },
@@ -71,23 +86,51 @@ const props = defineProps({
// Shape: { level: 'ok'|'warning'|'critical'|'unknown', label: 'Reporting, 4m ago' }
liveness: { type: Object, default: null },
accessmethods: { type: Array, default: () => [] },
locationname: { type: String, default: '' }
locationname: { type: String, default: '' },
// Which side of a 'controls' edge this page wants to see, by asset type
// name. A PC page wants the machines it serves; a machine page wants the
// PCs that drive it. Defaults to 'machine' so the PC page - the only
// caller when this was written - keeps its exact behaviour.
counterparttype: { type: String, default: 'machine' },
// Overrides the heading. Empty keeps the shop-floor phrasing that earned
// its place: a PC matters because a machine stops without it.
counterpartlabel: { type: String, default: '' },
// Which relationship types link this asset to its counterpart. Not every
// pairing is 'controls': a PC controls a machine, but a machine reaches a
// printer through usesprinter and defaultprinter. Hardcoding 'controls'
// left the band blank on the printer page, which is the one page where the
// link is not obvious from the asset itself.
relationshiptypes: { type: Array, default: () => ['controls'] },
// Triage facts a page knows but core cannot derive from a relationship
// edge. The machine page's dual-bay sibling is the case this exists for:
// it is resolved by the machines plugin, not from the relationships
// endpoint, and it used to sit in its own banner above the hero - two
// strips of chrome saying two halves of the same "what am I looking at".
// Shape: [{ label, links: [{ text, to }] }]. A link with no `to` renders
// as plain text rather than a dead chip.
extraitems: { type: Array, default: () => [] }
})
const machines = ref([])
const counterpartheading = computed(() => {
if (props.counterpartlabel) return props.counterpartlabel
return machines.value.length === 1 ? 'Serves machine' : 'Serves machines'
})
const slots = useSlots()
const hasAnything = computed(() =>
Boolean(slots.actions) ||
Boolean(props.liveness) ||
machines.value.length > 0 ||
props.extraitems.length > 0 ||
(props.accessmethods && props.accessmethods.length > 0) ||
Boolean(props.locationname)
)
onMounted(loadMachines)
watch(() => props.assetid, loadMachines)
watch(() => [props.assetid, props.counterparttype, props.relationshiptypes], loadMachines)
async function loadMachines() {
machines.value = []
@@ -96,9 +139,11 @@ async function loadMachines() {
const response = await api.get(`/assets/${props.assetid}/relationships`)
const data = response.data.data || {}
const found = []
// BOTH directions. The canonical edge is PC controls machine, but
// production also holds reciprocal rows, so reading one direction shows
// the link on half the pages and not the other half.
// BOTH directions, then deduped. Production holds reciprocal rows for
// the same link, so reading one direction shows it on half the pages and
// not the other half - and reading both without deduping shows it twice.
// Direction is therefore not trustworthy here and is deliberately not
// used to label anything.
for (const edge of data.outgoing || []) collect(edge, edge.targetasset, found)
for (const edge of data.incoming || []) collect(edge, edge.sourceasset, found)
machines.value = found
@@ -111,10 +156,19 @@ async function loadMachines() {
function collect(edge, other, found) {
if (!other) return
if ((edge.relationshiptypename || '').toLowerCase() !== 'controls') return
if ((other.assettypename || '').toLowerCase() !== 'machine') return
const wanted = props.relationshiptypes.map(t => t.toLowerCase())
if (!wanted.includes((edge.relationshiptypename || '').toLowerCase())) return
if ((other.assettypename || '').toLowerCase() !== props.counterparttype.toLowerCase()) return
if (found.some(m => m.assetid === other.assetid)) return
found.push({ assetid: other.assetid, assetnumber: other.assetnumber, name: other.name })
// pluginid and assettypename are what assetRoute() needs; without them the
// chip falls back to /assets/<assetid>, which is not a route.
found.push({
assetid: other.assetid,
assetnumber: other.assetnumber,
name: other.name,
pluginid: other.pluginid,
assettypename: other.assettypename
})
}
</script>

View File

@@ -1,71 +1,142 @@
<template>
<div v-if="visibleCards.length" class="dc-grid">
<section
v-for="card in visibleCards"
:key="card.id"
class="dc-card"
:class="'dc-' + (card.severity || 'info')"
>
<header class="dc-head">
<span class="dc-dot" aria-hidden="true"></span>
<h3 class="dc-title">{{ card.title }}</h3>
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
</header>
<div class="dc-board">
<!-- metric: the count IS the story -->
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
below rather than navigating away: landing on a number that tells you
whether to keep reading is the one thing an RMM board gets right. -->
<div v-if="totalRows" class="dc-attention">
<button
v-for="level in attentionLevels"
:key="level.key"
type="button"
class="dc-att"
:class="['dc-att-' + level.key, { 'dc-att-on': filter === level.key }]"
:aria-pressed="filter === level.key"
@click="toggleFilter(level.key)"
>
<span class="dc-att-n">{{ level.count }}</span>
<span class="dc-att-label">{{ level.label }}</span>
</button>
<button v-if="filter || sourcefilter" type="button" class="dc-att-clear"
@click="clearFilters">
Clear filter
</button>
</div>
<!-- exceptions / list: one line per thing, each linking to itself -->
<ul v-else class="dc-rows">
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
<!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
<!-- What KIND, next to how bad. When five endpoints go quiet at once,
setting those aside to look at the crashes is the difference between a
board and a list. Chips reflect the severity filter above them, so the
two read as one control rather than two that can contradict. -->
<div v-if="facets.length > 1" class="dc-facets">
<button
v-for="facet in facets"
:key="facet.key"
type="button"
class="dc-facet"
:class="['dc-facet-' + facet.severity, { 'dc-facet-on': sourcefilter === facet.key }]"
:aria-pressed="sourcefilter === facet.key"
@click="toggleSource(facet.key)"
>
{{ facet.label }}
<span class="dc-facet-n">{{ facet.count }}</span>
</button>
</div>
<!-- A check that cannot answer. Not a queue row: there is no asset to act
on, and burying "the monitor is dead" among the things it failed to
find would be the same mistake in a new place. -->
<p v-for="card in unavailable" :key="card.id" class="dc-unavailable">
<span class="dc-unavailable-dot" aria-hidden="true"></span>
{{ card.title }}: {{ reasonFor(card) }}
</p>
<div class="dc-cols">
<!-- ONE queue, worst first, instead of a card per source. Reading order
is work order. -->
<section v-if="canseechecks && (queue.length || overflow.length)"
class="dc-panel dc-queue">
<header class="dc-panel-head">
<h3>Needs attention</h3>
<span class="dc-panel-count">{{ queueTotal }}</span>
</header>
<ul class="dc-rows">
<DashboardRow
v-for="row in queue"
:key="row.key"
:row="row"
:severity="row.severity"
showsource
/>
</ul>
<!-- What the board could not fit, per source and with somewhere to go.
A bare "and 45 more" that leads nowhere is worse than silence. -->
<p v-for="entry in overflow" :key="entry.id" class="dc-more">
<router-link v-if="entry.viewall" :to="entry.viewall" class="dc-more-link">
{{ entry.hidden }} more {{ entry.title.toLowerCase() }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</ul>
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
</p>
</section>
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
quiet and leaving them to find the list is worse than not saying it.
A card without a viewall still says the count, because the number
itself is information. -->
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
<!-- Only claim the board is clear to somebody who can actually see the
checks. A reader with no permission for any of them is not looking at
a healthy fleet, they are looking at nothing - and telling them
"nothing needs attention" is the same lie as reporting Low Supplies 0
while the monitoring is down. -->
<section v-else-if="canseechecks" class="dc-panel dc-clear">
<p class="dc-clear-line">Nothing needs attention.</p>
</section>
<aside class="dc-rail">
<!-- The page owns what sits above the context cards: inventory is not
an exception, so it is not this component's to declare. -->
<slot name="rail"></slot>
<section v-for="card in context" :key="card.id" class="dc-panel">
<header class="dc-panel-head">
<h3>{{ card.title }}</h3>
<span class="dc-panel-count">{{ countOf(card) }}</span>
</header>
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<ul v-else class="dc-rows">
<DashboardRow
v-for="(row, index) in visibleRows(card)"
:key="index"
:row="row"
:stacked="card.layout === 'stacked'"
/>
</ul>
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
</aside>
</div>
</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.
// The fleet dashboard. Plugins declare cards via get_dashboard_widgets
// (GET /api/dashboard/widgets); core owns the drawing. See
// docs/proposals/dashboard-live-fleet.md.
//
// LAYOUT: this used to be one card per source in a fixed grid, and both halves
// hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card beside it left a hole; and a
// box per source flattened priority, because an enforcement failure and a
// warranty notice look equally urgent when each owns a card. Now the
// critical and warning rows merge into one worst-first queue, and info cards
// sit in a rail as context. The severity driving it was always declared - the
// old layout just threw it away.
//
// 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
@@ -74,16 +145,89 @@
import { ref, computed, onMounted } from 'vue'
import api from '../api'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import DashboardRow from '@/components/DashboardRow.vue'
import {
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
permittedCards, renderableCards, rows as cardData,
queueRows, queueOverflow, contextCards, severityTotals,
sourceFacets, unavailableCards, cardUnavailable,
} from './dashboardCards'
const auth = useAuthStore()
const cards = ref([])
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
// Severity filter from the rollup. Empty means show everything.
const filter = ref('')
const liveCards = computed(() => cards.value.filter(cardVisible))
const totals = computed(() => severityTotals(liveCards.value))
const attentionLevels = computed(() => {
const totalcounts = totals.value
return [
{ key: 'critical', label: 'Critical', count: totalcounts.critical },
{ key: 'warning', label: 'Needs a look', count: totalcounts.warning },
{ key: 'info', label: 'For information', count: totalcounts.info },
].filter((level) => level.count > 0)
})
const totalRows = computed(
() => attentionLevels.value.reduce((sum, level) => sum + level.count, 0))
// The filter narrows what is already on the board rather than refetching: the
// counts and the rows then cannot disagree.
const filteredCards = computed(() => (filter.value
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
: liveCards.value))
// Which check to show, on top of the severity filter. Both narrow what is
// already loaded, so no combination can disagree with the counts.
const sourcefilter = ref('')
const facets = computed(() => sourceFacets(filteredCards.value))
const sourceFiltered = computed(() => (sourcefilter.value
? filteredCards.value.filter((card) => card.id === sourcefilter.value)
: filteredCards.value))
const unavailable = computed(() => unavailableCards(liveCards.value))
const queue = computed(() => queueRows(sourceFiltered.value))
const overflow = computed(() => queueOverflow(sourceFiltered.value))
const context = computed(
() => contextCards(filteredCards.value).filter((card) => !cardUnavailable(card)))
const queueTotal = computed(
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
// How many cards this caller is ALLOWED to see, independent of whether they
// currently hold rows. Zero means no visibility into exceptions at all, which
// must not be drawn as "nothing wrong".
const permittedcount = ref(0)
const canseechecks = computed(() => permittedcount.value > 0)
function toggleFilter(level) {
filter.value = filter.value === level ? '' : level
// A source chip that the new severity filter excludes would leave the queue
// empty with two controls both looking active and neither obviously wrong.
if (sourcefilter.value && !facets.value.some((f) => f.key === sourcefilter.value)) {
sourcefilter.value = ''
}
}
function toggleSource(key) {
sourcefilter.value = sourcefilter.value === key ? '' : key
}
function clearFilters() {
filter.value = ''
sourcefilter.value = ''
}
function reasonFor(card) {
return cardUnavailable(card)
}
function countOf(card) {
return cardData(card).length
@@ -103,6 +247,8 @@ async function load() {
const wanted = renderableCards(
permittedCards(declared, (name) => auth.hasPermission(name)))
permittedcount.value = wanted.length
cards.value = await Promise.all(wanted.map(async (card) => {
try {
const response = await api.get(toApiPath(card.endpoint))
@@ -118,154 +264,191 @@ defineExpose({ load })
</script>
<style scoped>
.dc-grid {
display: grid;
/* Four across on a wide screen. A card holds a hostname, a machine number
and a state on one line, so the track cannot go much below this without
truncating the rows that matter most - but 28rem only fits three once the
sidebar takes its share at 1920, which is the common case here. auto-fit
rather than auto-fill so two cards fill the width instead of leaving
empty tracks. */
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
max-width: 120rem;
gap: 1rem;
margin-bottom: 1.75rem;
align-items: start;
.dc-board { margin-bottom: 1.75rem; }
/* ---- rollup ---- */
.dc-attention {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1rem;
}
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
alone this reaches five on an ultrawide, and a fifth column buys nothing -
the cards get narrower and the rows they hold start truncating again. */
@media (min-width: 96rem) {
.dc-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dc-att {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font: inherit;
color: inherit;
}
.dc-card {
.dc-att:hover { border-color: var(--text-light); }
.dc-att-on { box-shadow: 0 0 0 2px var(--primary) inset; }
.dc-att-critical { border-left-color: var(--danger); }
.dc-att-warning { border-left-color: var(--warning); }
.dc-att-info { border-left-color: var(--primary); }
.dc-att-n {
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.dc-att-critical .dc-att-n { color: var(--danger); }
.dc-att-warning .dc-att-n { color: var(--warning); }
.dc-att-info .dc-att-n { color: var(--primary); }
.dc-att-label { font-size: 0.85rem; color: var(--text-light); }
.dc-att-clear {
background: none;
border: none;
color: var(--link);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0 0.5rem;
}
/* ---- source chips ---- */
.dc-facets {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
}
.dc-facet {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.2rem 0.7rem;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
color: var(--text-light);
}
.dc-facet:hover { border-color: var(--text-light); }
.dc-facet-on {
color: var(--text);
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
}
.dc-facet-critical { border-left: 3px solid var(--danger); }
.dc-facet-warning { border-left: 3px solid var(--warning); }
.dc-facet-n {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
}
/* ---- a check that cannot answer ---- */
.dc-unavailable {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
padding: 0.5rem 0.8rem;
border: 1px solid var(--border);
border-left: 3px solid var(--warning);
border-radius: 6px;
background: var(--bg-card);
font-size: 0.85rem;
color: var(--text-light);
}
.dc-unavailable-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--warning);
flex: none;
}
/* ---- two columns ---- */
.dc-cols {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 120rem;
}
/* Context sits BELOW the queue, in a row, rather than in a side rail. The
queue then gets the full width - which is what "not enough room to relay the
data" was about - and a quiet day leaves no column-shaped void where the
queue ran out of rows. */
.dc-rail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 1rem;
align-items: start;
min-width: 0;
}
/* ---- panels ---- */
.dc-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
/* Nothing escapes the card. A printer row carrying three cartridge readings
plus a location was pushing past the border: a flex child will not shrink
below its content width unless told to, so text-overflow never engaged and
the row simply overflowed. min-width:0 below is what actually enables the
ellipsis; this is the backstop. */
overflow: hidden;
min-width: 0;
/* Nothing escapes the panel: a printer row carrying three cartridge readings
plus a location was pushing past the border, because a flex child will not
shrink below its content unless told to. */
overflow: hidden;
}
/* Severity is a small dot beside the title, not a coloured card or a thick
bar. A card painted by severity reads as an alert even when it holds one
minor row, and six of them read as a crisis - which is how a board stops
being read at all. */
.dc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
.dc-critical .dc-dot { background: var(--danger); }
.dc-warning .dc-dot { background: var(--warning); }
.dc-info .dc-dot { background: var(--primary); }
.dc-head {
.dc-panel-head {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.35rem;
}
.dc-title {
.dc-panel-head h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1;
flex: 1 1 auto;
}
.dc-count {
font-size: 0.8rem;
.dc-panel-count {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
font-variant-numeric: tabular-nums;
}
.dc-metric {
margin: 0.5rem 0 0;
font-size: 2rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.dc-metric { margin: 0.5rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
/* One row per line, not a wrapped paragraph. Each row is title / detail /
meta on a single line that truncates, so ten rows are ten scannable lines
rather than a block of text that has to be read. */
.dc-rows { list-style: none; margin: 0; padding: 0; }
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
to make room for text that is then truncated anyway, and neither reads. */
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-title {
font-weight: 600;
color: var(--link);
text-decoration: none;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
.dc-row-detail {
color: var(--text);
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Meta is pushed right and allowed to disappear first: it is the least
important part of the line, and letting it wrap is what made rows look
like paragraphs. */
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
/* Shrinks and truncates before the title or detail do: it is the least
important part of the line, and it was the part running off the edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* One chip per depleted supply. Bordered rather than filled: a row of solid
red pills reads as an emergency even when a cartridge is merely low. */
.dc-chip {
flex: none;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.75rem;
color: var(--text);
white-space: nowrap;
cursor: help;
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
.dc-more {
display: block;
margin: 0.5rem 0 0;
font-size: 0.8rem;
font-size: 0.85rem;
color: var(--text-light);
}
.dc-more-link { color: var(--link); text-decoration: none; }
.dc-more-link:hover { text-decoration: underline; }
.dc-clear-line {
margin: 0.25rem 0;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<li class="dc-row" :class="{ 'dc-row-stacked': stacked }">
<span v-if="severity" class="dc-row-dot" :class="'dc-dot-' + severity" aria-hidden="true"></span>
<!-- With coordinates, the name carries the same floor-plan preview the
asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title" :title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<!-- Which check produced this row. Only in the merged queue, where rows
from different sources sit together and the title alone no longer says
what is wrong with the thing. -->
<span v-if="showsource && row.source" class="dc-row-source"
:title="row.source">{{ row.source }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta && row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</template>
<script setup>
/**
* One row of the dashboard board.
*
* Extracted from DashboardCards.vue when the six per-source cards became one
* merged queue plus a context rail: both render the same row, and the map
* tooltip, link fallbacks and chips are too easy to get subtly different in a
* second copy.
*/
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
defineProps({
row: { type: Object, required: true },
// Severity dot, drawn only in the merged queue. In a single-source panel the
// panel header already says the severity, and a dot on every row is noise.
severity: { type: String, default: '' },
showsource: { type: Boolean, default: false },
stacked: { type: Boolean, default: false },
})
</script>
<style scoped>
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
flex: none;
align-self: center;
}
.dc-dot-critical { background: var(--danger); }
.dc-dot-warning { background: var(--warning); }
.dc-dot-info { background: var(--primary); }
.dc-row-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
min-width: 0;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
/* Which check produced a queued row. Cards declare a `shortname` for this,
because their `title` is a card header - "Endpoints with crashes or
shutdowns" - and as a row badge it ran wider than the hostname it qualified,
putting the badge rather than the asset at the start of every line.
Truncating the header instead was worse: four consecutive rows reading
"ENDPOINTS WITH CR..." carry no information at all, and the badge's one real
job is separating the two checks that read alike, PCs gone silent and
endpoints gone quiet. The cap stays as a guard for a plugin that declares no
shortname. */
.dc-row-source {
flex: 0 1 auto;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.68rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-light);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
}
.dc-row-detail {
color: var(--text-light);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
flex: none;
font-variant-numeric: tabular-nums;
}
.dc-chip {
flex: none;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
color: var(--text-light);
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
</style>

View File

@@ -31,8 +31,25 @@
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
<template v-if="rows(panel).length">
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
<!-- Filter chips for any list panel that shows a health dot. Client
side over rows already fetched, so the counts cannot disagree with
what is on screen. -->
<div v-if="facets(panel).length > 1" class="pap-facets">
<button
v-for="facet in facets(panel)"
:key="facet.value"
type="button"
class="pap-facet"
:class="{ on: isFacetOn(panel, facet.value) }"
@click="toggleFacet(panel, facet.value)"
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
type="button" class="pap-facet pap-facet-clear"
@click="facetSelection[panel.id] = []">Clear</button>
</div>
<template v-if="visibleRows(panel).length">
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge"
@@ -59,6 +76,9 @@
{{ actionError[panel.id] }}
</div>
</template>
<div v-else-if="rows(panel).length" class="pap-empty">
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
</div>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
const facetSelection = ref({})
function isFacetOn(panel, value) {
return (facetSelection.value[panel.id] || []).includes(value)
}
function toggleFacet(panel, value) {
const current = facetSelection.value[panel.id] || []
facetSelection.value = {
...facetSelection.value,
[panel.id]: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
}
}
function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
})
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.pap-facet {
font: inherit;
font-size: 0.78rem;
line-height: 1.6;
padding: 0.05rem 0.5rem;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text-light);
cursor: pointer;
}
.pap-facet:hover { border-color: var(--primary); }
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
.pap-facet-clear { border-style: dashed; }
/* The count is the point: a red dot over a list of info rows is confusing
until the chip says "Critical 2" and one click shows them. */
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
.pap-manage { font-size: 0.82rem; }
/* Two aligned columns rather than space-between: a ragged right edge is hard
to scan once a panel carries more than a handful of rows, and a long value

View File

@@ -51,6 +51,26 @@ export function mapDetailTip(card, item) {
return full && full !== mapDetail(card, item) ? full : ''
}
// "silent 1 days". A suffix is written for the common case, which is plural,
// and the card declaring it has no way to know the value. One row saying it was
// a shrug; the merged queue puts every quiet endpoint on one screen, so it now
// says it five times in a column and reads as broken.
//
// Trims a trailing 's' at exactly 1, on the FIRST word only, keeping whatever
// follows it: ' events (48h)' becomes ' event (48h)'.
//
// The length floor is not arbitrary. It stops abbreviated units being mangled,
// since 'ms' and 'hrs' end in s and are not plurals - a test asserting ' ms'
// stays ' ms' is what caught that. The first version also refused multiword
// suffixes outright to avoid chopping the wrong token, which was too blunt:
// it left ' events (48h)' plural, and capturing the word fixes the same hazard
// properly.
export function singularise(suffix, value) {
const text = suffix || ''
if (Number(value) !== 1) return text
return text.replace(/^ ([a-z]{3,})s(?=$|[ (])/, ' $1')
}
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
@@ -59,7 +79,7 @@ export function mapMeta(card, item) {
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 || ''}`
const text = `${spec.label ? spec.label + ' ' : ''}${value}${singularise(spec.suffix, value)}`
return { text, mono: !!spec.mono }
})
.filter(Boolean)
@@ -164,12 +184,30 @@ export function metricValue(card) {
// "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.
// Why a card has no rows, when it knows. "Nothing is low" and "we cannot tell
// whether anything is low" are different answers, and a card that gives the
// same empty for both is not merely unhelpful - it reads as reassurance. The
// printer supplies card returns this when Zabbix is unconfigured or
// unreachable, which is how a dead monitor used to render as "Low Supplies 0".
export function cardUnavailable(card) {
const data = card._data
if (!data || Array.isArray(data)) return ''
return typeof data.unavailable === 'string' ? data.unavailable : ''
}
export function unavailableCards(cards) {
return cards.filter((card) => cardUnavailable(card))
}
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
// A card that cannot answer says so, whatever its empty rule: 'hide' means
// "nothing to report", and this is not nothing to report.
if (cardUnavailable(card)) return true
return card.empty === 'line'
}
@@ -202,3 +240,138 @@ export const RENDERERS = ['exceptions', 'metric', 'list']
export function renderableCards(cards) {
return cards.filter((card) => RENDERERS.includes(card.render))
}
// ---------------------------------------------------------------------------
// THE MERGED QUEUE
//
// The board used to be one card per source in a fixed grid, and both halves of
// that hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card sat beside it leaving a hole;
// and giving each source its own box flattened priority, because an enforcement
// failure and a warranty notice look equally urgent when each owns a card.
//
// Merging them into one list sorted worst-first makes reading order the work
// order. The severity that drives it is already declared per card - this only
// stops the layout from throwing it away.
// ---------------------------------------------------------------------------
// Severities that are somebody's job right now, in the order they are worked.
export const QUEUE_SEVERITIES = ['critical', 'warning']
// Rows one source may contribute to the queue. Without a per-source cap, fifty
// expiring warranties bury two crashed PCs; the cap is what keeps a noisy
// source from crowding out a rare one.
export const QUEUEPERSOURCE = 5
// Total rows on the board. Past this it is a report, not something to scan.
export const QUEUEMAX = 12
function severityRank(severity) {
const rank = SEVERITY_ORDER[severity]
return rank === undefined ? 3 : rank
}
/**
* One flat, worst-first list of rows from every queue-severity card.
*
* Each row keeps the fields its own card mapped, plus where it came from, so a
* merged row can still say which check produced it.
*/
/**
* The checks currently contributing to the queue, for the filter chips:
* [{key, label, count}] ordered worst-first then biggest-first.
*
* Severity answers "how bad"; this answers "what kind". On a fleet where five
* endpoints go quiet at once, being able to set those aside and look at the
* crashes is the difference between a board and a list.
*/
export function sourceFacets(cards) {
const seen = new Map()
for (const card of sortCards(cards.filter(
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
const count = rows(card).length
if (!count) continue
seen.set(card.id, {
key: card.id,
label: card.shortname || card.title || card.id,
count,
severity: card.severity || 'info',
})
}
return [...seen.values()]
}
export function queueRows(cards) {
const out = []
for (const card of sortCards(cards.filter(
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
const taken = cardRows(card).slice(0, QUEUEPERSOURCE)
taken.forEach((row, index) => {
out.push({
...row,
key: card.id + ':' + index,
severity: card.severity || 'info',
// The badge in a merged queue. Cards declare `shortname` for this:
// their `title` is a card HEADER ("Endpoints with crashes or
// shutdowns") and ran wider than the hostname it qualified, putting the
// badge rather than the asset at the start of every line. Falls back to
// the title so a plugin that has not declared one still reads.
source: card.shortname || card.title || '',
sourceid: card.id,
viewall: card.viewall || '',
})
})
}
return out.slice(0, QUEUEMAX)
}
/**
* What the queue could not show, per source: [{id, title, hidden, viewall}].
*
* Kept per source rather than as one number because the number alone is not
* actionable - "and 45 more" with no way to reach them is worse than silence,
* and each source has its own list to link to.
*/
export function queueOverflow(cards) {
const shown = {}
for (const row of queueRows(cards)) {
shown[row.sourceid] = (shown[row.sourceid] || 0) + 1
}
return cards
.filter((card) => QUEUE_SEVERITIES.includes(card.severity))
.map((card) => ({
id: card.id,
title: card.title || '',
hidden: Math.max(0, rows(card).length - (shown[card.id] || 0)),
viewall: card.viewall || '',
}))
.filter((entry) => entry.hidden > 0)
.sort((a, b) => b.hidden - a.hidden)
}
/**
* Cards that are context rather than work - the info severity. They keep their
* own list rendering in a rail beside the queue, because 50 expiring warranties
* are worth knowing about and are not worth pushing a crashed PC down the page.
*/
export function contextCards(cards) {
return sortCards(cards.filter(
(card) => !QUEUE_SEVERITIES.includes(card.severity)))
}
/**
* Row counts by severity, for the rollup above the queue: {critical, warning,
* info}. Counts ROWS, not cards - three cards holding one row each is three
* things to do, not three.
*/
export function severityTotals(cards) {
const totals = { critical: 0, warning: 0, info: 0 }
for (const card of cards) {
const severity = card.severity || 'info'
if (totals[severity] === undefined) continue
totals[severity] += card.render === 'metric'
? metricValue(card)
: rows(card).length
}
return totals
}

View File

@@ -3,6 +3,9 @@ import {
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
cardVisible, sortCards, permittedCards, renderableCards,
visibleRows, overflowCount,
queueRows, queueOverflow, contextCards, severityTotals,
QUEUEPERSOURCE, QUEUEMAX, singularise,
cardUnavailable, unavailableCards, sourceFacets,
} from './dashboardCards'
const failuresCard = {
@@ -226,3 +229,242 @@ describe('stacked rows', () => {
.toBe('Start of a long message that continues well past the card')
})
})
// ---------------------------------------------------------------------------
// The merged queue. These exist because the old grid failed in ways that were
// invisible: a card truncated at 5 of 50 and nothing said where the rest went,
// and severity was declared but the layout ignored it.
// ---------------------------------------------------------------------------
const critA = {
id: 'endpoints-stability', title: 'Endpoints with crashes', render: 'exceptions',
severity: 'critical', position: 20, viewall: '/pcs',
map: { title: 'hostname' },
_data: [{ hostname: 'PC-CMM-TEST' }, { hostname: 'HOST-7' }],
}
const critB = {
id: 'geenforce-failures', title: 'Enforcement failures', render: 'exceptions',
severity: 'critical', position: 10, viewall: '/geenforce',
map: { title: 'hostname' },
_data: [{ hostname: 'WJPC0615' }],
}
const warnA = {
id: 'computers-quiet', title: 'PCs gone silent', render: 'exceptions',
severity: 'warning', position: 30,
map: { title: 'hostname' },
_data: [{ hostname: 'FFBWTH63' }],
}
const infoA = {
id: 'warranty-expiring', title: 'Warranties expiring', render: 'exceptions',
severity: 'info', position: 40, viewall: '/reports/warranty',
map: { title: 'assetnumber' },
_data: Array.from({ length: 50 }, (_, i) => ({ assetnumber: 'A' + i })),
}
describe('queueRows', () => {
it('merges every queue card into one list, worst first', () => {
const queue = queueRows([warnA, critA, critB])
expect(queue.map((r) => r.severity)).toEqual(
['critical', 'critical', 'critical', 'warning'])
})
it('orders by declared position within a severity', () => {
// geenforce is position 10, endpoints 20: a failure outranks a crash.
const queue = queueRows([critA, critB])
expect(queue[0].title).toBe('WJPC0615')
})
it('keeps the source on every row, so a merged row can still say why', () => {
const queue = queueRows([critB])
expect(queue[0].source).toBe('Enforcement failures')
expect(queue[0].sourceid).toBe('geenforce-failures')
expect(queue[0].viewall).toBe('/geenforce')
})
it('leaves info cards out - they are context, not work', () => {
const queue = queueRows([critB, infoA])
expect(queue).toHaveLength(1)
expect(queue.every((r) => r.severity !== 'info')).toBe(true)
})
it('caps one source so a noisy check cannot bury a rare one', () => {
const noisy = { ...critA, _data: Array.from({ length: 40 }, (_, i) => ({ hostname: 'H' + i })) }
const queue = queueRows([noisy, warnA])
expect(queue.filter((r) => r.sourceid === 'endpoints-stability')).toHaveLength(QUEUEPERSOURCE)
// the warning still made it onto the board
expect(queue.some((r) => r.sourceid === 'computers-quiet')).toBe(true)
})
it('caps the whole board', () => {
const many = Array.from({ length: 6 }, (_, i) => ({
...critA, id: 'c' + i, position: i,
_data: Array.from({ length: 5 }, (_, j) => ({ hostname: 'H' + i + j })),
}))
expect(queueRows(many)).toHaveLength(QUEUEMAX)
})
it('gives every row a stable key', () => {
const keys = queueRows([critA, critB]).map((r) => r.key)
expect(new Set(keys).size).toBe(keys.length)
})
})
describe('queueOverflow', () => {
it('reports what was hidden, per source, with somewhere to go', () => {
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
const [entry] = queueOverflow([noisy])
expect(entry.hidden).toBe(7) // 12 rows, 5 shown
expect(entry.viewall).toBe('/pcs')
expect(entry.title).toBe('Endpoints with crashes')
})
it('says nothing when everything is on the board', () => {
expect(queueOverflow([critB, warnA])).toEqual([])
})
it('ignores info cards, which are not in the queue to overflow from', () => {
expect(queueOverflow([infoA])).toEqual([])
})
it('lists the biggest backlog first', () => {
const a = { ...critA, id: 'a', _data: Array.from({ length: 8 }, (_, i) => ({ hostname: 'x' + i })) }
const b = { ...critA, id: 'b', _data: Array.from({ length: 20 }, (_, i) => ({ hostname: 'y' + i })) }
expect(queueOverflow([a, b])[0].id).toBe('b')
})
})
describe('contextCards', () => {
it('is the info cards, sorted', () => {
const context = contextCards([critA, infoA, warnA])
expect(context.map((c) => c.id)).toEqual(['warranty-expiring'])
})
})
describe('severityTotals', () => {
it('counts ROWS, not cards', () => {
// Three cards holding one row each is three things to do, not three cards.
const totals = severityTotals([critA, critB, warnA, infoA])
expect(totals).toEqual({ critical: 3, warning: 1, info: 50 })
})
it('counts a metric card by its value', () => {
const metric = { id: 'm', render: 'metric', severity: 'warning', _data: { value: 7 } }
expect(severityTotals([metric]).warning).toBe(7)
})
it('treats an undeclared severity as info, matching the sort', () => {
const bare = { id: 'b', render: 'exceptions', _data: [{}, {}] }
expect(severityTotals([bare]).info).toBe(2)
})
})
describe('singularise', () => {
it('drops the s at exactly one', () => {
expect(singularise(' days', 1)).toBe(' day')
expect(singularise(' events', 1)).toBe(' event')
})
it('leaves plurals alone', () => {
expect(singularise(' days', 0)).toBe(' days')
expect(singularise(' days', 2)).toBe(' days')
expect(singularise(' days', 19)).toBe(' days')
})
it('singularises the first word and keeps what follows', () => {
// The card that made this matter: ' events (48h)' read "1 events (48h)"
// five rows running once the queue merged.
expect(singularise(' events (48h)', 1)).toBe(' event (48h)')
expect(singularise(' events (48h)', 2)).toBe(' events (48h)')
})
it('handles a trailing word rather than refusing it', () => {
// "1 day ago" is correct; an earlier version left this plural out of
// caution about chopping the wrong token.
expect(singularise(' days ago', 1)).toBe(' day ago')
})
it('leaves a unit that merely ends in s', () => {
expect(singularise(' ms', 1)).toBe(' ms')
expect(singularise('%', 1)).toBe('%')
})
it('handles no suffix', () => {
expect(singularise('', 1)).toBe('')
expect(singularise(undefined, 1)).toBe('')
})
})
describe('cardUnavailable', () => {
// "Nothing is low" and "we cannot tell whether anything is low" are different
// answers. The printer supplies card returned the same empty for both, so a
// dead Zabbix rendered as "Low Supplies 0" - reassurance, not information.
it('reads the reason a card gives for having no rows', () => {
const card = { _data: { rows: [], unavailable: 'Supply monitoring is not reachable' } }
expect(cardUnavailable(card)).toBe('Supply monitoring is not reachable')
})
it('is empty for a card that simply has nothing to report', () => {
expect(cardUnavailable({ _data: { rows: [] } })).toBe('')
expect(cardUnavailable({ _data: [] })).toBe('')
expect(cardUnavailable({ _data: null })).toBe('')
})
it('ignores a non-string, so bad data cannot render as a reason', () => {
expect(cardUnavailable({ _data: { rows: [], unavailable: true } })).toBe('')
})
it('shows an unavailable card even when its empty rule says hide', () => {
const card = {
empty: 'hide', render: 'exceptions',
_data: { rows: [], unavailable: 'Supply monitoring is not reachable' },
}
expect(cardVisible(card)).toBe(true)
})
it('still hides a card that is merely empty', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: { rows: [] } })).toBe(false)
})
it('still hides a card whose fetch failed, which is not the same thing', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: null })).toBe(false)
})
it('picks out the unavailable ones', () => {
const blind = { id: 'a', _data: { rows: [], unavailable: 'not reachable' } }
const fine = { id: 'b', _data: { rows: [{}] } }
expect(unavailableCards([blind, fine]).map((c) => c.id)).toEqual(['a'])
})
})
describe('sourceFacets', () => {
it('is one chip per contributing check, worst first', () => {
const facets = sourceFacets([warnA, critA, critB])
expect(facets.map((f) => f.key)).toEqual(
['geenforce-failures', 'endpoints-stability', 'computers-quiet'])
})
it('labels with the shortname, since the title is a heading', () => {
const card = { ...critA, shortname: 'Crashes' }
expect(sourceFacets([card])[0].label).toBe('Crashes')
})
it('falls back to the title when no shortname is declared', () => {
expect(sourceFacets([critA])[0].label).toBe('Endpoints with crashes')
})
it('counts every row, not just the ones the queue had room for', () => {
// The chip says how many there are; the queue caps what it shows. A chip
// reading 5 above a "3 more" line would be its own small lie.
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
expect(sourceFacets([noisy])[0].count).toBe(12)
})
it('leaves out checks with nothing to show', () => {
const empty = { ...critA, id: 'empty', _data: [] }
expect(sourceFacets([empty])).toEqual([])
})
it('leaves out info cards, which are not in the queue', () => {
expect(sourceFacets([infoA])).toEqual([])
})
})

View File

@@ -45,6 +45,73 @@ export function rows(panel) {
return list
}
// Optional in-card filter chips. A list panel declares facets: {key, order,
// labels} and the renderer offers one chip per distinct value of that key,
// with a count. Filtering is client-side over rows the panel already fetched -
// no extra request, and the counts stay honest because they are computed from
// the same array.
//
// Declared rather than hardcoded because the reason it exists is generic: the
// activity feed mixes info, warning and critical, and somebody triaging wants
// the criticals alone. Any list panel with a category column has that problem.
// Colour names carry no meaning to a reader. A panel keyed on statecolor
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
// a word (info/warning/critical) is labelled by the word itself.
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
// A panel that shows a health dot has already declared what it is dotting on.
// Reuse that instead of asking every panel to declare filters a second time:
// any card with a status rollup gets chips, and the two cannot disagree.
function facetSpec(panel) {
if (panel.facets && panel.facets.key) return panel.facets
const status = panel.status
if (!status || !status.key) return null
const levels = status.levels || {}
const order = Object.keys(levels).sort(
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
)
const labels = {}
Object.keys(levels).forEach((value) => {
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
? humanize(levels[value])
: humanize(value)
})
return { key: status.key, order, labels }
}
export function facets(panel) {
const spec = facetSpec(panel)
if (!spec || !spec.key) return []
const all = rows(panel)
const counts = new Map()
all.forEach((item) => {
const value = item[spec.key]
if (value === null || value === undefined || value === '') return
const key = String(value)
counts.set(key, (counts.get(key) || 0) + 1)
})
// Declared order first, so severity reads critical-warning-info rather than
// alphabetically; anything unexpected in the data still gets a chip.
const declared = (spec.order || []).filter((value) => counts.has(value))
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
return [...declared, ...extra].map((value) => ({
value,
label: (spec.labels && spec.labels[value]) || humanize(value),
count: counts.get(value)
}))
}
// rows() after the viewer's chip selection. An empty selection means "no filter
// applied", NOT "hide everything": a card that vanished when you deselected the
// last chip would read as broken.
export function facetedRows(panel, selected) {
const all = rows(panel)
const spec = facetSpec(panel)
if (!spec || !spec.key || !selected || !selected.length) return all
return all.filter((item) => selected.includes(String(item[spec.key])))
}
export function mapTitle(panel, item) {
const key = panel.map && panel.map.title
return key ? item[key] : ''

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -228,3 +228,102 @@ describe('rowAction (inline MTConnect toggle)', () => {
expect(info.toggleLabel).toBe('Start')
})
})
describe('facet chips on a card that shows a health dot', () => {
// The case that prompted this: the Activity card shows a RED dot because a
// critical event is in the data, while the visible rows are all info because
// the critical is older and sits further down. The dot and the list appear to
// disagree. Chips resolve it: the count sits under the dot, and one click
// brings the critical rows to the surface.
const activity = {
id: 'endpoints-activity',
render: 'list',
status: {
key: 'severity',
levels: { info: 'ok', warning: 'warning', critical: 'critical' },
},
_data: [
{ title: 'Successful logon', severity: 'info' },
{ title: 'Successful logon', severity: 'info' },
{ title: 'Scheduled task created', severity: 'warning' },
{ title: 'Unexpected shutdown', severity: 'critical' },
],
}
it('derives chips from the status declaration, so no panel declares filters twice', () => {
const chips = facets(activity)
expect(chips.map((c) => c.value)).toEqual(['critical', 'warning', 'info'])
})
it('orders worst first rather than alphabetically', () => {
expect(facets(activity)[0].value).toBe('critical')
})
it('counts from the same rows the dot is computed from, so they cannot disagree', () => {
const chips = facets(activity)
expect(chips.find((c) => c.value === 'critical').count).toBe(1)
expect(chips.find((c) => c.value === 'info').count).toBe(2)
expect(rollupStatus(activity).level).toBe('critical')
})
it('labels a colour-keyed panel by its level, not by the colour name', () => {
const services = {
id: 'svc',
render: 'list',
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
_data: [{ statecolor: 'green' }, { statecolor: 'red' }],
}
const labels = facets(services).map((c) => c.label)
expect(labels).toContain('Critical')
expect(labels).not.toContain('Red')
})
it('shows everything when nothing is selected, rather than hiding the card', () => {
expect(facetedRows(activity, []).length).toBe(4)
expect(facetedRows(activity, undefined).length).toBe(4)
})
it('filters to the selected severities, and allows more than one', () => {
expect(facetedRows(activity, ['critical']).map((r) => r.title))
.toEqual(['Unexpected shutdown'])
expect(facetedRows(activity, ['critical', 'warning']).length).toBe(2)
})
it('offers no chips for a panel with no status rollup', () => {
expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([])
})
it('prefers an explicit facets declaration over the status rollup', () => {
// Services carry BOTH: a statecolor status dot and a statelabel facet.
// Chips must follow the declaration, because a service is Running or
// Stopped - "OK" and "Critical" is not how anybody describes one.
const services = {
id: 'endpoints-services',
render: 'list',
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
facets: { key: 'statelabel', order: ['Running', 'Stopped', 'Paused'] },
_data: [
{ statelabel: 'Running', statecolor: 'green' },
{ statelabel: 'Stopped', statecolor: 'red' },
{ statelabel: 'Paused', statecolor: 'red' },
],
}
const chips = facets(services)
expect(chips.map((c) => c.label)).toEqual(['Running', 'Stopped', 'Paused'])
expect(chips.map((c) => c.label)).not.toContain('Critical')
})
it('gives an unlisted state its own chip rather than dropping the row', () => {
// Windows has transitional states, and an agent can report something the
// order list never anticipated. Those rows must stay reachable.
const services = {
id: 'svc2',
render: 'list',
facets: { key: 'statelabel', order: ['Running', 'Stopped'] },
_data: [{ statelabel: 'Running' }, { statelabel: 'Start Pending' }],
}
const chips = facets(services)
expect(chips.map((c) => c.value)).toEqual(['Running', 'Start Pending'])
expect(facetedRows(services, ['Start Pending']).length).toBe(1)
})
})

View File

@@ -0,0 +1,43 @@
/**
* Where an asset's detail page lives.
*
* Extracted from AssetRelationships.vue because a second caller appeared (the
* triage band) and got it wrong: it linked to `/assets/<assetid>`, which is not
* a route. There is no `/assets/:id` page and no catch-all, so every chip was a
* dead link. One copy, so a third caller cannot repeat it.
*
* Two ids are in play and they are not interchangeable. `assetid` is the core
* row; the detail routes key on `pluginid`, the id of the extension-table row
* (machineid, computerid, printerid). Sending assetid to /machines/:id opens
* the wrong machine, or none.
*/
// Asset type NAME to route base. The names are seed data, and seeding is not
// consistent about case: shopdb/core/services/csvimport.py seeds 'Computer'
// and 'Machine' capitalised, while this site's database holds them lowercase.
// The lookup is therefore lowercased on both sides - a case-sensitive match
// silently produced dead links at whichever site seeded the other way.
const ROUTE_BY_TYPE = {
machine: '/machines',
computer: '/pcs',
printer: '/printers',
network_device: '/network',
'network device': '/network',
measuring_tool: '/measuringtools',
'measuring tool': '/measuringtools'
}
export function assetRoute(asset) {
if (!asset) return '#'
const typename = String(asset.assettypename || asset.assettype || '').toLowerCase()
const basePath = ROUTE_BY_TYPE[typename] || '/assets'
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
export default assetRoute

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { assetRoute } from './assetRoute'
describe('assetRoute', () => {
// The bug this file exists to prevent: the triage band linked to
// /assets/<assetid>, and there is no /assets/:id route and no catch-all,
// so every chip was a dead link on five detail pages.
it('routes by asset type, never to /assets', () => {
expect(assetRoute({ assettypename: 'machine', pluginid: 223, assetid: 313 }))
.toBe('/machines/223')
expect(assetRoute({ assettypename: 'computer', pluginid: 279, assetid: 304 }))
.toBe('/pcs/279')
expect(assetRoute({ assettypename: 'printer', pluginid: 1, assetid: 594 }))
.toBe('/printers/1')
})
it('uses pluginid, not assetid - they are different rows', () => {
// /machines/:id keys on machineid. Passing assetid opens the wrong
// machine, or none, and looks like a data problem rather than a link bug.
const route = assetRoute({ assettypename: 'machine', pluginid: 223, assetid: 313 })
expect(route).toBe('/machines/223')
expect(route).not.toContain('313')
})
it('matches the type name whatever case the site seeded it in', () => {
// csvimport.py seeds 'Computer' and 'Machine' capitalised; this site's
// database holds them lowercase. A case-sensitive lookup produced dead
// links at whichever site seeded the other way.
expect(assetRoute({ assettypename: 'Computer', pluginid: 7 })).toBe('/pcs/7')
expect(assetRoute({ assettypename: 'MACHINE', pluginid: 8 })).toBe('/machines/8')
})
it('accepts the older assettype key as well as assettypename', () => {
expect(assetRoute({ assettype: 'printer', pluginid: 4 })).toBe('/printers/4')
})
it('falls back through typedata when pluginid is absent', () => {
expect(assetRoute({ assettypename: 'network_device', typedata: { networkdeviceid: 12 } }))
.toBe('/network/12')
expect(assetRoute({ assettypename: 'machine', typedata: { machineid: 30 } }))
.toBe('/machines/30')
})
it('returns a harmless anchor for nothing', () => {
expect(assetRoute(null)).toBe('#')
expect(assetRoute(undefined)).toBe('#')
})
it('handles both spellings of the multiword type names', () => {
expect(assetRoute({ assettypename: 'network device', pluginid: 2 })).toBe('/network/2')
expect(assetRoute({ assettypename: 'measuring_tool', pluginid: 3 })).toBe('/measuringtools/3')
expect(assetRoute({ assettypename: 'measuring tool', pluginid: 3 })).toBe('/measuringtools/3')
})
})

View File

@@ -19,10 +19,33 @@ export const PALETTE = [
'#6c757d', // gray
]
// Plugin panels emit colour NAMES, not hex - a services row says 'yellow', an
// activity row says 'gray'. Without this the brightness test cannot parse them
// and falls through to white, which is why an amber badge rendered white text
// on yellow and could not be read. Green, red and grey are dark enough that
// white worked by luck, so the fault only showed when amber was introduced.
const NAMED_COLORS = {
red: '#dc3545',
green: '#28a745',
yellow: '#ffc107',
amber: '#ffc107',
orange: '#fd7e14',
blue: '#4181ff',
grey: '#6c757d',
gray: '#6c757d',
black: '#000000',
white: '#ffffff',
}
export function resolveColor(color) {
if (!color || typeof color !== 'string') return color
return NAMED_COLORS[color.trim().toLowerCase()] || color
}
// Black or white text for a given background, by perceived brightness.
export function readableText(bg) {
if (!bg || typeof bg !== 'string') return '#ffffff'
let hex = bg.trim().replace('#', '')
let hex = resolveColor(bg).trim().replace('#', '')
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
if (hex.length !== 6) return '#ffffff'
const r = parseInt(hex.slice(0, 2), 16)
@@ -34,6 +57,8 @@ export function readableText(bg) {
// Style object for a badge/pill from a stored color (with a neutral fallback).
export function colorStyle(color, fallback = '#6c757d') {
const backgroundColor = color || fallback
// Resolve the name for the BACKGROUND too, so the swatch and the text
// contrast are computed from the same value.
const backgroundColor = resolveColor(color) || fallback
return { backgroundColor, color: readableText(backgroundColor) }
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { readableText, colorStyle } from './colorStyle'
describe('named colours', () => {
// Panels emit names, not hex. Before these resolved, the brightness test
// could not parse them and returned white for everything - which is how an
// amber badge shipped as white-on-yellow.
it('gives yellow dark text, not white', () => {
expect(readableText('yellow')).toBe('#1a1a1a')
expect(colorStyle('yellow').color).toBe('#1a1a1a')
})
it('still gives dark colours white text', () => {
expect(readableText('red')).toBe('#ffffff')
expect(readableText('green')).toBe('#ffffff')
expect(readableText('gray')).toBe('#ffffff')
})
it('resolves the name for the background as well as the text', () => {
expect(colorStyle('yellow').backgroundColor).toBe('#ffc107')
})
it('leaves hex and unknown values alone', () => {
expect(colorStyle('#ffffff').color).toBe('#1a1a1a')
expect(colorStyle('rebeccapurple').backgroundColor).toBe('rebeccapurple')
})
})

View File

@@ -7,147 +7,228 @@
<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 />
<!-- The board: a severity rollup, one worst-first queue of what needs a
person, and a rail of context. 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.
<!-- Inventory context, below the exceptions.
These used to mix two different things without saying so: two tiles
counted asset TYPES and two counted asset STATUSES, and the status
one was labelled "Active", which reads as "not deleted" when it
actually meant status = In Use across every type. Now every tile
counts one thing and its label says which. -->
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Machines</div>
<div class="value">{{ stats.totalmachines || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">PCs</div>
<div class="value">{{ stats.totalpc || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Printers</div>
<div class="value">{{ stats.totalprinter || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Network devices</div>
<div class="value">{{ stats.totalnetwork || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">All assets</div>
<div class="value">{{ stats.totalassets || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">All assets in use</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">All assets in repair</div>
<div class="value">{{ stats.inrepair || 0 }}</div>
</div>
</div>
Inventory goes in the rail through the slot, because a count is not
an exception and DashboardCards has no business declaring one. -->
<DashboardCards>
<template #rail>
<!-- Only when the totals actually loaded. /api/dashboard/summary
needs assets.view, and a caller without it got an empty object
that rendered as a tidy column of zeros - a site with nothing
in it, stated with total confidence. Same fault as reporting Low
Supplies 0 while the monitoring is down. -->
<section v-if="summaryloaded" class="dc-panel assets">
<header class="assets-head">
<h3>Assets</h3>
<router-link to="/reports" class="assets-link">Reports</router-link>
</header>
<!-- Printer Stats -->
<div class="card">
<div class="card-header">
<h3>Printers</h3>
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<!-- One line per asset type, then the total under a rule.
These used to be seven equal tiles in which some of them summed
to another: the type counts ARE all assets, shown beside their
own total at the same weight. A total that looks like another
category invites people to add it in again. Two of the seven
also counted STATUS rather than type without saying so.
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Total Printers</div>
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">Online</div>
<div class="value">{{ printerStats.online || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">Low Supplies</div>
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
</div>
<div class="stat-card danger">
<div class="label">Critical</div>
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
</div>
</div>
</div>
EVERY counted type must appear here. The endpoint's grand total
is machines + PCs + network + printers + measuring tools, and
omitting one leaves a total nothing on screen explains. -->
<div class="assets-row">
<router-link to="/machines" class="assets-k">Machines</router-link>
<span class="assets-v">{{ stats.totalmachines || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/pcs" class="assets-k">PCs</router-link>
<span class="assets-v">{{ stats.totalpc || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/printers" class="assets-k">Printers</router-link>
<span class="assets-v">{{ stats.totalprinter || 0 }}</span>
</div>
<div class="assets-row">
<router-link to="/network" class="assets-k">Network devices</router-link>
<span class="assets-v">{{ stats.totalnetwork || 0 }}</span>
</div>
<!-- Measuring tools are IN the grand total the endpoint returns, so
they have to be visible here. Left out, the total silently
stopped matching the rows above it - 610 of rows over a stated
729 on an instance with gages - which is the exact fault this
panel was built to remove. Dev has none, so it looked correct
here and was wrong everywhere the import had run. -->
<div class="assets-row">
<router-link to="/measuringtools" class="assets-k">Measuring tools</router-link>
<span class="assets-v">{{ stats.totalmeasuringtool || 0 }}</span>
</div>
<!-- Recent Devices -->
<div class="card">
<div class="card-header">
<h3>Recent Devices</h3>
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<div class="assets-row assets-total">
<span class="assets-k">All assets</span>
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Type</th>
<th>Status</th>
<th>Business Unit</th>
</tr>
</thead>
<tbody>
<tr v-for="machine in recentMachines" :key="machine.assetid">
<td>{{ machine.assetnumber || machine.name || '-' }}</td>
<td>{{ machine.assettypename || '-' }}</td>
<td>{{ machine.statusname || '-' }}</td>
<td>{{ machine.businessunitname || '-' }}</td>
</tr>
<tr v-if="recentMachines.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No devices found
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="assets-status">
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'assets-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
</span>
</p>
</section>
<!-- Never leave the page blank, and do not apologise either: show
what this reader CAN open. A guest who can see none of the cards
and cannot read the totals previously got a heading and nothing
else, which reads as a broken site rather than a closed door.
The list is the same permission-filtered navigation the sidebar
uses - server-filtered for a signed-out visitor - so it can never
offer a link that dead-ends at the router guard, and it names no
plugin this page had to know about. -->
<section v-else-if="openable.length" class="dc-panel assets">
<header class="assets-head">
<h3>Where you can go</h3>
</header>
<div v-for="item in openable" :key="item.route" class="assets-row">
<router-link :to="item.route" class="assets-k">{{ item.name }}</router-link>
</div>
</section>
</template>
</DashboardCards>
</template>
</div>
</template>
<script setup>
// WHAT THIS PAGE DROPPED, and why, so it does not come back by accident:
//
// The printer strip (Total / Online / Low Supplies / Critical). It repeated the
// printer count a third time - the tile row and "Total Printers" were already
// the same number - and "Online" equalled total, so three tiles carried one
// fact. Supplies are already a dashboard card the printers plugin declares
// (printers-supplies, severity warning), so a genuinely low cartridge reaches
// the queue where it can be acted on, instead of sitting in a tile reading 0.
//
// Recent Devices. It was the largest block on the page and listed five machines
// that were all "In Use" with business unit "TBD". Recency is not a reason to
// look at something, and it cost more vertical space than everything that
// needed a person put together. A filtered "recently changed" view belongs in
// Reports.
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue'
import { dashboardApi, assetsApi, printersApi } from '../api'
import { ref, computed, onMounted } from 'vue'
import { dashboardApi } from '../api'
const loading = ref(true)
const stats = ref({})
const printerStats = ref({})
const recentMachines = ref([])
// Whether the totals actually came back. Swallowing the error and rendering
// stats as {} turned a 403 into a column of confident zeros.
const summaryloaded = ref(false)
// Where this reader can actually go, for the case where the board has nothing
// for them. Same source as the sidebar, so it is already filtered to what they
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
onMounted(async () => {
try {
const [dashRes, machinesRes, printersRes] = await Promise.all([
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
])
stats.value = dashRes.data.data || {}
recentMachines.value = machinesRes.data.data || []
printerStats.value = printersRes.data.data || {}
const response = await dashboardApi.summary()
stats.value = response.data.data || {}
summaryloaded.value = Object.keys(stats.value).length > 0
} catch (error) {
console.error('Dashboard load error:', error)
} finally {
loading.value = false
// A caller without assets.view is restricted, not broken, so this is not
// logged as an error - the panel offers them somewhere to go instead.
summaryloaded.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
if (!summaryloaded.value) {
// Only fetched when it is needed, so the common case costs nothing.
try {
const response = await dashboardApi.navigation()
navitems.value = response.data.data || []
} catch (error) {
navitems.value = []
}
}
loading.value = false
})
</script>
<style scoped>
/* Matches the panels in DashboardCards; the rail reads as one column rather
than as a page card that happens to sit beside them. */
.assets {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
min-width: 0;
}
.assets-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.assets-head h3 {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1 1 auto;
}
.assets-link {
font-size: 0.78rem;
color: var(--link);
text-decoration: none;
}
.assets-link:hover { text-decoration: underline; }
.assets-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
}
.assets-row:last-of-type { border-bottom: none; }
.assets-k {
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
a.assets-k:hover { text-decoration: underline; }
span.assets-k { color: var(--text); font-weight: 600; }
.assets-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.assets-total {
margin-top: 0.35rem;
border-top: 2px solid var(--border);
border-bottom: none;
}
.assets-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--text-light);
}
.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.assets-zero { color: var(--text-light); }
</style>

View File

@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
{
'id': 'backups-stale',
'title': 'Backups stopped',
'shortname': 'Backups',
'endpoint': '/api/backups/dashboard/stale',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -1934,6 +1934,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-quiet',
'viewall': '/pcs',
'title': 'PCs gone silent',
'shortname': 'PC silent',
'endpoint': '/api/computers/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -1952,6 +1953,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-sharedmachines',
'viewall': '/machines',
'title': 'Machine numbers on two PCs',
'shortname': 'Duplicate machine no.',
'endpoint': '/api/computers/dashboard/sharedmachines',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -47,6 +47,17 @@ def _service_running(entry):
return raw in _RUNNING_STATES
# Paused, plus the four transitional states Windows reports while a service is
# on its way somewhere.
_TRANSITIONAL_STATES = ('paused', 'start pending', 'stop pending',
'continue pending', 'pause pending', 'starting',
'stopping', 'pausing')
def _service_transitional(raw):
return (raw or '').strip().lower() in _TRANSITIONAL_STATES
def _service_name(entry):
return entry.get('name') or entry.get('service') or 'Unknown'
@@ -178,7 +189,7 @@ def list_endpoints():
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
row['status'] = ('never', 'quiet', 'reporting')[rank]
row['machines'] = machines.get(state.assetid, [])
row['health'] = _health_for(state, critical.get(state.assetid, 0))
row['health'] = _health_for(state, critical.get(state.assetid))
data.append(row)
return paginated_response(data, page, per_page, total)
@@ -261,30 +272,48 @@ def _machines_for_assets(assetids):
def _critical_counts_for_assets(assetids):
"""{assetid: count} of critical events in the stability window."""
"""{assetid: (total, dominant_eventid)} of critical events in the window.
Broken down BY EVENT ID, not just totalled. A bare count says "418
critical", which tells an operator nothing about what to do; naming the
event that dominates - service timeout, application crash - is the
difference between a number and a lead.
"""
if not assetids:
return {}
cutoff = _naive_utcnow() - timedelta(hours=48)
rows = (db.session.query(EndpointEvent.assetid,
rows = (db.session.query(EndpointEvent.assetid, EndpointEvent.eventid,
db.func.count(EndpointEvent.endpointeventid))
.filter(EndpointEvent.assetid.in_(assetids),
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
EndpointEvent.eventtime.isnot(None),
EndpointEvent.eventtime >= cutoff)
.group_by(EndpointEvent.assetid)
.group_by(EndpointEvent.assetid, EndpointEvent.eventid)
.all())
return dict(rows)
perasset = {}
for assetid, eventid, count in rows:
total, dominant, best = perasset.get(assetid, (0, None, 0))
total += count
if count > best:
dominant, best = eventid, count
perasset[assetid] = (total, dominant, best)
return {assetid: (total, dominant)
for assetid, (total, dominant, _best) in perasset.items()}
def _health_for(state, criticalcount):
def _health_for(state, critical):
"""Worst-of rollup: a stopped watched service, or a recent critical event.
Deliberately a PHRASE, not a score. "eDNC stopped" tells an operator what
to do; a percentage does not.
to do; a percentage does not - and neither does "418 critical", which is
why the dominant event is named rather than merely counted.
"""
if criticalcount:
return {'level': 'critical',
'label': f'{criticalcount} critical' if criticalcount > 1 else '1 critical'}
if critical:
total, dominant = critical
title = event_title(dominant) if dominant else 'critical'
label = title if total == 1 else '{} x{}'.format(title, total)
return {'level': 'critical', 'label': label}
stopped = []
try:
@@ -450,7 +479,14 @@ def panel_services(asset_id: int):
'name': name,
'kind': (kind or '').lower() or None,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
# Amber for paused and the four Windows transitional states. A
# service stuck in Start Pending is failing to start on a retry
# cycle - which is what raises event 7011 - and a paused one was
# paused deliberately. Painting either the same red as Stopped
# loses the distinction that tells you which to chase.
'statecolor': ('green' if running
else 'yellow' if _service_transitional(raw)
else 'red' if raw else 'gray'),
}
# Only offer Start/Stop to a caller who could actually run it. The
# emission never consulted the caller, so a read-only viewer was shown
@@ -520,6 +556,20 @@ def panel_activity(asset_id: int):
.limit(15)
.all())
# The newest 15 of ANY severity can be entirely routine logons while the
# health rollup is shouting about a burst of crashes underneath them - the
# page then contradicts itself for no visible reason. Pull the newest
# criticals separately and merge, so what health is reporting is always
# present in the feed that is supposed to explain it.
criticals = (EndpointEvent.query
.filter(EndpointEvent.assetid == asset_id,
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS))
.order_by(EndpointEvent.eventtime.desc())
.limit(5)
.all())
seen = {e.endpointeventid for e in events}
events.extend(e for e in criticals if e.endpointeventid not in seen)
commands = (EndpointCommand.query
.filter_by(assetid=asset_id)
.order_by(EndpointCommand.createdat.desc())
@@ -563,9 +613,24 @@ def panel_activity(asset_id: int):
# Undated rows sort last rather than crashing the comparison.
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
# Trim to the cap without dropping the criticals that were fetched on
# purpose: they keep their place in time, but they survive the cut.
if len(rows) > 15:
keep = [r for r in rows if r['severity'] == 'critical'][:5]
rest = [r for r in rows if r not in keep]
rows = sorted(keep + rest[:15 - len(keep)],
key=lambda r: (r['eventtime'] is not None, r['eventtime']),
reverse=True)
# The list renderer reads a badge colour from a ROW FIELD, so without this
# every badge falls back to grey and the feed loses the one signal it is
# there to carry.
colours = {'critical': 'red', 'warning': 'yellow', 'info': 'gray'}
return success_response([{
'title': r['title'],
'severity': r['severity'],
'severitycolor': colours.get(r['severity'], 'gray'),
'detail': r['detail'],
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
} for r in rows[:15]])

View File

@@ -281,6 +281,18 @@ class EndpointsPlugin(BasePlugin):
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'service'},
# Facet on the STATE, not the status colour. statecolor is only
# ever green, red or grey here, so chips derived from it would
# read "OK" and "Critical" - not how anybody talks about a
# service. statelabel carries the real vocabulary: Running,
# Stopped, Paused, and the Windows transitional states. Any
# value not listed still gets a chip, appended after these.
'facets': {
'key': 'statelabel',
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
'Stop Pending', 'Continue Pending', 'Pause Pending',
'Unknown'],
},
'status': {
'key': 'statecolor',
'levels': {
@@ -307,6 +319,18 @@ class EndpointsPlugin(BasePlugin):
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'process'},
# Facet on the STATE, not the status colour. statecolor is only
# ever green, red or grey here, so chips derived from it would
# read "OK" and "Critical" - not how anybody talks about a
# service. statelabel carries the real vocabulary: Running,
# Stopped, Paused, and the Windows transitional states. Any
# value not listed still gets a chip, appended after these.
'facets': {
'key': 'statelabel',
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
'Stop Pending', 'Continue Pending', 'Pause Pending',
'Unknown'],
},
'status': {
'key': 'statecolor',
'levels': {
@@ -350,6 +374,10 @@ class EndpointsPlugin(BasePlugin):
'title': 'title',
'badge': {
'label': 'severity',
# The renderer reads the colour from a row field; with
# no colour declared every badge renders grey, which is
# what the feed spent its first version doing.
'color': 'severitycolor',
'labelmap': {
'critical': 'Critical',
'warning': 'Warning',
@@ -416,6 +444,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-quiet',
'viewall': '/pcs',
'title': 'Endpoints gone quiet',
'shortname': 'Agent quiet',
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -434,6 +463,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-stability',
'viewall': '/pcs',
'title': 'Endpoints with crashes or shutdowns',
'shortname': 'Crashes',
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -86,6 +86,7 @@ class GeEnforcePlugin(BasePlugin):
'id': 'geenforce-failures',
'viewall': '/geenforce',
'title': 'Enforcement failures',
'shortname': 'Enforcement',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -20,16 +20,6 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
<div v-if="machine.dualpathpartner" class="dualpath-banner">
Dual-bay machine - sibling bay:
<router-link
v-if="machine.dualpathpartner.machineid"
:to="`/machines/${machine.dualpathpartner.machineid}`"
>{{ machine.dualpathpartner.assetnumber }}</router-link>
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
</div>
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="machine.machine?.imageurl">
@@ -70,6 +60,18 @@
<span class="hero-detail-value">{{ machine.locationname }}</span>
</div>
</div>
<!-- The 6am band, machine side: which PCs drive this machine. The
same fact as a PC's "Serves machine", read from the other end -
and the end somebody is standing at when the machine has
stopped. It lived only in the relationships card near the
bottom of the page. -->
<AssetTriageBand
:assetid="machine.assetid"
counterparttype="computer"
counterpartlabel="Controlled by"
:extraitems="triageextras"
/>
</div>
</div>
@@ -248,11 +250,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
@@ -269,6 +272,25 @@ const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const machine = ref(null)
// The dual-bay sibling, as a triage chip. It had its own banner above the hero
// until the band existed; two strips of chrome answering two halves of the same
// "what am I looking at" read as clutter, and the sibling belongs beside the
// PC that drives the machine rather than above it.
const triageextras = computed(() => {
const partner = machine.value?.dualpathpartner
if (!partner) return []
return [{
// Says what the pair MEANS, not just that a link exists: a dualpath pair is
// one physical machine in two bays, which is why the old banner spelled it
// out rather than showing a bare number.
label: 'Dual-bay sibling',
links: [{
text: partner.assetnumber,
to: partner.machineid ? `/machines/${partner.machineid}` : null
}]
}]
})
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
// relationships render via the shared AssetRelationships card
@@ -322,21 +344,6 @@ async function confirmRetire() {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.dualpath-banner {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
color: var(--text);
font-size: 0.9rem;
}
.dualpath-banner a {
color: var(--link);
font-weight: 600;
}
.feature-tag {
display: inline-block;

View File

@@ -209,6 +209,7 @@ class MachinesPlugin(BasePlugin):
'id': 'machines-outofservice',
'viewall': '/machines',
'title': 'Machines out of service',
'shortname': 'Out of service',
'endpoint': '/api/machines/dashboard/outofservice',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -46,6 +46,22 @@
<span class="hero-detail-value">{{ tool.locationname }}</span>
</div>
</div>
<!-- The 6am band: which PC drives this instrument.
A metrology tool is reached through the PC that runs it - the
import synthesizes one measuring-tool asset per metrology PC and
a 'controls' edge PC -> tool (stage_metrology), so the
counterpart here is a COMPUTER, exactly as on the machine page.
This was first written as counterparttype="machine" labelled
"Used on", which filtered for a counterpart that cannot exist
and so rendered nothing on every gage that had a PC. A hand-held
gage genuinely has no such edge, and the band correctly shows
nothing for it. -->
<AssetTriageBand
:assetid="tool.assetid"
counterparttype="computer"
counterpartlabel="Controlled by"
/>
</div>
</div>
@@ -169,6 +185,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'

View File

@@ -68,6 +68,14 @@
<span class="hero-detail-value">{{ device.businessunitname }}</span>
</div>
</div>
<!-- The 6am band: what stops if this switch or AP goes down. A network
device is rarely interesting in itself; it matters because of what
sits behind it. -->
<AssetTriageBand
:assetid="device.assetid"
counterparttype="machine"
/>
<div class="hero-features" v-if="device.networkdevice">
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
@@ -227,6 +235,7 @@ import { useAuthStore } from '@/stores/auth'
import { networkApi } from '@/api'
import { withBase } from '@/utils/basePath'
import AssetRelationships from '@/components/AssetRelationships.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'

View File

@@ -206,6 +206,7 @@ class NotificationsPlugin(BasePlugin):
'id': 'notifications-active',
'viewall': '/notifications',
'title': 'Active notifications',
'shortname': 'Notification',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
'severity': 'info',

View File

@@ -2124,8 +2124,21 @@ def _get_low_supplies_data():
return cached
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
# "No printer is low" and "we cannot tell whether any printer is low" are
# different answers, and this used to give the same empty one for both. The
# dashboard then showed Low Supplies 0 while the monitoring was dead, which
# is not a missing feature but a wrong statement: it reads as reassurance.
if not service.isconfigured:
# Enabled plus a URL plus a token. Absent any of those the site has not
# asked for supply monitoring, and a permanent "not configured" line
# would be a card reporting nothing wrong every day - which is how a
# board teaches people to stop reading it. Nothing to say: stay hidden.
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
if not service.isreachable:
# Configured and unanswerable is a different thing: something meant to
# work is not working, and the cartridges are NOT known to be fine.
return {'printers': [], 'unavailable': 'Supply monitoring is not reachable',
'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
# active printers with an IP, with vendor and model for waste/part rules
rows = (
@@ -2655,7 +2668,10 @@ def dashboard_supplies():
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
return success_response(rows)
# Envelope rather than a bare list, so the card can say WHY it has no rows.
# An empty card and a blind card must not look the same.
return success_response({'rows': rows,
'unavailable': data.get('unavailable')})
# =============================================================================

View File

@@ -66,6 +66,16 @@
<span class="hero-detail-value mono">{{ ipAddress }}</span>
</div>
</div>
<!-- The 6am band: which machine stops if this printer is down.
A shop-floor printer is often a label or tag printer feeding one
machine, and that link was only visible in the relationships
card near the bottom of the page. -->
<AssetTriageBand
:assetid="printer.assetid"
counterparttype="machine"
:relationshiptypes="['usesprinter', 'defaultprinter']"
/>
</div>
</div>
@@ -301,6 +311,7 @@ import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'

View File

@@ -354,6 +354,7 @@ class PrintersPlugin(BasePlugin):
'id': 'printers-supplies',
'viewall': '/reports/toner',
'title': 'Printer supplies',
'shortname': 'Supplies',
'endpoint': '/api/printers/dashboard/supplies',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -87,7 +87,10 @@ def _checkout_events(checkout):
'device_id': checkout.device.serialnumber if checkout.device else None,
'action': 'check-in',
'timestamp': checkout.checkintime.isoformat() + 'Z',
'scanned_viruses': None,
# Both read from the checkout row now. scanned_viruses was
# hardcoded None: the shape promised the field and had nowhere to
# read it from, so an imported history would have shown blank.
'scanned_viruses': checkout.scannedviruses,
'locker_location': None,
'sanitized': checkout.waswiped,
})
@@ -233,6 +236,10 @@ def checkin_device(device_id, data):
checkintime = override
open_checkout.checkintime = checkintime
open_checkout.waswiped = bool(data.get('sanitized'))
# None, not False, when the caller said nothing: an unrecorded scan is
# not a failed scan, and an audit reads those very differently.
scanned = data.get('scanned_viruses')
open_checkout.scannedviruses = None if scanned is None else bool(scanned)
open_checkout.checkinnotes = data.get('notes')
device.ischeckedout = False
device.currentuserid = None

235
plugins/usb/cli.py Normal file
View File

@@ -0,0 +1,235 @@
"""CLI: move USB records off an external cmmc_usb database and into ShopDB.
flask usb import-external report what would happen, change nothing
flask usb import-external --commit write it
A site running the external mode keeps its devices and its check-in/out history
in a separate cmmc_usb database. This reads that database through the same
connection settings the routes already use, converts it to the self-hosted
tables, and leaves the source untouched. After a clean run, set
usb_directory_mode to selfhosted and the external database can be retired.
TWO THINGS THIS HAS TO RECONCILE, and they are the reason it is a command
rather than a few INSERTs:
The two schemas disagree about shape. External is an EVENT LOG - one row per
action, check-out or check-in - while self-hosted keeps a PAIRED ROW, one
checkout carrying both its times. Pairing is per device and in time order, and
real logs are not tidy: a check-out with no return is an open checkout, but a
return with no matching check-out, or two check-outs in a row, mean something
was recorded wrong years ago. Those are reported rather than guessed at, and
they never silently vanish.
Identity is the other one. usbdevices.serialnumber is unique and NOT NULL, and
the external devices table has no serial column - only device_id. So device_id
becomes the serial. A blank or duplicated device_id stops the import for that
device instead of inventing a key.
Dry run by default. The point of the first run is the report.
"""
from collections import defaultdict
import click
from flask.cli import AppGroup, with_appcontext
from shopdb.api import db, cmmc_usb_connection
from .models import USBDevice, USBCheckout
usb_cli = AppGroup('usb', help='USB device records.')
# The external status vocabulary, mapped to the self-hosted boolean. Anything
# unrecognised is treated as checked in and reported, rather than assumed.
_CHECKED_OUT = {'checked-out', 'checked out', 'out', 'issued'}
_RETIRED = {'retired', 'disposed', 'destroyed'}
def _external_rows():
"""(devices, log) from the external database, oldest log entry first."""
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT device_id, device_desc, device_owner, status, '
'locker_location FROM devices')
devices = cur.fetchall()
cur.execute('SELECT log_id, badge_number, device_id, action, timestamp, '
'scanned_viruses, locker_location, sanitized '
'FROM checkinoutlog ORDER BY device_id, timestamp, log_id')
log = cur.fetchall()
finally:
conn.close()
return devices, log
def _is_checkout(action):
return str(action or '').strip().lower().replace('_', '-') in (
'check-out', 'checkout', 'out')
def _is_checkin(action):
return str(action or '').strip().lower().replace('_', '-') in (
'check-in', 'checkin', 'in', 'return')
def _pair(entries, problems, deviceid):
"""Walk one device's log in time order and fold it into checkout pairs."""
pairs, open_row = [], None
for row in entries:
action = row.get('action')
if _is_checkout(action):
if open_row is not None:
# Two check-outs with no return between them. The first is
# unclosed and stays that way; say so rather than inventing a
# return time that nobody recorded.
problems.append(
'device %s: check-out at %s while %s was still out - first '
'left open' % (deviceid, row.get('timestamp'),
open_row.get('badge_number')))
pairs.append((open_row, None))
open_row = row
elif _is_checkin(action):
if open_row is None:
problems.append(
'device %s: check-in at %s with no matching check-out - '
'skipped' % (deviceid, row.get('timestamp')))
continue
pairs.append((open_row, row))
open_row = None
else:
problems.append('device %s: unrecognised action %r at %s - skipped'
% (deviceid, action, row.get('timestamp')))
if open_row is not None:
pairs.append((open_row, None))
return pairs
@usb_cli.command('import-external')
@click.option('--commit', is_flag=True, default=False,
help='Write the records. Without this the command only reports.')
@with_appcontext
def import_external(commit):
"""Copy devices and check-in/out history from the external cmmc_usb DB."""
try:
devices, log = _external_rows()
except Exception as error:
raise click.ClickException(
'Could not read the external database: %s\n'
'Check the cmmc_usb_db_* settings, and that this host can reach it.'
% error)
problems = []
seen_serials = set()
made_devices = 0
made_checkouts = 0
open_checkouts = 0
by_device = defaultdict(list)
for row in log:
by_device[row.get('device_id')].append(row)
for source in devices:
serial = (source.get('device_id') or '').strip()
if not serial:
problems.append('device with no device_id - skipped, it cannot be '
'given a serial number')
continue
if serial in seen_serials:
problems.append('device_id %r appears twice - only the first is '
'imported' % serial)
continue
seen_serials.add(serial)
status = str(source.get('status') or '').strip().lower()
if status and status not in _CHECKED_OUT and status not in _RETIRED \
and status not in ('checked-in', 'checked in', 'in', 'available'):
problems.append('device %s: unrecognised status %r - treated as '
'checked in' % (serial, source.get('status')))
device = USBDevice.query.filter_by(serialnumber=serial).first()
if device is None:
device = USBDevice(serialnumber=serial)
db.session.add(device)
made_devices += 1
device.label = source.get('device_desc') or device.label
device.storagelocation = source.get('locker_location') or device.storagelocation
device.notes = _append_owner(device.notes, source.get('device_owner'))
device.ischeckedout = status in _CHECKED_OUT
db.session.flush()
for out_row, in_row in _pair(by_device.get(source.get('device_id'), []),
problems, serial):
checkout = USBCheckout(
usbdeviceid=device.usbdeviceid,
sso=str(out_row.get('badge_number') or '')[:20],
checkouttime=out_row.get('timestamp'),
checkintime=in_row.get('timestamp') if in_row else None,
)
if in_row:
checkout.waswiped = _tri(in_row.get('sanitized'))
checkout.scannedviruses = _tri(in_row.get('scanned_viruses'))
else:
open_checkouts += 1
db.session.add(checkout)
made_checkouts += 1
# The device's own flag and the history have to agree, or the kiosk
# will offer a drive that the log says somebody still holds.
if device.ischeckedout and not _has_open(device):
problems.append('device %s: marked checked out, but its log has no '
'open check-out' % serial)
click.echo('devices read from external : %d' % len(devices))
click.echo('log rows read : %d' % len(log))
click.echo('devices to create : %d' % made_devices)
click.echo('checkouts to create : %d' % made_checkouts)
click.echo(' of which still open : %d' % open_checkouts)
if problems:
click.echo('')
click.echo('%d row(s) need a human decision:' % len(problems))
for line in problems:
click.echo(' - %s' % line)
if not commit:
db.session.rollback()
click.echo('')
click.echo('DRY RUN - nothing was written. Re-run with --commit when the '
'report above looks right.')
return
db.session.commit()
click.echo('')
click.echo('Written. Next: set usb_directory_mode to selfhosted in Settings, '
'confirm the kiosk and the device list against the external '
'database, and only then retire it.')
def _tri(value):
"""External flags are 0/1/NULL. Keep NULL as None: unrecorded is not false."""
if value is None:
return None
return bool(value)
def _has_open(device):
return USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid,
checkintime=None).count() > 0
def _append_owner(notes, owner):
"""External device_owner has no column here, so keep it rather than lose it."""
owner = (owner or '').strip()
if not owner:
return notes
line = 'Imported owner: %s' % owner
if notes and line in notes:
return notes
return (notes + '\n' + line) if notes else line
def build_cli():
return [usb_cli]

View File

@@ -0,0 +1,49 @@
"""Record whether a returned drive was virus-scanned.
The external cmmc_usb checkinoutlog carries scanned_viruses and sanitized on
every row. The self-hosted usbcheckouts table already has waswiped, which is
the same fact as sanitized under a different name, but nothing corresponds to
scanned_viruses. That gap was harmless while the two backends were
alternatives, and becomes data loss the moment a site imports its history and
cuts over: whether a returned drive was scanned is precisely what a
removable-media audit asks.
Nullable on purpose, and NOT defaulted to false. A checkout recorded before
this column existed is not a checkout where the drive went unscanned; it is one
where nobody wrote it down. False would assert something the data does not say.
Idempotent, so a re-run on an already-migrated database is a no-op.
Revision ID: usb0003scanfields
Revises: usb0002dropmachineid
"""
from alembic import op
import sqlalchemy as sa
revision = 'usb0003scanfields'
down_revision = 'usb0002dropmachineid'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'usbcheckouts' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
if 'scannedviruses' not in cols:
op.add_column('usbcheckouts',
sa.Column('scannedviruses', sa.Boolean(), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'usbcheckouts' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
if 'scannedviruses' in cols:
op.drop_column('usbcheckouts', 'scannedviruses')

View File

@@ -133,6 +133,10 @@ class USBCheckout(BaseModel):
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkinnotes = db.Column(db.Text, nullable=True)
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
# Nullable, and never defaulted to False: a checkout with no recorded scan
# is one where nobody wrote it down, not one where the drive went unscanned.
scannedviruses = db.Column(db.Boolean, nullable=True,
comment='Was the device virus-scanned on return')
# Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))

View File

@@ -70,6 +70,11 @@ class USBPlugin(BasePlugin):
'docs': 'plugins/usb/README.md',
}
def get_cli_commands(self) -> List:
"""CLI: import records off an external cmmc_usb database."""
from .cli import build_cli
return build_cli()
def get_reports(self) -> List[Dict]:
"""Removable-media audit.

View File

@@ -128,6 +128,7 @@ class WarrantyPlugin(BasePlugin):
'id': 'warranty-expiring',
'viewall': '/reports/warranty',
'title': 'Warranties expiring',
'shortname': 'Warranty',
'endpoint': '/api/warranty/dashboard/expiring',
'render': 'exceptions',
'severity': 'info',

View File

@@ -1,3 +1,28 @@
# DIRECT requirements only. requirements.txt is COMPILED from this file:
#
# uv pip compile requirements.in --universal --generate-hashes -o requirements.txt
#
# Upgrade one package without moving everything else:
#
# uv pip compile requirements.in --universal --generate-hashes \
# --upgrade-package <name> -o requirements.txt
#
# This explanation lives HERE, not in requirements.txt, because a compile
# rewrites that file from scratch. The same guidance was written into its
# header once and was silently wiped by the next `uv pip compile` - including
# the line warning that hand-edits vanish. It is restored there for readers,
# but this copy is the one that survives.
#
# Only DIRECT requirements belong in this file. A runtime import satisfied in
# dev only because some test dependency happens to pull it in will pass every
# test and then fail on a production venv: `packaging` reached this project
# through pytest, and the Windows install could not import shopdb at all until
# it was declared. tests/test_runtime_dependencies.py guards that.
#
# Check dependencies against published advisories before a release:
#
# venv/bin/python scripts/scan_sbom.py --gate
# Flask and extensions
flask>=3.0
flask-sqlalchemy>=3.1

View File

@@ -434,9 +434,9 @@ greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
# via sqlalchemy
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# email-validator
# requests
@@ -593,9 +593,9 @@ pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementatio
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pyjwt==2.12.1 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
pyjwt==2.13.0 \
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
# via flask-jwt-extended
pymysql==1.1.3 \
--hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \

379
scripts/scan_sbom.py Normal file
View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Check the SBOM against OSV, and say what actually needs doing.
python3 scripts/scan_sbom.py scan, report, exit 0
python3 scripts/scan_sbom.py --gate exit 1 on a shipped finding
python3 scripts/scan_sbom.py --json out.json machine-readable too
python3 scripts/scan_sbom.py --sbom path.json scan an existing SBOM
generate_sbom.py already answers "what are we running": 345 components, each
with a purl and a hash. Nothing consumed that file, so "are we exposed" was
still a person going and looking. This closes that.
WHY OSV, AND NOT A SCANNER
OSV.dev takes a purl and answers with the advisories that apply. No account, no
API key, no vendor agent, no database to keep current on an air-gapped box. The
SBOM is already the input it wants, so this is a lookup rather than another
scan of the tree - which also means it cannot disagree with what shipped.
SHIPPED VERSUS DEV-ONLY IS THE WHOLE POINT
Two thirds of the components here are build tooling that never reaches a
server, and the SBOM already marks them scope 'excluded'. A scanner that fails
a release over a vite transitive dependency teaches everybody to ignore it
within a month, so dev-only findings are REPORTED and never gate. Only what
ships can stop a build.
SEVERITY IS TAKEN, NOT COMPUTED
An OSV entry carries whatever the upstream database recorded. Where that is a
plain word (GHSA gives LOW/MODERATE/HIGH/CRITICAL) it is used as-is. Where
there is only a CVSS vector, this reports UNKNOWN rather than parsing the
vector into a score it might get wrong. UNKNOWN is shown and never gates: a
guessed score is worse than an honest gap, because somebody would act on it.
TRIAGE IS A FILE, NOT A MEMORY
scripts/vulnerability-allowlist.json records advisories reviewed and accepted,
each with a reason and a date. That is what turns a recurring report into a
record: "we looked at this on the 5th, here is why it does not apply" outlives
the person who worked it out.
"""
import argparse
import json
import pathlib
import sys
import urllib.error
import urllib.request
REPO = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_SBOM = REPO / 'sbom.cdx.json'
ALLOWLIST = REPO / 'scripts' / 'vulnerability-allowlist.json'
OSV_BATCH = 'https://api.osv.dev/v1/querybatch'
OSV_VULN = 'https://api.osv.dev/v1/vulns/'
# Order matters: the gate compares by index, so adding a level means deciding
# where it sits rather than hoping a string comparison does the right thing.
SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MODERATE', 'MEDIUM', 'HIGH', 'CRITICAL']
GATE_AT = 'HIGH'
# OSV batches, but not without limit, and a 345-component POST is rude.
BATCH_SIZE = 100
TIMEOUT = 40
def post(url, payload):
body = json.dumps(payload).encode('utf8')
request = urllib.request.Request(
url, data=body, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
return json.loads(response.read().decode('utf8'))
def get(url):
with urllib.request.urlopen(url, timeout=TIMEOUT) as response:
return json.loads(response.read().decode('utf8'))
def load_components(path):
"""[(purl, name, version, shipped)] from a CycloneDX document."""
document = json.loads(path.read_text(encoding='utf8'))
out = []
for component in document.get('components', []):
purl = component.get('purl')
if not purl:
continue
out.append((
purl,
component.get('name', purl),
component.get('version', ''),
component.get('scope', 'required') != 'excluded',
))
return out
def severity_of(entry):
"""The severity the upstream database recorded, or UNKNOWN.
Deliberately not derived from a CVSS vector. See the module docstring.
"""
specific = entry.get('database_specific') or {}
word = str(specific.get('severity') or '').strip().upper()
if word in SEVERITY_ORDER:
return word
return 'UNKNOWN'
# OSV names ecosystems in its own vocabulary; a purl uses another. Only the two
# this SBOM produces are mapped, because a wrong guess here silently attributes
# some other package's fix version to this component.
PURL_ECOSYSTEM = {'npm': 'npm', 'pypi': 'PyPI'}
def parse_purl(purl):
"""('npm', 'axios') from 'pkg:npm/axios@1.13.2'. Scopes survive."""
body = purl[4:] if purl.startswith('pkg:') else purl
body = body.split('?')[0].split('#')[0]
kind, _, rest = body.partition('/')
name = rest.rsplit('@', 1)[0] if '@' in rest[1:] else rest
from urllib.parse import unquote
return PURL_ECOSYSTEM.get(kind.lower(), kind), unquote(name)
def fixed_versions(entry, purl):
"""Versions upstream says carry the fix, for THIS package only.
Reported as a list rather than a single 'upgrade to X' because an advisory
that was patched on several release branches has several right answers, and
picking one for the reader would be guessing at their constraints.
"""
ecosystem, name = parse_purl(purl)
out = []
for affected in entry.get('affected', []):
package = affected.get('package') or {}
if package.get('ecosystem') != ecosystem:
continue
if str(package.get('name', '')).lower() != name.lower():
continue
for range_ in affected.get('ranges', []):
for event in range_.get('events', []):
version = event.get('fixed')
if version and version not in out:
out.append(version)
return out
def at_or_above(severity, floor):
try:
return SEVERITY_ORDER.index(severity) >= SEVERITY_ORDER.index(floor)
except ValueError:
return False
def load_allowlist():
if not ALLOWLIST.is_file():
return {}
data = json.loads(ALLOWLIST.read_text(encoding='utf8'))
return {entry['id']: entry for entry in data.get('accepted', [])}
def query(components):
"""{purl: [vuln ids]} from OSV, in batches."""
found = {}
for start in range(0, len(components), BATCH_SIZE):
chunk = components[start:start + BATCH_SIZE]
payload = {'queries': [{'package': {'purl': purl}}
for purl, _n, _v, _s in chunk]}
result = post(OSV_BATCH, payload)
for (purl, _n, _v, _s), row in zip(chunk, result.get('results', [])):
ids = [v['id'] for v in (row.get('vulns') or [])]
if ids:
found[purl] = ids
return found
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sbom', type=pathlib.Path, default=None)
parser.add_argument('--json', type=pathlib.Path, default=None,
help='also write the findings as JSON')
parser.add_argument('--gate', action='store_true',
help='exit 1 when a SHIPPED component has a finding at '
'or above %s that is not allowlisted' % GATE_AT)
args = parser.parse_args()
sbom = args.sbom or DEFAULT_SBOM
if not sbom.is_file():
# Generate rather than fail: the input is derived from files in the
# repo, so there is no reason to make somebody run two commands.
sys.stderr.write('No SBOM at %s - generating one.\n' % sbom)
sbom = REPO / 'sbom.cdx.json'
import subprocess
subprocess.run([sys.executable, str(REPO / 'scripts' / 'generate_sbom.py'),
str(REPO), '-o', str(sbom)], check=True)
components = load_components(sbom)
shipped = {purl: ship for purl, _n, _v, ship in components}
names = {purl: '%s %s' % (n, v) for purl, n, v, _s in components}
versions = {purl: v for purl, _n, v, _s in components}
print('Scanning %d components (%d shipped) against OSV'
% (len(components), sum(1 for _p, _n, _v, s in components if s)))
try:
hits = query(components)
except (urllib.error.URLError, TimeoutError) as error:
sys.stderr.write(
'Could not reach OSV: %s\n'
'This needs outbound HTTPS. Run it where the repository lives, not '
'on an air-gapped server.\n' % error)
return 2
allowed = load_allowlist()
findings = []
for purl, ids in sorted(hits.items()):
for vuln_id in ids:
try:
entry = get(OSV_VULN + vuln_id)
except (urllib.error.URLError, TimeoutError):
entry = {'id': vuln_id, 'summary': '(details unavailable)'}
findings.append({
'id': vuln_id,
'purl': purl,
'component': names.get(purl, purl),
'shipped': shipped.get(purl, True),
'severity': severity_of(entry),
'summary': (entry.get('summary') or '').strip(),
'version': versions.get(purl, ''),
'fixedin': fixed_versions(entry, purl),
'allowlisted': vuln_id in allowed,
'reason': (allowed.get(vuln_id) or {}).get('reason'),
})
findings.sort(key=lambda f: (not f['shipped'],
-SEVERITY_ORDER.index(f['severity']),
f['component']))
blocking = [f for f in findings
if f['shipped'] and not f['allowlisted']
and at_or_above(f['severity'], GATE_AT)]
report(findings, blocking)
if args.json:
args.json.write_text(json.dumps(
{'findings': findings, 'blocking': [f['id'] for f in blocking]},
indent=2), encoding='utf8')
print('\nWrote %s' % args.json)
if args.gate and blocking:
return 1
return 0
def version_key(version):
"""Sort dotted versions numerically, so 1.10 beats 1.9.
A prerelease sorts BELOW the release it precedes, so 2.0.0 beats
2.0.0-rc1. Treating '-rc1' as another dotted segment gets this backwards -
the longer key wins a list comparison - and the rollup would then send
somebody to a release candidate. The release marker is the last element for
exactly that reason.
"""
text = str(version)
base, _, pre = text.partition('-')
parts = []
for chunk in base.split('.'):
parts.append((1, int(chunk)) if chunk.isdigit() else (0, 0))
# 1 = a real release, 0 = a prerelease of it.
parts.append((0,) if pre else (1,))
return parts
def minimal_fix(fixedin, current):
"""The SMALLEST published fix above the version in use, or None.
An advisory patched on several release branches lists a fix per branch:
nanoid's is fixed in both 3.3.18 and 5.1.16. Taking the highest recommends
a major upgrade when a patch on the current branch would do - and where the
package is transitive, that major may not even be reachable, because the
parent's semver range forbids it. The smallest fix above the current
version is the one that actually clears the advisory.
Fixes at or below the current version belong to older branches and are
ignored; recommending one would be a downgrade.
"""
above = [v for v in fixedin if version_key(v) > version_key(current)]
if not above:
return None
return min(above, key=version_key)
def upgrade_plan(findings):
"""[(component, version, count, major)] - one row per component with a fix.
Per advisory, the minimal upgrade that clears it; per component, the
highest of those, since one upgrade has to satisfy every advisory against
it.
"""
by_component = {}
for f in findings:
if f['allowlisted'] or not f['fixedin']:
continue
target = minimal_fix(f['fixedin'], f['version'])
if target is None:
continue
name = f['component']
best, count, current = by_component.get(name, (None, 0, f['version']))
if best is None or version_key(target) > version_key(best):
best = target
by_component[name] = (best, count + 1, current)
rows = []
for name, (best, count, current) in by_component.items():
# A major bump is a different job from a patch bump - it can be
# ESM-only, or drop an API - so it is labelled rather than listed
# beside patch upgrades as though the two cost the same.
rows.append((name, best, count, major_of(best) != major_of(current)))
return sorted(rows)
def major_of(version):
head = str(version).split('.')[0]
return int(head) if head.isdigit() else None
def report(findings, blocking):
if not findings:
print('\nNo advisories match any component. Nothing to do.')
return
ship = [f for f in findings if f['shipped']]
dev = [f for f in findings if not f['shipped']]
print('\n%d advisory match(es): %d against shipped components, %d against '
'build-only tooling.' % (len(findings), len(ship), len(dev)))
if ship:
print('\nSHIPPED - these reach a server')
for f in ship:
mark = 'allowed' if f['allowlisted'] else ''
fix = ('fix: ' + ', '.join(f['fixedin'][:3])) if f['fixedin'] else 'no fix published'
print(' [%-8s] %-30s %-20s %-16s %s'
% (f['severity'], f['component'][:30], f['id'], fix[:16], mark))
if f['reason']:
print(' accepted: %s' % f['reason'])
elif f['summary']:
print(' %s' % f['summary'][:92])
if dev:
print('\nBUILD ONLY - never reaches a server, never gates a release')
for f in dev:
print(' [%-8s] %-34s %s'
% (f['severity'], f['component'][:34], f['id']))
if ship:
upgrades = upgrade_plan(ship)
if upgrades:
print('\nWHAT TO UPGRADE - shipped components, smallest upgrade that clears them')
for component, version, count, major in upgrades:
note = ' MAJOR - check for breaking changes' if major else ''
print(' %-30s -> %-12s clears %-3d%s'
% (component[:30], version, count, note))
print('')
if blocking:
print('%d shipped finding(s) at or above %s and not allowlisted.'
% (len(blocking), GATE_AT))
print('Fix the dependency, or record a decision in %s with a reason '
'and a date.' % ALLOWLIST.relative_to(REPO))
else:
print('Nothing shipped is at or above %s outside the allowlist.' % GATE_AT)
if __name__ == '__main__':
sys.exit(main())

View File

@@ -78,9 +78,36 @@ import API. It is site glue, not product code.
venv/bin/python -m scripts.site_imports.wjf.run
```
The 16 stages run in order (reference -> catalog -> assets hub -> locations ->
printers -> dependents -> relationships -> subnets -> usb -> verify). It is
idempotent - a crashed run resumes from `idmap.json`.
The 17 stages run in order (reference -> employees -> catalog -> assets hub
-> locations -> printers -> metrology -> communications -> applications ->
warranties -> notifications -> knowledgebase -> relationships ->
defaultprinters -> subnets -> usb -> verify).
**Pointing it at a different source.** The three source databases are
named logically in the loader and resolved from the environment, so a
restore under another name needs no code edit:
```
SOURCE_DB_MAIN=prodscratch \
SOURCE_DB_USB=cmmc_usb_src \
SOURCE_DB_EMPLOYEES=wjf_employees_src \
DATABASE_URL=... venv/bin/python -m scripts.site_imports.wjf.run
```
Also `SOURCE_DB_HOST`, `SOURCE_DB_PORT`, `SOURCE_DB_USER`,
`SOURCE_DB_PASSWORD`. The run prints every source it redirected, so
"it imported nothing" can be told apart from "it read the wrong database".
**Resume is per target database.** A crashed run resumes from the crosswalk
in `idmap.json`, which maps this site's legacy ids to assetids **in one
specific database**. The loader stamps that database's identity into the
file and refuses a crosswalk built against a different one, because reusing
it attaches warranties and printer assignments to whatever asset now holds
that id - wrong data, no error. If you rebuild the target, delete
`idmap.json` or pass `--idmap` a fresh path.
The exit code means something: non-zero if any endpoint errored, so a run
that logged 200 failures cannot read as a success in a deploy log.
3. Reclassify servers into network devices. The classic DB stored servers as
PCs, so the import lands them as `computer` assets. Re-point them in place:
@@ -151,8 +178,12 @@ documented skips (inactive rows, duplicate machinenumbers, LocationOnly, the
- Keep the classic app authoritative during the window. The pilot is read-mostly
for validation; do not dual-write.
- Have a few real users (IT + a floor lead) work the pilot and log gaps.
- Re-import is cheap: fix a loader mapping, drop + rebuild the pilot DB, re-run.
Nothing you do to the pilot touches classic.
- Re-import is cheap: fix a loader mapping, drop + rebuild the pilot DB,
**delete `idmap.json`**, re-run. Nothing you do to the pilot touches classic.
The crosswalk is bound to the database it was built against and the loader
refuses a stale one, but deleting it is the habit to have - a refusal costs
you a restart, and before the guard existed a stale file silently attached
rows to the wrong assets.
- Point the **collector** (GE-Enforce fleet ingest) at the pilot in parallel to
confirm live PC check-ins land (see `COLLECTOR-INTEGRATION.md`), using a
scoped service token.
@@ -163,7 +194,7 @@ When the window is clean:
1. Freeze classic writes (announce a short read-only window).
2. Take final fresh dumps; re-run the loader into a clean pilot DB so the
cutover data is current.
cutover data is current. A clean DB means a fresh `idmap.json` too.
3. Verify counts + a fast UI spot-check.
4. Repoint the production hostname/DNS (or the reverse proxy) at the pilot.
5. Retire the classic app to read-only standby (do not delete - keep it as the

View File

@@ -33,16 +33,34 @@ resolved decisions, and remaining stages.
## Status
Complete - all 15 stages built and verified end-to-end against a fresh scratch
target (zero endpoint errors): `reference`, `employees`, `catalog`, `assets`
(the hub + machineid->assetid crosswalk), `locations`, `printers`,
All 17 stages built: `reference`, `employees`, `catalog`, `assets` (the hub +
machineid->assetid crosswalk), `locations`, `printers`, `metrology`,
`communications`, `applications` (+ installs), `warranties`, `notifications`,
`knowledgebase`, `relationships`, `subnets`, `usb`, `verify`.
`knowledgebase`, `relationships`, `defaultprinters`, `subnets`, `usb`, `verify`.
Last full run: 983 assets (computer 663, machine 76, network 58, measuring-tool
136, printer 50), 24 locations, 415 employees, 850 installs, 461 primary IPs,
464 warranties, 261 notifications, 341 KB, 93 relationships, 37 subnets, 18 USB
devices + 232 events. UI spot-check passed (computer list + shop-floor map).
**The counts below are from the 2026-07-13 run and are STALE.** Two routing
changes landed after them - the `isactive=1` filter and the metrology routing
rework - so the asset mix has moved substantially. They are kept only as a
shape, not as a checklist: re-measure from the next full run before quoting
them anywhere, and do not verify a fresh import against them.
Last full run (2026-07-13, superseded): 983 assets (computer 663, machine 76,
network 58, measuring-tool 136, printer 50), 24 locations, 415 employees, 850
installs, 461 primary IPs, 464 warranties, 261 notifications, 341 KB, 93
relationships, 37 subnets, 18 USB devices + 232 events.
Known gaps, none of them blocking a scratch run:
- `warranties`, `notifications` and `knowledgebase` POST without a duplicate
check on the API side, so resuming ACROSS one of those stages doubles its
rows. Resuming at or after them is safe. Re-run into a clean target when in
doubt.
- There is no `--dry-run` and no guard on the target: the harness mints an
admin PAT and sets `setup_complete` in whatever `DATABASE_URL` points at.
Check the target before running.
- The `verify` stage counts target `assets` against source `machines`, which is
apples to oranges - the target also holds printers and synthesized metrology
tools.
Known follow-ups: general asset `locationid` is null (no source column outside
printers); the ~146 duplicate machinenumbers are first-wins-skipped by decision.

View File

@@ -14,6 +14,7 @@ crashed run resumes and later stages resolve foreign keys.
"""
import json
import uuid
import os
import pymysql
@@ -30,26 +31,63 @@ def _source_password():
with open(os.path.abspath(env)) as handle:
for line in handle:
if line.startswith('DATABASE_URL='):
# Not every server runs as root, and 'root:' then simply is not
# there: split()[1] raised IndexError, which read as a crash
# rather than the clear message two lines down.
if 'root:' not in line:
continue
after = line.split('root:', 1)[1]
return after.split('@', 1)[0]
raise RuntimeError('could not resolve source DB password')
class Source:
"""Read-only access to the three scratch source databases."""
"""Read-only access to the three source databases.
def __init__(self, host='127.0.0.1', port=3306, user='root', password=None):
The stage functions name a source by its LOGICAL name - 'shopdb_src',
'cmmc_usb_src', 'wjf_employees_src' - and this class resolves each to a
real database. That indirection is the point: the names were hardcoded at
29 call sites, so pointing the loader at a differently-named copy (the
prodscratch restore, or a site's own dump) meant editing run.py, which is
not something anybody should do to aim a loader. The logical names stay in
the stages where they read as documentation; the mapping lives here.
Everything is environment-overridable, defaulting to the scratch layout:
SOURCE_DB_MAIN default shopdb_src
SOURCE_DB_USB default cmmc_usb_src
SOURCE_DB_EMPLOYEES default wjf_employees_src
SOURCE_DB_HOST/PORT/USER/PASSWORD
"""
# logical name -> (env var, default real name)
ALIASES = {
'shopdb_src': ('SOURCE_DB_MAIN', 'shopdb_src'),
'cmmc_usb_src': ('SOURCE_DB_USB', 'cmmc_usb_src'),
'wjf_employees_src': ('SOURCE_DB_EMPLOYEES', 'wjf_employees_src'),
}
def __init__(self, host=None, port=None, user=None, password=None):
self.password = password or _source_password()
self.host, self.port, self.user = host, port, user
self.host = host or os.environ.get('SOURCE_DB_HOST', '127.0.0.1')
self.port = int(port or os.environ.get('SOURCE_DB_PORT', 3306))
self.user = user or os.environ.get('SOURCE_DB_USER', 'root')
self._conns = {}
@classmethod
def resolve(cls, name):
"""The real database behind a logical source name."""
env, default = cls.ALIASES.get(name, (None, name))
return os.environ.get(env, default) if env else name
def db(self, name):
if name not in self._conns:
self._conns[name] = pymysql.connect(
real = self.resolve(name)
if real not in self._conns:
self._conns[real] = pymysql.connect(
host=self.host, port=self.port, user=self.user,
password=self.password, database=name, charset='utf8mb4',
password=self.password, database=real, charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
return self._conns[name]
return self._conns[real]
def rows(self, dbname, sql, args=None):
with self.db(dbname).cursor() as cur:
@@ -57,15 +95,62 @@ class Source:
return cur.fetchall()
class IdMap:
"""Persisted legacy-id -> new-id crosswalks, one namespace per entity."""
class StaleIdMapError(RuntimeError):
"""The crosswalk on disk was built against a different target database."""
def __init__(self, path):
class IdMap:
"""Persisted legacy-id -> new-id crosswalks, one namespace per entity.
BOUND TO ONE TARGET DATABASE. The crosswalk holds this site's legacy ids
mapped to assetids in a specific database, and those assetids mean nothing
anywhere else. Two ways that used to go wrong, both silent:
- Delete idmap.json and re-run: every create 409s, nothing resolves into
the crosswalk, and warranties, installs, relationships and printer
assignments all quietly import as ~0 rows.
- Keep idmap.json across a drop-and-rebuild of the target: the legacy ids
now point at assetids belonging to the OLD database, so warranties and
printer assignments attach to whatever asset happens to hold that id
now. Wrong data, no error, and the runbook's two "drop and rebuild"
steps never said to delete the file.
The bind is a marker row in the target's own settings table, so it dies
with the data: a rebuild produces a new marker, and the mismatch is caught
before a single row is written. The database NAME is not enough on its own,
because a rebuild keeps it.
"""
MARKER_KEY = 'siteimport_targetinstance'
def __init__(self, path, target=None):
self.path = path
self.target = target
self._data = {}
if os.path.exists(path):
with open(path) as handle:
self._data = json.load(handle)
self._check_target()
def _check_target(self):
if not self.target:
return
seen = self._data.get('_target')
if seen is None:
# Written before this check existed. Adopt it rather than refuse:
# refusing would strand a crosswalk that is probably fine, and the
# next run is bound properly.
self._data['_target'] = self.target
self.save()
return
if seen != self.target:
raise StaleIdMapError(
'%s was built against target %s, but this run targets %s.\n'
'Those assetids belong to a different database and using them '
'would attach imported rows to the wrong assets.\n'
'Delete the file to start a fresh crosswalk, or point '
'--idmap at the one for this target.'
% (self.path, seen, self.target))
def put(self, namespace, legacy, new):
self._data.setdefault(namespace, {})[str(legacy)] = new
@@ -78,7 +163,8 @@ class IdMap:
return str(legacy) in self._data.get(namespace, {})
def count(self, namespace):
return len(self._data.get(namespace, {}))
entry = self._data.get(namespace, {})
return len(entry) if isinstance(entry, dict) else 0
def save(self):
with open(self.path, 'w') as handle:
@@ -97,11 +183,38 @@ class Harness:
self._ensure_asset_types()
self.secret = self._mint_admin_pat()
default = os.path.join(os.path.dirname(__file__), 'idmap.json')
self.ids = IdMap(idmap_path or default)
self.ids = IdMap(idmap_path or default, target=self._target_instance())
self.source = Source()
self.errors = []
self._defaultlevelid = None
def _target_instance(self):
"""A stable id for THIS target database, created once and stored in it.
Kept in the target's own settings table so it dies when the data does.
A drop-and-rebuild yields a new id, which is what lets IdMap catch a
crosswalk carried over from the previous database - the failure that
silently attaches imported rows to whatever asset now holds that id.
The database name alone would not do: a rebuild keeps the name.
"""
from shopdb.core.models import Setting
existing = Setting.query.filter_by(key=IdMap.MARKER_KEY).first()
if existing and existing.value:
return existing.value
marker = uuid.uuid4().hex
if existing:
existing.value = marker
else:
db.session.add(Setting(
key=IdMap.MARKER_KEY, value=marker, valuetype='string',
category='import',
description='Identifies this database to the site-import '
'crosswalk. Created by the loader; do not copy it '
'between databases.'))
db.session.commit()
return marker
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).

View File

@@ -9,16 +9,19 @@ Stages are idempotent (re-running resolves existing rows by natural key), so a
crashed run resumes. See scripts/site_imports/wjf/harness.py for the contract
this drives, and scratchpad/IMPORT-PLAN.md for the full mapping + decisions.
Implemented: reference (vendors, businessunits, operatingsystems), employees.
TODO stages (assets hub + dependents + network + usb) are stubbed - they need
the machineid->assetid crosswalk this harness persists.
All 17 stages are implemented; STAGES at the bottom of this file is the list
and main()'s --stages default is the order they must run in. The assets hub
comes before everything that references an asset, because it is what builds the
machineid->assetid crosswalk the harness persists.
"""
import argparse
import sys
from urllib.parse import quote
import pymysql
from .harness import Harness
from .harness import Harness, Source, StaleIdMapError
def _id_of(row, idfield):
@@ -33,6 +36,76 @@ def _id_of(row, idfield):
return None
# The server clamps any page size to MAX_PAGE_SIZE (shopdb/config.py), which
# is 100. Asking for more is not an error and not a warning - it just silently
# returns 100 rows, which is why the 409-resolve below pages instead.
PAGE_SIZE = 100
# A resolve that walks more pages than this is a bug in the caller, not a big
# table. Bounded so a paging mistake cannot spin forever against a live server.
MAX_RESOLVE_PAGES = 200
def _rows_of(payload):
"""The list out of a list-endpoint response, whichever shape it took."""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
items = payload.get('items')
if isinstance(items, list):
return items
return []
def _resolve_existing(h, lookup, unique_field, wanted, idfield):
"""Find an existing row by its natural key. Returns its id, or None.
This used to ask for ?per_page=10000 and read the single response. The
server clamps per_page to 100, so it only ever searched the first 100 rows
and returned None for everything after that. The failure was silent and
downstream: _upsert returned None, the id never reached the crosswalk, and
the run finished "successfully" having quietly dropped a model's link on
every machine that used it. The target already holds more than 100 models
and more than 100 applications, so this fired on any re-run or resume - the
exact case the loader documents as safe.
Two passes. Some endpoints expose an exact natural-key filter for this
(?modelnumber= on models, ?appname= on applications, ?vendor= on vendors);
an endpoint without one ignores the unknown query parameter and returns its
normal first page, so the paged walk that follows is what finds the row.
"""
wanted_key = str(wanted).strip().lower()
separator = '&' if '?' in lookup else '?'
def match(rows):
for row in rows:
if str(row.get(unique_field, '')).strip().lower() == wanted_key:
return _id_of(row, idfield)
return None
# Pass 1: the exact filter, where the endpoint has one.
exact = '%s%s%s=%s&per_page=%d' % (
lookup, separator, unique_field, quote(str(wanted)), PAGE_SIZE)
_, data = h.get(exact)
found = match(_rows_of(data))
if found is not None:
return found
# Pass 2: every page, in order.
for page in range(1, MAX_RESOLVE_PAGES + 1):
_, data = h.get('%s%sper_page=%d&page=%d'
% (lookup, separator, PAGE_SIZE, page))
rows = _rows_of(data)
if not rows:
return None
found = match(rows)
if found is not None:
return found
if len(rows) < PAGE_SIZE:
return None
return None
def _upsert(h, path, payload, unique_field, idfield, list_path=None):
"""POST payload; on 409 resolve the existing row by its unique field.
Returns the new/existing id, or None."""
@@ -40,14 +113,8 @@ def _upsert(h, path, payload, unique_field, idfield, list_path=None):
if status in (200, 201):
return _id_of(data, idfield)
if status == 409:
lookup = list_path or path
lookup += ('&' if '?' in lookup else '?') + 'per_page=10000'
_, rows = h.get(lookup)
items = rows if isinstance(rows, list) else rows.get('items', rows)
for row in (items or []):
if str(row.get(unique_field, '')).strip().lower() == \
str(payload[unique_field]).strip().lower():
return _id_of(row, idfield)
return _resolve_existing(h, list_path or path, unique_field,
payload[unique_field], idfield)
return None
@@ -418,10 +485,22 @@ def stage_metrology(h):
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid})
if status not in (200, 201):
if status in (200, 201):
tool_assetid = _id_of(data, 'assetid')
made += 1
elif status == 409:
# Already imported by an earlier run. Resolve it rather than
# skipping: `continue` here left the crosswalk without this tool,
# so the Controls edge below was never made and a resumed run
# produced metrology PCs driving nothing. stage_printers already
# had this branch; this stage was written later and missed it.
tool_assetid = _resolve_existing(
h, '/api/measuringtools', 'assetnumber',
f"MT-{m['machineid']}", 'assetid')
if not tool_assetid:
continue
else:
continue
tool_assetid = _id_of(data, 'assetid')
made += 1
if tool_assetid and controls_id:
h.ids.put('metrologytool', m['machineid'], tool_assetid)
st, _ = h.post('/api/assets/relationships', {
@@ -906,10 +985,27 @@ def main():
'metrology,communications,applications,warranties,notifications,'
'knowledgebase,relationships,defaultprinters,subnets,usb,verify',
help='comma list of stages to run')
parser.add_argument(
'--idmap', default=None,
help='path to the legacy-id crosswalk (default: idmap.json beside '
'this script). One per target database - a crosswalk from '
'another database maps to the wrong assets.')
args = parser.parse_args()
h = Harness()
print(f'== WJ import loader (target: {h.app.config["SQLALCHEMY_DATABASE_URI"].split("/")[-1]}) ==')
try:
h = Harness(idmap_path=args.idmap)
except StaleIdMapError as error:
print('REFUSED: %s' % error)
return 1
target = h.app.config['SQLALCHEMY_DATABASE_URI'].split('/')[-1]
print(f'== WJ import loader (target: {target}) ==')
# Say which sources this run reads. They are overridable now, so "it
# imported nothing" is usually "it read the wrong database".
for logical in ('shopdb_src', 'cmmc_usb_src', 'wjf_employees_src'):
real = Source.resolve(logical)
if real != logical:
print(f' source {logical} -> {real}')
try:
for name in args.stages.split(','):
name = name.strip()
@@ -922,9 +1018,15 @@ def main():
print(f'\n {len(h.errors)} endpoint error(s):')
for path, code, _payload, data in h.errors[:10]:
print(f' {code} {path}: {data}')
# An endpoint error is not a crash, but it did leave rows unimported,
# and a run that exits 0 after 200 of them looks like a success in a
# deploy log.
if h.errors:
return 1
finally:
h.close()
return 0
if __name__ == '__main__':
main()
sys.exit(main())

View File

@@ -0,0 +1,23 @@
{
"_comment": [
"Advisories reviewed and accepted, with the reason and the date.",
"This is a RECORD, not a mute button: an entry says somebody looked at",
"the advisory and worked out why it does not apply here. Anyone can",
"re-read the reason and disagree.",
"",
"An entry needs an id, a reason that names the precondition that is",
"absent, who accepted it, and when. 'reviewuntil' is when the reasoning",
"should be checked again - a reason stops being true when the code that",
"made it true changes, and 'invalidatedby' should say what that change",
"would be.",
"",
"Currently EMPTY, which is the state to aim for. The one entry this file",
"has held so far - a PyJWT algorithm-confusion advisory that needed a",
"configuration this application does not use - was removed by upgrading",
"PyJWT rather than by keeping the exception.",
"",
"Do NOT add an entry to make a build pass. Upgrading the dependency is",
"almost always cheaper than defending a decision to skip it."
],
"accepted": []
}

View File

@@ -88,7 +88,12 @@ from .plugins import plugin_manager
# emission never consulted the caller, so a read-only viewer was shown controls
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
# visitor holds whatever the guest role was granted. Additive name, minor bump.
__contract_version__ = '0.25.0'
# 0.26.0: dashboard cards may declare 'shortname'. The dashboard merged its
# per-source cards into one worst-first queue, and a card's 'title' is written
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
# ran wider than the hostname it qualified. Optional, defaults to the title, so
# a plugin that does not declare one is unaffected. Additive field, minor bump.
__contract_version__ = '0.26.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -238,6 +238,9 @@ class BasePlugin(ABC):
Each card: {
'id': str, # stable, unique across plugins
'title': str, # heading
'shortname': str, # optional: label for this card's rows in the
# merged queue. A title reads as a header and is
# too long as a row badge; defaults to title.
'endpoint': str, # API path the card fetches its own data from
'render': str, # core renderer: 'exceptions' | 'list' | 'stat'
'severity': str, # 'critical' | 'warning' | 'info' - orders cards

View File

@@ -104,3 +104,50 @@ def test_relationships_row_carries_isdirectional(client, db, auth_headers):
ctrl_rows = [r for r in data['outgoing']
if r['relationshiptypename'] == 'controls']
assert ctrl_rows and all(r['isdirectional'] is True for r in ctrl_rows)
def test_the_controlled_asset_sees_the_link_from_its_own_page(client, db, auth_headers):
"""The target of a directional edge must receive it in `incoming`.
A PC controls a machine, and the row is stored once, source=PC. The PC's
page reads it from `outgoing`; the machine's page can only ever see it in
`incoming`. Nothing asserted that second half, and a UI was shipped that
rendered the link on the PC page and showed the machine page nothing - the
reciprocal-links defect. The endpoint was correct all along, which is
exactly why this assertion is worth having: it pins the contract the
frontend depends on, so the next such bug cannot be blamed on the API.
"""
atype_machine = AssetType(assettype='machine')
atype_computer = AssetType(assettype='computer')
db.session.add_all([atype_machine, atype_computer])
db.session.flush()
controls = RelationshipType(relationshiptype='controls', isdirectional=True)
db.session.add(controls)
db.session.flush()
pc = _make_asset('PC-1', atype_computer.assettypeid)
machine = _make_asset('M-1', atype_machine.assettypeid)
# Stored ONCE, in the canonical direction only.
db.session.add(AssetRelationship(sourceassetid=pc.assetid,
targetassetid=machine.assetid,
relationshiptypeid=controls.relationshiptypeid))
db.session.commit()
resp = client.get(f'/api/assets/{machine.assetid}/relationships',
headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()['data']
assert data['outgoing'] == [], 'the machine controls nothing'
assert len(data['incoming']) == 1, 'the machine must see the PC that controls it'
row = data['incoming'][0]
assert row['relationshiptypename'] == 'controls'
# The peer has to arrive with the fields the UI routes on. assetid alone
# is not enough: the detail routes key on pluginid, and the type name
# chooses the route base.
peer = row['sourceasset']
assert peer is not None
assert peer['assetid'] == pc.assetid
assert peer['assettypename'] == 'computer'

View File

@@ -70,8 +70,10 @@ EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
# employees adds the photofilename column on top of its cutover anchor.
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
# adds scannedviruses so an imported external history keeps the scan result -
# the field a removable-media audit actually asks about.
EXPECTED_HEAD_REVISION['usb'] = 'usb0003scanfields'
# network links a device to a catalog model, which is where its photo comes
# from - machines, PCs and printers already had that link.
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'

View File

@@ -0,0 +1,90 @@
"""The supplies dashboard card must not report "nothing low" when it cannot see.
_get_low_supplies_data returned the same empty result for three different
situations - Zabbix not configured, Zabbix unreachable, and every cartridge
full - so the dashboard displayed "Low Supplies 0 / Critical 0" while the
monitoring was dead. That is not a missing feature; it is a wrong statement, and
it reads as reassurance.
These pin the three cases apart.
"""
from unittest.mock import patch
import pytest
from shopdb.api import db
from shopdb.core.models import Setting
def _call(client, auth_headers):
response = client.get('/api/printers/dashboard/supplies', headers=auth_headers)
assert response.status_code == 200
return response.get_json()['data']
class FakeService:
def __init__(self, configured, reachable):
self.isconfigured = configured
self.isreachable = reachable
@pytest.fixture(autouse=True)
def _clear_cache(app):
from shopdb.extensions import cache
cache.delete('printers_low_supplies')
yield
cache.delete('printers_low_supplies')
def test_unreachable_says_so_rather_than_reporting_zero(client, auth_headers):
"""Configured and unanswerable: the cartridges are NOT known to be fine."""
with patch('plugins.printers.api.asset_routes.ZabbixService',
return_value=FakeService(configured=True, reachable=False)):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data['unavailable'] == 'Supply monitoring is not reachable'
def test_unconfigured_stays_quiet(client, auth_headers):
"""The site did not ask for supply monitoring.
A permanent "not configured" line would be a card reporting nothing wrong
every day, which is how a board teaches people to stop reading it. It has
nothing to say, so it says nothing and the card hides.
"""
with patch('plugins.printers.api.asset_routes.ZabbixService',
return_value=FakeService(configured=False, reachable=False)):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data.get('unavailable') is None
def test_reachable_with_nothing_low_is_not_flagged_as_blind(client, auth_headers):
"""Zabbix answered and every cartridge is fine. That is a real zero."""
with patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': [],
'summary': {'total_checked': 3, 'low': 0, 'critical': 0}}):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data.get('unavailable') is None
def test_the_envelope_carries_rows_so_the_card_can_still_render(client, auth_headers):
"""The response became an envelope to carry the reason; rows must survive."""
printer = {
'printerid': 1, 'assetnumber': 'PRN-1', 'printername': 'Front office',
'location': 'Bay 3', 'mapx': None, 'mapy': None, 'levelid': None,
'supplies': [{'name': 'Black', 'remaining': 2, 'status': 'critical'}],
}
with patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': [printer],
'summary': {'total_checked': 1, 'low': 1, 'critical': 1}}):
data = _call(client, auth_headers)
assert data.get('unavailable') is None
assert len(data['rows']) == 1
assert data['rows'][0]['printername'] == 'Front office'
assert data['rows'][0]['iscritical'] is True

View File

@@ -0,0 +1,99 @@
"""Folding an external check-in/out LOG into self-hosted checkout PAIRS.
The external cmmc_usb database records one row per action; the self-hosted
usbcheckouts table records one row per checkout, carrying both times. The
import has to convert between them, and the interesting cases are all the ways
a real log is untidy after years of use: a drive still out, a return nobody
has a matching issue for, two issues in a row.
The rule these tests pin down is that NOTHING is invented and NOTHING vanishes
silently. An unclosed checkout stays unclosed; anything that cannot be paired
is reported. A quieter import that guessed the missing halves would produce a
tidier database and a false audit history, which for removable-media records is
the worst of the available outcomes.
"""
from plugins.usb.cli import _pair, _tri
def _row(action, when, badge='B1'):
return {'action': action, 'timestamp': when, 'badge_number': badge}
def test_a_clean_out_and_in_becomes_one_closed_checkout():
problems = []
pairs = _pair([_row('check-out', 1), _row('check-in', 2)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert out['timestamp'] == 1
assert back['timestamp'] == 2
assert problems == []
def test_a_drive_still_out_stays_open_and_is_not_reported_as_a_problem():
# This is the normal state of any drive currently issued to somebody, not
# a data fault, so it must not appear in the problem list.
problems = []
pairs = _pair([_row('check-out', 1)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert back is None
assert problems == []
def test_a_return_with_no_matching_issue_is_skipped_and_reported():
problems = []
pairs = _pair([_row('check-in', 5)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'no matching check-out' in problems[0]
assert 'DEV1' in problems[0]
def test_two_issues_in_a_row_leave_the_first_open_rather_than_inventing_a_return():
problems = []
pairs = _pair([_row('check-out', 1, 'ALICE'),
_row('check-out', 2, 'BOB'),
_row('check-in', 3, 'BOB')], problems, 'DEV1')
assert len(pairs) == 2
first, second = pairs
assert first[0]['badge_number'] == 'ALICE'
assert first[1] is None, 'the first checkout must not be given a return time'
assert second[0]['badge_number'] == 'BOB'
assert second[1]['timestamp'] == 3
assert len(problems) == 1
assert 'still out' in problems[0]
def test_an_unrecognised_action_is_skipped_and_reported():
problems = []
pairs = _pair([_row('destroyed', 1)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'unrecognised action' in problems[0]
def test_action_spelling_variants_are_accepted():
# Years of a hand-maintained log produce more than one spelling.
for out_word, in_word in (('check-out', 'check-in'),
('checkout', 'checkin'),
('out', 'in'),
('CHECK_OUT', 'RETURN')):
problems = []
pairs = _pair([_row(out_word, 1), _row(in_word, 2)], problems, 'DEV1')
assert len(pairs) == 1, out_word
assert pairs[0][1] is not None, out_word
assert problems == [], out_word
def test_unrecorded_flags_stay_unknown_rather_than_becoming_false():
# NULL in the source means nobody wrote it down. False would assert that
# the drive was checked and failed, which is a different claim entirely.
assert _tri(None) is None
assert _tri(0) is False
assert _tri(1) is True

225
tests/test_scan_sbom.py Normal file
View File

@@ -0,0 +1,225 @@
"""The vulnerability scanner's judgement, without touching the network.
scan_sbom.py decides three things that matter: whether a finding can stop a
release, which version clears it, and whether somebody already accepted it.
Each is pure given an OSV entry, so each is tested against a hand-written entry
rather than a live query. The network call itself is not tested - if OSV is
down the script says so and exits 2, which is the only sensible behaviour and
not worth a mock.
"""
import importlib.util
import json
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
SCANNER = REPO / 'scripts' / 'scan_sbom.py'
def load():
"""Import the script by path - scripts/ is not a package."""
spec = importlib.util.spec_from_file_location('scan_sbom', SCANNER)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture(scope='module')
def scan():
return load()
class TestSeverity:
def test_word_is_taken_from_the_database(self, scan):
entry = {'database_specific': {'severity': 'HIGH'}}
assert scan.severity_of(entry) == 'HIGH'
def test_lowercase_is_accepted(self, scan):
assert scan.severity_of(
{'database_specific': {'severity': 'critical'}}) == 'CRITICAL'
def test_a_cvss_vector_alone_is_unknown_not_guessed(self, scan):
# Deliberate: parsing a vector into a score we might get wrong is worse
# than an honest gap, because somebody would act on the wrong number.
entry = {'severity': [{'type': 'CVSS_V3',
'score': 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/'
'C:H/I:H/A:H'}]}
assert scan.severity_of(entry) == 'UNKNOWN'
def test_missing_severity_is_unknown(self, scan):
assert scan.severity_of({}) == 'UNKNOWN'
def test_unknown_never_reaches_the_gate(self, scan):
assert not scan.at_or_above('UNKNOWN', scan.GATE_AT)
def test_gate_floor_and_above(self, scan):
assert scan.at_or_above('HIGH', 'HIGH')
assert scan.at_or_above('CRITICAL', 'HIGH')
assert not scan.at_or_above('MODERATE', 'HIGH')
assert not scan.at_or_above('LOW', 'HIGH')
class TestPurl:
def test_npm(self, scan):
assert scan.parse_purl('pkg:npm/axios@1.13.2') == ('npm', 'axios')
def test_pypi_maps_to_the_osv_name(self, scan):
assert scan.parse_purl('pkg:pypi/pyjwt@2.12.1') == ('PyPI', 'pyjwt')
def test_scoped_npm_name_survives(self, scan):
assert scan.parse_purl('pkg:npm/%40vue/compiler-sfc@3.5.0') == (
'npm', '@vue/compiler-sfc')
def test_no_version_is_fine(self, scan):
assert scan.parse_purl('pkg:npm/axios') == ('npm', 'axios')
class TestFixedVersions:
def test_reads_the_fixed_event(self, scan):
entry = {'affected': [{
'package': {'ecosystem': 'PyPI', 'name': 'pyjwt'},
'ranges': [{'events': [{'introduced': '0'}, {'fixed': '2.13.0'}]}],
}]}
assert scan.fixed_versions(entry, 'pkg:pypi/pyjwt@2.12.1') == ['2.13.0']
def test_another_packages_fix_is_not_attributed_here(self, scan):
# An advisory covering several packages must not lend one package's
# fix version to another - that would send somebody to a wrong version.
entry = {'affected': [
{'package': {'ecosystem': 'npm', 'name': 'other'},
'ranges': [{'events': [{'fixed': '9.9.9'}]}]},
{'package': {'ecosystem': 'npm', 'name': 'axios'},
'ranges': [{'events': [{'fixed': '1.16.0'}]}]},
]}
assert scan.fixed_versions(entry, 'pkg:npm/axios@1.13.2') == ['1.16.0']
def test_same_name_in_another_ecosystem_is_ignored(self, scan):
entry = {'affected': [{
'package': {'ecosystem': 'npm', 'name': 'pyjwt'},
'ranges': [{'events': [{'fixed': '9.9.9'}]}],
}]}
assert scan.fixed_versions(entry, 'pkg:pypi/pyjwt@2.12.1') == []
def test_no_fix_published_is_empty(self, scan):
entry = {'affected': [{
'package': {'ecosystem': 'npm', 'name': 'axios'},
'ranges': [{'events': [{'introduced': '0'}]}],
}]}
assert scan.fixed_versions(entry, 'pkg:npm/axios@1.13.2') == []
class TestVersionOrder:
def test_ten_is_above_nine(self, scan):
# A string compare puts 1.9.0 above 1.10.0, which would recommend
# downgrading.
assert scan.version_key('1.10.0') > scan.version_key('1.9.0')
def test_prerelease_does_not_displace_a_release(self, scan):
assert scan.version_key('2.0.0') > scan.version_key('2.0.0-rc1')
def test_versions_with_different_segment_counts_compare(self, scan):
# OSV fix versions are not normalised, so a two-segment and a
# three-segment version land in the same sort.
assert scan.version_key('2.0.1') > scan.version_key('2.0')
assert scan.version_key('1.16.0') > scan.version_key('1.9')
class TestUpgradePlan:
def _finding(self, component, version, fixedin, allowlisted=False):
return {'component': component, 'version': version, 'fixedin': fixedin,
'allowlisted': allowlisted}
def test_highest_fix_wins_and_counts_what_it_clears(self, scan):
findings = [
self._finding('axios 1.13.2', '1.13.2', ['1.13.5']),
self._finding('axios 1.13.2', '1.13.2', ['1.16.0']),
self._finding('axios 1.13.2', '1.13.2', ['1.15.1']),
]
assert scan.upgrade_plan(findings) == [('axios 1.13.2', '1.16.0', 3, False)]
def test_a_major_jump_is_flagged(self, scan):
findings = [self._finding('nanoid 3.3.11', '3.3.11', ['5.1.16'])]
assert scan.upgrade_plan(findings)[0][3] is True
def test_a_branch_patch_beats_a_major_on_the_same_advisory(self, scan):
# The real case this was found on: nanoid is fixed in BOTH 3.3.18 and
# 5.1.16, and it is transitive under a postcss range of ^3.3.11 - so
# 5.x is not merely a bigger job, it is unreachable. Recommending the
# highest fix sent somebody to a major upgrade they could not make.
findings = [self._finding('nanoid 3.3.11', '3.3.11',
['3.3.18', '5.1.16'])]
component, version, _count, major = scan.upgrade_plan(findings)[0]
assert version == '3.3.18'
assert major is False
def test_component_target_satisfies_every_advisory_against_it(self, scan):
# Two advisories, minimal fixes 3.3.16 and 3.3.18: the row must be the
# higher, or the upgrade clears one and leaves the other.
findings = [
self._finding('nanoid 3.3.11', '3.3.11', ['3.3.16', '5.1.16']),
self._finding('nanoid 3.3.11', '3.3.11', ['3.3.18', '5.1.6']),
]
assert scan.upgrade_plan(findings)[0][1] == '3.3.18'
def test_a_fix_on_an_older_branch_is_never_a_downgrade(self, scan):
# axios advisories list fixes on the 0.30.x line too; on 1.13.2 those
# are behind us and must not be recommended.
findings = [self._finding('axios 1.13.2', '1.13.2',
['0.30.2', '1.15.2'])]
assert scan.upgrade_plan(findings)[0][1] == '1.15.2'
def test_only_older_branch_fixes_means_no_row(self, scan):
findings = [self._finding('axios 1.13.2', '1.13.2', ['0.30.2'])]
assert scan.upgrade_plan(findings) == []
def test_a_patch_bump_is_not_flagged(self, scan):
findings = [self._finding('pyjwt 2.12.1', '2.12.1', ['2.13.0'])]
assert scan.upgrade_plan(findings)[0][3] is False
def test_allowlisted_findings_do_not_ask_for_an_upgrade(self, scan):
findings = [self._finding('pyjwt 2.12.1', '2.12.1', ['2.13.0'],
allowlisted=True)]
assert scan.upgrade_plan(findings) == []
def test_a_finding_with_no_fix_is_omitted(self, scan):
findings = [self._finding('thing 1.0', '1.0', [])]
assert scan.upgrade_plan(findings) == []
class TestAllowlist:
def test_the_shipped_allowlist_parses_and_is_complete(self, scan):
"""Every entry must carry the reasoning, or it is just a mute."""
data = json.loads(scan.ALLOWLIST.read_text(encoding='utf8'))
for entry in data.get('accepted', []):
for field in ('id', 'reason', 'acceptedby', 'accepted'):
assert entry.get(field), '%s missing %s' % (entry, field)
assert len(entry['reason']) > 40, (
'reason for %s is too short to be a reason' % entry['id'])
def test_allowlist_is_keyed_by_advisory_id(self, scan):
allowed = scan.load_allowlist()
for key, entry in allowed.items():
assert key == entry['id']
class TestScopeSeparation:
def test_build_only_components_are_read_as_not_shipped(self, scan, tmp_path):
sbom = tmp_path / 'sbom.json'
sbom.write_text(json.dumps({'components': [
{'name': 'axios', 'version': '1.0', 'purl': 'pkg:npm/axios@1.0'},
{'name': 'vite', 'version': '5.0', 'purl': 'pkg:npm/vite@5.0',
'scope': 'excluded'},
]}), encoding='utf8')
components = scan.load_components(sbom)
shipped = {name: ship for _p, name, _v, ship in components}
assert shipped == {'axios': True, 'vite': False}
def test_a_component_without_a_purl_is_skipped(self, scan, tmp_path):
# OSV is queried by purl; a component without one cannot be looked up,
# and inventing an identifier would produce confident wrong answers.
sbom = tmp_path / 'sbom.json'
sbom.write_text(json.dumps({'components': [
{'name': 'local-thing', 'version': '1.0'},
]}), encoding='utf8')
assert scan.load_components(sbom) == []

View File

@@ -0,0 +1,164 @@
"""The WJ reference loader's resolve, crosswalk binding and source aliasing.
The loader had no tests at all, and three of its defects were the silent kind:
they did not raise, they imported fewer rows and finished cheerfully. These
cover the logic that can be exercised without a MySQL source, which is exactly
the part that was wrong.
Not covered here: the stage functions themselves. They need the classic source
schema, so they belong in a fixture-backed integration test against a scratch
database, not in the unit suite.
"""
import json
import os
import pytest
from scripts.site_imports.wjf import run as loader
from scripts.site_imports.wjf.harness import IdMap, Source, StaleIdMapError
class FakeHarness:
"""Records the paths requested and replays canned list responses."""
def __init__(self, pages):
self.pages = pages
self.requested = []
def get(self, path):
self.requested.append(path)
return 200, self.pages.get(path, {'items': []})
class TestResolveExisting:
def test_finds_a_row_beyond_the_first_page(self):
"""The bug: per_page is clamped to 100, so row 101 was invisible.
The old code asked for per_page=10000, read one response, and returned
None for anything the server did not fit in its first 100 rows. The
target already holds more than 100 models and more than 100
applications, so this fired on every resume.
"""
page1 = {'items': [{'modelnumber': 'M%03d' % i, 'modelnumberid': i}
for i in range(100)]}
page2 = {'items': [{'modelnumber': 'LATE', 'modelnumberid': 999}]}
harness = FakeHarness({
'/api/models?modelnumber=LATE&per_page=100': {'items': []},
'/api/models?per_page=100&page=1': page1,
'/api/models?per_page=100&page=2': page2,
})
found = loader._resolve_existing(
harness, '/api/models', 'modelnumber', 'LATE', 'modelnumberid')
assert found == 999
def test_tries_the_exact_filter_first(self):
harness = FakeHarness({
'/api/models?modelnumber=WIDGET&per_page=100':
{'items': [{'modelnumber': 'WIDGET', 'modelnumberid': 7}]},
})
assert loader._resolve_existing(
harness, '/api/models', 'modelnumber', 'WIDGET', 'modelnumberid') == 7
# One request: it must not walk every page when the filter answered.
assert len(harness.requested) == 1
def test_values_needing_encoding_do_not_break_the_filter(self):
harness = FakeHarness({})
loader._resolve_existing(harness, '/api/vendors', 'vendor',
'Acme & Co/Ltd', 'vendorid')
assert 'Acme%20%26%20Co/Ltd' in harness.requested[0] or \
'Acme%20%26%20Co%2FLtd' in harness.requested[0]
def test_matching_ignores_case_and_padding(self):
harness = FakeHarness({
'/api/vendors?vendor=acme&per_page=100':
{'items': [{'vendor': ' ACME ', 'vendorid': 3}]},
})
assert loader._resolve_existing(
harness, '/api/vendors', 'vendor', 'acme', 'vendorid') == 3
def test_missing_row_returns_none_without_spinning(self):
harness = FakeHarness({})
assert loader._resolve_existing(
harness, '/api/models', 'modelnumber', 'NOPE', 'modelnumberid') is None
# Empty first page ends it: the filter attempt plus one page.
assert len(harness.requested) == 2
def test_a_short_page_ends_the_walk(self):
harness = FakeHarness({
'/api/models?per_page=100&page=1': {'items': [{'modelnumber': 'A', 'modelnumberid': 1}]},
})
assert loader._resolve_existing(
harness, '/api/models', 'modelnumber', 'ZZZ', 'modelnumberid') is None
assert len(harness.requested) == 2
def test_a_bare_list_response_is_accepted(self):
harness = FakeHarness({
'/api/x?name=a&per_page=100': [{'name': 'a', 'id': 5}],
})
assert loader._resolve_existing(harness, '/api/x', 'name', 'a', 'id') == 5
def test_an_existing_query_string_keeps_its_separator(self):
harness = FakeHarness({})
loader._resolve_existing(harness, '/api/models?category=pc',
'modelnumber', 'M1', 'modelnumberid')
assert harness.requested[0].startswith('/api/models?category=pc&modelnumber=')
class TestIdMapBinding:
def test_a_crosswalk_from_another_database_is_refused(self, tmp_path):
"""The silent corruption this guard exists for.
Keeping idmap.json across a drop-and-rebuild leaves legacy ids pointing
at assetids from the OLD database, so warranties and printer
assignments attach to whatever asset holds that id now. No error, wrong
data.
"""
path = tmp_path / 'idmap.json'
path.write_text(json.dumps({'_target': 'aaaa', 'asset': {'1': 10}}))
with pytest.raises(StaleIdMapError) as caught:
IdMap(str(path), target='bbbb')
assert 'different database' in str(caught.value)
def test_the_matching_target_loads(self, tmp_path):
path = tmp_path / 'idmap.json'
path.write_text(json.dumps({'_target': 'aaaa', 'asset': {'1': 10}}))
ids = IdMap(str(path), target='aaaa')
assert ids.get('asset', 1) == 10
def test_an_unstamped_crosswalk_is_adopted_not_refused(self, tmp_path):
# Written before the guard existed. Refusing would strand a crosswalk
# that is probably fine; adopting binds it for every run after.
path = tmp_path / 'idmap.json'
path.write_text(json.dumps({'asset': {'1': 10}}))
ids = IdMap(str(path), target='ccc')
assert ids.get('asset', 1) == 10
assert json.loads(path.read_text())['_target'] == 'ccc'
def test_the_bookkeeping_key_is_not_counted_as_a_namespace(self, tmp_path):
path = tmp_path / 'idmap.json'
ids = IdMap(str(path), target='ccc')
ids.put('asset', 1, 10)
assert ids.count('asset') == 1
assert ids.count('_target') == 0
def test_a_fresh_crosswalk_needs_no_stamp_to_start(self, tmp_path):
ids = IdMap(str(tmp_path / 'new.json'), target='ddd')
ids.put('asset', 5, 50)
assert ids.get('asset', 5) == 50
class TestSourceAliasing:
def test_logical_names_default_to_the_scratch_databases(self, monkeypatch):
for var in ('SOURCE_DB_MAIN', 'SOURCE_DB_USB', 'SOURCE_DB_EMPLOYEES'):
monkeypatch.delenv(var, raising=False)
assert Source.resolve('shopdb_src') == 'shopdb_src'
assert Source.resolve('cmmc_usb_src') == 'cmmc_usb_src'
assert Source.resolve('wjf_employees_src') == 'wjf_employees_src'
def test_the_environment_repoints_the_loader(self, monkeypatch):
"""What unblocks the prodscratch run without editing run.py."""
monkeypatch.setenv('SOURCE_DB_MAIN', 'prodscratch')
assert Source.resolve('shopdb_src') == 'prodscratch'
def test_an_unknown_name_passes_through(self, monkeypatch):
assert Source.resolve('something_else') == 'something_else'