Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours
This commit is contained in:
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
102
frontend/package-lock.json
generated
102
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">▾</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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
frontend/src/components/DashboardRow.vue
Normal file
154
frontend/src/components/DashboardRow.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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] : ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
43
frontend/src/utils/assetRoute.js
Normal file
43
frontend/src/utils/assetRoute.js
Normal 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
|
||||
54
frontend/src/utils/assetRoute.spec.js
Normal file
54
frontend/src/utils/assetRoute.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
27
frontend/src/utils/colorStyle.spec.js
Normal file
27
frontend/src/utils/colorStyle.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
|
||||
{
|
||||
'id': 'backups-stale',
|
||||
'title': 'Backups stopped',
|
||||
'shortname': 'Backups',
|
||||
'endpoint': '/api/backups/dashboard/stale',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
235
plugins/usb/cli.py
Normal 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]
|
||||
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal file
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal 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')
|
||||
@@ -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'))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
379
scripts/scan_sbom.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
23
scripts/vulnerability-allowlist.json
Normal file
23
scripts/vulnerability-allowlist.json
Normal 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": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
90
tests/test_plugins/test_supplies_card_blindness.py
Normal file
90
tests/test_plugins/test_supplies_card_blindness.py
Normal 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
|
||||
99
tests/test_plugins/test_usb_import_pairing.py
Normal file
99
tests/test_plugins/test_usb_import_pairing.py
Normal 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
225
tests/test_scan_sbom.py
Normal 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) == []
|
||||
164
tests/test_site_import_loader.py
Normal file
164
tests/test_site_import_loader.py
Normal 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'
|
||||
Reference in New Issue
Block a user