1fa005eb8a7aa33f4fc7f6f57052fcc51b931069
345 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
902339baf9 |
Persist and surface endpoint installed-software (SBOM)
The endpoint agent already reports an installed-software list (SBOM), but ingress dropped it: the collector schema had no entry for it and there was nowhere to store it, so operators could not see what is installed on a PC. Persist it as a per-asset snapshot in a new endpointapplications table, replaced on every report (current inventory is what operators ask for, not install history). Add applications to the collector schema and KNOWN_PAYLOAD_KEYS so ingress stops discarding it, a read route for the raw collection, and a collapsed-by-default Installed software panel to surface it. appid is nullable groundwork for a future Managed Applications link; no matcher is wired yet. |
||
|
|
7040832565 |
Add agentless adoption tool for endpoints
Standalone one-shot enrollment tool that creates a ShopDB PC + Endpoint entry using only the endpoints collector key, reusing the existing collector adopt path. Collects identity, WMI hardware, and installed-software SBOM; requires admin. Server-side packaging route attaches to the existing endpoints blueprint; no core contract change. |
||
|
|
cfb0b9ca4e |
collect installed-software SBOM from endpoint agent
The agent now reports installed applications from both Uninstall registry hives (64-bit and WOW6432Node) as {displayname, displayversion, publisher}, mirroring the Assess-Orchestrator applications collection. Registry-only, no Win32_Product/WMIC self-repair and no AppX. The server logs it as an unpersisted key until the SBOM store is built.
|
||
|
|
57845e1a9a |
log ignored agent collector keys for later UI enhancements
The endpoint agent now sends disk, bios, memory, processor, osinfo, and domain telemetry. The server does not persist these yet, so ingest logs any unpersisted payload keys with a compact preview. This makes incoming telemetry observable in the logs before any UI or schema work, so we can confirm the agent is sending it and enhance the UI later. |
||
|
|
b5fc5c01d1 |
Stay quiet when a site never asked for supply monitoring
Splitting "cannot see the cartridges" from "nothing is low" was right, but it treated two unlike cases as one. isconfigured means enabled AND a URL AND a token, so its absence means the site did not ask for supply monitoring at all - and a permanent "Supply monitoring is not configured" line is a card reporting nothing wrong every day, which is how a board teaches people to stop reading it. It matters for sites adopting ShopDB without Zabbix, who would otherwise inherit a warning about a feature they declined. Not configured now returns a plain empty and the card hides. Only configured and unanswerable reports itself, because that is something meant to work that is not working. Also withdraws an unfounded inference. A dev box has no route to Zabbix by design, so "not reachable" there is the expected state and says nothing about what production can see. The value of this change is not that it revealed a production fault; it is that when production's Zabbix does go down, the board will say so instead of displaying "Low Supplies 0". Four tests pin the three cases apart: unreachable reports, unconfigured stays quiet, and a real zero from a reachable Zabbix is not flagged as blindness. The fourth holds the envelope's rows, since carrying a reason changed the response shape. Verified they are not vacuous by restoring the original branch in place - the unreachable test fails against it. Full suite 2182 passed. |
||
|
|
b80bd8731f |
Filter the queue by check, and say when a check cannot answer
TWO CONTROLS, ONE BOARD Severity answers "how bad". It could not answer "what kind", and on a fleet where eight endpoints go quiet at once, being able to set those aside and look at the four crashes is the difference between a board and a list. Chips per contributing check, coloured by its severity, composing with the severity rollup above them. Both narrow what is already loaded rather than refetching, so no combination can disagree with the counts. Picking a severity that excludes the active chip clears the chip: two controls lit above an empty queue, neither obviously wrong, is worse than losing the narrower one. A chip counts every row, not the rows the queue had room for. A chip reading 5 above a "3 more" line would be its own small lie. "LOW SUPPLIES 0" WAS NOT TRUE Asked what happened to toner replacement, and the answer was worse than the layout question that prompted it. _get_low_supplies_data returned the same empty result for three different situations: Zabbix not configured, Zabbix unreachable, and every cartridge full. The dashboard strip therefore displayed "Low Supplies 0 / Critical 0" while the monitoring was dead - and this instance is exactly that case, with zabbix_enabled true against a URL nothing answers on. So the strip removed in the previous commit was not information. It was reassurance, and it read identically whether the printers were fine or nobody could see them. That is the failure this codebase already names elsewhere: an empty card and a broken card must not look the same. The endpoint now distinguishes the three, and returns an envelope rather than a bare list so a card can say WHY it has no rows. cardVisible shows such a card whatever its empty rule, because "I cannot tell" is not "nothing to report". It renders as a notice line, 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. Note what this does NOT fix: the Zabbix URL on this instance is a dev placeholder. The board will now say so instead of implying the cartridges are full, but somebody still has to point it at a real server - and it is worth checking what production points at, since nothing would have shown there either. Frontend 314 passed (13 new), full suite 2178. The response shape changed from a list to an envelope; no test depended on the old shape. |
||
|
|
13c46af5ce |
Rebuild the dashboard around what needs a person
The board was 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. Warranties showed 5 of 50 behind "and 45 more"; the crash card truncated at 4; a one-row card sat beside a four-row card and left a hole. Nothing had room, which is the complaint that started this: not enough space to relay the data. And a box per source flattened priority. An enforcement failure and a warranty notice looked equally urgent because each owned a card, while the largest type on the page belonged to counts that are true every day and need nobody. WHAT THE PAGE IS NOW A severity rollup, one worst-first queue of everything that needs a person, and a row of context beneath it. The queue merges every critical and warning row and sorts them, so reading order is work order. It is full width, which is where the room came from. Rows are capped per source, because fifty expiring warranties must not bury two crashed PCs, and what did not fit is named per source with a link to it - a bare "and 45 more" that leads nowhere is worse than saying nothing. The rollup counts ROWS, not cards, and filters the board in place rather than navigating away, so the counts and the rows cannot disagree. Severity, position and permission were already declared per card and already sorted on. The old layout threw that away; this only stops it. WHAT WENT, AND WHY IT SHOULD NOT COME BACK Four asset tiles plus an "All assets" tile: machines + PCs + printers + network IS all assets, so the page showed a total beside its own addends at the same weight. It is now a line under a rule, where a total belongs. The printer strip. It carried the printer count a third time - the tile row and "Total Printers" were already the same number - and "Online" equalled total, so three tiles held one fact. A genuinely low cartridge already reaches the queue through the printers-supplies card, which is the moment anyone wants it. Three cards whose whole job was to display 0. Recent Devices: the largest block on the page, listing five machines that were all "In Use" with business unit "TBD". Recency is not a reason to look at something. CONTRACT 0.26.0: 'shortname' Cards may declare a short label for their rows in the merged queue. Their 'title' is written as a heading - "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. CSS-truncating the title was tried first and was worse: four consecutive rows reading "ENDPOINTS WITH CR..." say nothing at all. Optional and defaults to the title, so a plugin that does not declare one is unaffected. Additive field, minor bump. ALSO: "silent 1 days" A meta suffix is written for the plural case and the card cannot know the value. One row saying it was a shrug; the merged queue puts every quiet endpoint on one screen, so it said it five times in a column and read as broken. Now singularised at exactly 1, on the first word, keeping what follows: "1 event (48h)", "silent 1 day". The test asserting ' ms' survives is what caught the first version turning it into ' m'. DashboardRow.vue is extracted because the queue and the context panels render the same row, and the map tooltip, link fallbacks and chips are too easy to get subtly different in a second copy. Verified on screen against live data. Full suite 2178 passed, frontend 301. |
||
|
|
78017eee2a |
Put the dual-bay sibling in the triage band, and fix the gage counterpart
TWO STRIPS OF CHROME SAYING THE SAME KIND OF THING
The machine page opened with a banner - "Dual-bay machine - sibling bay: 2022" -
directly above a hero whose band said "Controlled by". Both answer "what am I
looking at", and splitting them across a banner and a band cost a strip of
vertical space to say so.
The sibling is now a chip in the band beside the controlling PC. The band takes
an `extraitems` prop rather than learning what dualpath is: the pairing is
resolved by the machines plugin, not from the relationships endpoint, so core
cannot derive it, and a dualpath-shaped prop on a core component would be the
machines plugin leaking upward. A page passes [{label, links:[{text, to}]}] and
the band renders it with its own markup, which also keeps the chips visually
identical to the ones it derives itself.
The label stays "Dual-bay sibling" rather than "Dualpath". The banner spelled
the meaning out because a dualpath pair is ONE physical machine in two bays; a
chip reading "Dualpath" would have dropped that.
THE GAGE PAGE FILTERED FOR A COUNTERPART THAT CANNOT EXIST
MeasuringToolDetail was wired counterparttype="machine" labelled "Used on".
Wrong on both counts. The import synthesizes one measuring-tool asset per
metrology PC and a 'controls' edge PC -> tool (stage_metrology), so a gage's
counterpart is a COMPUTER, exactly as on the machine page. Filtering for a
machine peer meant the band rendered nothing on every gage that has a PC.
It looked merely empty rather than wrong because this dev database holds one
measuring tool with no relationships at all, so there was nothing to be missing.
A hand-held gage genuinely has no such edge and correctly shows nothing; a
metrology station now shows the PC that drives it.
The relationships card stays. It is the only surface with Add Relationship and
the per-row delete, and it lists types the band filters out - the band is
triage, the card is management. It does now repeat what the band shows, which
is worth revisiting separately.
Verified on screen. Frontend 280 passed.
|
||
|
|
c22c81afa5 |
Show the controls link from both ends, and fix the dead chip links
THE RECIPROCAL LINKS BUG A PC's page showed "Serves machine 7803" at the top. The machine's page showed nothing equivalent, though the same edge was right there in the relationships card at the bottom. That asymmetry read as missing data and was reported as such. It was neither missing data nor an API fault. The endpoint returns both directions and always did; AssetTriageBand already walked outgoing AND incoming and deduped, with a comment explaining that production holds reciprocal rows. It then discarded every peer whose asset type was not 'machine', and hardcoded the label "Serves machine". So the component could only ever work from the PC side, and it was mounted only on the PC page. The counterpart type and label are now props, defaulting to the old values so the PC page is unchanged. The machine page mounts the band with counterparttype="computer" and the label "Controlled by". Eliminated with evidence rather than assumed, because this bug had already been misdiagnosed twice: the endpoint runs two queries and returns both arrays; the generic card renders both; that route has no pagination to clamp; and a single permission gate cannot admit one direction and not the other. THE RELATIONSHIP TYPE WAS ALSO HARDCODED Not every asset pair is linked by 'controls'. A machine reaches a printer through usesprinter and defaultprinter, so the band was blank on the printer page - the one page where the link is not obvious from the asset itself. The types are now a prop, and the printer page passes its own. THE CHIPS WERE DEAD LINKS Found while adopting the band more widely. It linked to /assets/<assetid>. There is no /assets/:id route and no catch-all, so every chip in the band was dead - and this change would have propagated that to four more pages. AssetRelationships.vue already resolved this correctly and privately. That function is now frontend/src/utils/assetRoute.js and both components use it, so a third caller cannot get it wrong again. Two things it gets right that are easy to miss: the detail routes key on pluginid, not assetid, so passing assetid opens the wrong record; and the lookup is now case-insensitive, because csvimport.py seeds 'Computer' and 'Machine' capitalised while this site's database holds them lowercase - a case-sensitive match produced dead links at whichever site seeded the other way. The band now carries pluginid and assettypename on each chip, which the resolver needs. TESTS assetRoute had none and neither did the band. Seven cover the resolver, including the assetid-vs-pluginid confusion and the seeding-case difference. On the backend, nothing asserted that the TARGET of a directional edge receives it in `incoming` - only that rows carry isdirectional. That missing assertion is this bug's exact shape, so it is now pinned: the endpoint was right all along, and the next such report should not cost anyone a re-diagnosis of the API. Verified on screen: the machine page shows "Controlled by G6K76CW3ESF" in the hero, the PC page is unchanged, and a page with no such link renders no empty band. Full suite 2162 passed, frontend 280 passed. |
||
|
|
d4105c2289 |
Say what a service state actually is, and make the badges readable
Three colour faults on the PC page, each hiding the next. ACTIVITY BADGES WERE ALL GREY. The list renderer reads a badge colour from a ROW FIELD, and the activity panel declared none, so every severity rendered identically. The feed's whole job is to make a critical event visible and it was painting it the same as a logon. The route now emits severitycolor - info grey, warning yellow, critical red - and the panel declares it. PAUSED AND THE TRANSITIONAL STATES WERE RED. statecolor was green-or-red-or- grey, so a paused service looked like a stopped one. They are different: a paused service was paused deliberately, and one stuck in Start Pending is failing to start on a retry cycle - which is exactly what raises event 7011, the thing behind the critical counts on the Fleet page. Both are amber now. NAMED COLOURS ALWAYS GOT WHITE TEXT. readableText only parsed hex and returned white for anything else, but panels emit NAMES. Green, red and grey are dark enough that white worked by luck; amber is not, so the fix above shipped as white-on-yellow and could not be read. Names resolve to hex before the contrast test, so this holds for any named colour anywhere in the app rather than these two panels. colorStyle had no test file at all. It has one now. Every one of these was found by looking at the rendered page. None of them would fail a test that checked markup. Full suite 2131 passed, vitest 273 passed. |
||
|
|
60c35728e8 |
Import USB records off an external cmmc_usb database, so a site can retire it
A site running external mode keeps its devices and check-in/out history in a separate cmmc_usb database. There was no way to bring that in, so the choice was to keep the second database forever or lose the history. This adds 'flask usb import-external' - dry run by default, --commit to write - which reads through the same connection settings the routes already use and never writes to the source. Two things it has to reconcile, and they are why it is a command rather than a few INSERTs. The schemas disagree about SHAPE. External is an event log, one row per action; self-hosted is a paired row carrying both times. Pairing walks each device in time order, and real logs after years of use are untidy. The rule is that nothing is invented and nothing vanishes: a drive still out becomes an open checkout and is NOT flagged (that is just a drive somebody has), a return with no matching issue is skipped and named, and two issues in a row leave the first open rather than inventing a return time nobody recorded. Identity is the other. usbdevices.serialnumber is unique and NOT NULL and the external devices table has no serial, so device_id becomes it. Blank or duplicated ids stop that device rather than inventing a key. external device_owner has no column here, so it is appended to the device notes rather than dropped. Also adds usbcheckouts.scannedviruses, the one field with nowhere to land. waswiped already means what external calls sanitized - selfhosted.py was mapping it - but scanned_viruses was HARDCODED None in the log output: the API promised the field and had nothing behind it, so an imported history would have rendered blank. It now reads and writes properly. Nullable, never defaulted to false. An unrecorded scan is not a failed scan, and for removable-media records those are very different claims. Full suite 2131 passed; seven new tests pin the pairing rules. |
||
|
|
99c40ae7c3 |
Say WHICH critical event, and stop the feed hiding it
Reported from production: health showed '418 critical' while the ten most recent events were all info. Both were true, which is the problem. The health rollup counts critical events in a 48h window; the activity feed shows the fifteen most recent events of ANY severity. A box that logged 418 service timeouts overnight and ordinary logons since produces exactly that pair - a page contradicting itself with no way to tell why. Two fixes. The rollup now names the DOMINANT event instead of totalling: 'Service timeout x418' rather than '418 critical'. Counting by event id costs nothing (one more GROUP BY column) and turns a number into a lead. This is the same reasoning that made health a phrase rather than a score; it stopped one step short. The activity feed now fetches the newest criticals separately and merges them, and keeps up to five of them when trimming to the cap. They hold their place in time but survive the cut, so what health is reporting always appears in the feed that exists to explain it. Verified against the reported shape: 20 service timeouts two hours old under 12 newer logons. Before, the feed was fifteen logons; now it carries five critical rows and health reads 'Service timeout x20'. Worth noting the underlying signal is real - several hundred criticals on one endpoint in 48h is a box crash-looping or a service failing on a retry cycle. The display was hiding it, not inventing it. Full suite 2124 passed, naming and parity gates green. |
||
|
|
b1b9cdda72 |
Add bulk actions to the Fleet list, with a confirmation that names machines
Select endpoints, then Collect now or Restart across the selection. Restart is
gated on endpoints.restart, both are re-checked server-side.
The confirmation names the MACHINES, not just the hostnames:
Restart 2 computer(s)?
GJX9B2Z3ESF, HOST-7
This STOPS the machines these PCs serve:
DT Office
A dialog listing eight hostnames tells an operator nothing about what they are
about to halt, and that is the whole consequence of the action on a shop floor.
Where no machine is recorded it says so, because 'none listed' and 'we did not
check' are different things at 6am. Only possible now the Machine column exists.
A 409 is reported as a SKIP, not a failure. The queue permits one command in
flight per endpoint, so a bulk action across eight PCs where two are already
busy says '6 queued' and '2 skipped - a command is already in flight'. Folding
those into an error count would make a working button look broken.
Selections clear on every reload: the rows they referred to may not be on the
new page, and acting on an invisible selection is how bulk tools surprise
people.
No per-row buttons. They were built first, and a tenth column on a nowrap table
pushed itself off the edge - selecting one row costs a single click and leaves
one command path instead of two.
Full suite 2124 passed, vitest 260 passed, naming gate green.
|
||
|
|
057cee1f02 |
Match the Fleet tile radius to the global card
The tiles were 6px while .card is 0.25rem, which left Fleet marginally rounder than every other page. The global card treatment is a separate decision and has been deferred; until it is made, this page should look like the rest of the app rather than half-way to a mockup. |
||
|
|
0baed4cc80 |
Close the gaps against the design: critical tile, filters, policy card, action placement
Four things the mockup showed and the first cut did not have.
FLEET
- A 'with critical events' tile, from the stability route that existed and
nothing called. Clicking it narrows to rows whose health is not ok - client
side, because the health rollup is assembled per row after pagination, so
it narrows the PAGE rather than the fleet. Worth knowing before the count
is read as fleet-wide.
- PC type and Location filters. The list route already accepted both; there
was simply no control. Both use listAll, not list: perpage is clamped to
100 server-side, so a long location list would lose its tail in a dropdown
while still looking complete.
- The machine cell shows number and name together where they differ, so
'3105 Okuma' rather than a bare number.
PC DETAIL
- A Policy card, contributed by the computers plugin. It matters because the
effective policy already narrows the command allowlist, so without it the
reason a control is unavailable on a given PC is written down nowhere.
Provenance, so it sits in the reference column, not the triage band.
Its route returns an EMPTY field list rather than the feature-gate 404 when
policies are disabled. The panel contract hides a card with no fields, so
it disappears on sites that have not enabled the feature - a 404 would have
rendered a broken card on most of them. That was flagged in the design
review as a snag; this is the fix.
- Refresh Status and Restart Computer moved out of the page header into the
triage band. They act on the MACHINE; Print Label, Edit, Retire and Back to
List act on the RECORD, and mixing them put the most consequential button
on the page - it halts whatever machine the PC serves - between printing a
sticker and editing a form. In the band it sits beside the liveness chip
and the machine it would take down.
The band takes them through an 'actions' slot rather than knowing about PC
verbs, so a measuring tool can put 'Calibrate' there without the core
component learning anything about computers.
Full suite 2124 passed, vitest 260 passed, gates green, spec at 333 paths.
|
||
|
|
414a7243d7 |
Merge events and commands into one Activity feed, and lead with live state
Two changes to the PC page, both about what a responder meets first. LIVE STATE FIRST. The contributed panels moved to the head of the card flow. The grid is a two-column FLOW (column-count, with display:contents on the column wrappers), so a card cannot be pinned to a side and only document order decides where it lands - putting the panels first fills the left column with endpoint status, services, processes, interfaces and activity, and leaves identity, hardware, network and location to the right. Panels still self-order among themselves by declared position and still self-hide when no plugin answers for the asset type. ONE ACTIVITY FEED replaces the Recent events card. Events and commands answer the same question - what happened to this box - and reading them as two lists meant interleaving by eye. GET /api/endpoints/asset/<id>/activity merges them newest-first, capped at 15. Both are keyed by assetid, so it is two indexed reads and a merge sort rather than a join, and it renders through the existing ADR-010 list renderer with no bespoke frontend work. A failed command is marked critical, because that is the row somebody needs to notice. Not collapsed by default, unlike the card it replaces. The core AuditLog is deliberately NOT a third source: its rows carry the action as the entity name and the asset only inside a details string, so including it would mean parsing prose to find out which machine a row belongs to. Requester names are withheld from callers who cannot themselves issue commands, via caller_holds. A read-only viewer - a guest, on a site that grants this panel - gets the history without a roster of who restarted what, and the lookup is skipped entirely rather than fetched and discarded. requestedby holds a user ID, so names resolve through one batched query, not one per row. Verified on dev telemetry: a queued command shows as 'Collect now / Pending by admin' above the Windows events, ordered by time. Full suite 2124 passed, vitest 260 passed, naming/contract/parity gates green, spec regenerated at 332 paths. |
||
|
|
0f9ffdb2f1 |
Put the 6am answers at the top of the PC page, and stop saying things twice
Adds a shared triage band to the PC detail hero: is it alive, what stops
without it, how do I reach it.
'Serves machine' is the reason it exists. On a shop floor a PC matters because
a machine stops without it, and that fact was buried in the generic
relationships card at the bottom of the page. Both edge directions are read,
deduplicated, and filtered to assets typed 'machine' - production holds
reciprocal rows, so reading one direction shows the link on half the pages and
not the other half.
The band is a CORE component, not a PCDetail block. Five asset detail pages
already rebuild the same hero and grid independently, which is how they drifted
apart; machines, printers and network devices can drop this in rather than
growing a sixth copy. Liveness is passed in rather than fetched, because only a
plugin that collects telemetry can answer it and a site may not have one.
The status chip's verdict comes from the SERVER: /api/endpoints/<id> now
returns the same reporting/quiet/never it computes for the fleet list. The
quiet window is a site setting, so a threshold guessed in the browser would
disagree with the Fleet list about the same endpoint the moment a site changed
it.
Two cards removed from the body:
- Remote Access duplicated the band's chips. It is what a responder clicks
first, so it belongs above the fold, once.
- Status was worse than duplication. It reads the COMPUTER RECORD, while the
endpoint panel beside it reads live agent telemetry, and on an
agent-managed PC the record fields sit empty - so the page rendered 'Last
Reported: Never' a few inches from live telemetry saying otherwise. It now
renders only when that record actually holds something, which is the case
on sites running the asset-report script instead of the agent.
Verified against dev telemetry: band shows 'Reporting, 1 h ago', the machine it
serves, and its access chips; both duplicate cards are gone.
vitest 260 passed, naming/contract/parity gates green.
|
||
|
|
4c43c4e1ba |
Fix the Fleet tile and health colours in dark mode
Two colour bugs on the same page, both invisible in light mode. A button does not inherit color the way it inherits font, so the plain Endpoints total rendered in the browser default black on the dark theme's card. The coloured tiles set their own color, which is why only that one showed it. The coloured ones were wrong too, just less obviously: dark mode redefines --success and --warning to bright variants but leaves --success-dark and --warning-dark tuned for a white background, so those numbers and the health pills came out dark green and dark amber on a dark blue card. Scoped to the stamped [data-theme="dark"] rather than a prefers-color-scheme query, matching the note in style.css: the theme store always stamps the attribute, and a bare media query leaks dark styling into light mode on a dark-OS machine. |
||
|
|
54f0857e13 |
Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
Twelve endpoints routes shared one permission, which made a reasonable policy impossible to express. A site that wants signed-out visitors to see live status on a PC page had to hand them the entire fleet - including every logged-in username on the floor - because the per-asset panels and the fleet-wide list were gated on the same string. endpoints.fleet now gates the list and the three dashboard routes. endpoints.view keeps the per-asset reads: status, services, processes, interfaces, events, command history. The name is load-bearing. guest_grantable_permissions() opens any '.view' or '.read' permission to the guest role, so calling this one 'endpoints.fleet' rather than 'endpoints.fleetview' is what keeps the roster off the public surface while per-asset telemetry stays grantable. Verified both ways. Also: the services panel emitted Start/Stop row actions to every reader, because row_actions_for() never consulted the caller. Clicking was always refused server-side, so nothing was exploitable, but showing a read-only viewer controls over a shop-floor PC is the wrong signal - and it stops being cosmetic the moment a site grants that panel to guests. That needed a contract addition rather than a local check: caller_holds() on shopdb.api answers whether THIS caller holds a permission without refusing the request, which is the complement to require_permission for a surface that serves everyone but should only offer what the caller can act on. Guests are included, since a signed-out visitor holds whatever the guest role was granted. Reaching into shopdb.utils.authz instead would repeat the ADR-002 violation the contract gate caught earlier in this branch. Contract 0.24.0 -> 0.25.0. DEPLOYMENT: endpoints.fleet is a new permission. Run 'flask seed permissions' and grant it to the roles that should keep seeing the Fleet page. Until then that page is admin-only, since admins bypass every check - a safe direction, but not an automatic one. Full suite 2124 passed, naming and contract gates green, map and spec regenerated. |
||
|
|
4ef22b6579 |
Make the Fleet list answer the question it exists for
The fleet list showed hostname, user, uptime, CPU, memory and last-reported,
sorted by last-reported descending. Two of those columns have never held a
value, and that sort floats the healthiest endpoints to the top and buries the
ones somebody needs to act on.
What a shop-floor fleet view is actually for is which MACHINE stops if a PC is
dead, so that is now a column. Both edge directions are checked, deduplicated,
and constrained to assets typed 'machine' - without that last filter the column
filled up with office locations that happen to carry a controls edge.
Also here:
- Sorted WORST FIRST: never reported, then quiet, then reporting. Expressed
as a sortable SQL rank rather than a label applied after the fact, so the
order survives pagination.
- Health rollup as a PHRASE, not a score: 'eDNC stopped', '2 critical'. A
percentage does not tell an operator what to do.
- Filters for status, PC type, location and business unit; a sort parameter
(the route accepted none); and search widened from hostname alone to asset
number and the machine number a PC serves, which is how people actually
look a bay up.
- CPU and Memory columns dropped. The agent declares those fields and has
never assigned them, so they rendered '-' on every row.
- A summary strip wired to the dashboard routes, which existed and which
nothing called.
Two bugs found while building it:
The health rollup first read a 'running' boolean, but service rows carry a
STATE STRING and the vocabulary varies by source, so every service would have
reported as stopped. The panel and the rollup now share one _service_running()
helper; two copies of that rule would drift.
The summary counted quiet as total - reporting, which folded never-reported
endpoints in with quiet ones. Those are different failures - an enrolment or
key problem versus a machine that went down - so 'never' is counted separately.
Per-page batching for machines and critical-event counts: three queries per
page rather than two per row.
Full suite 2124 passed, vitest 260 passed, naming and contract gates green.
|
||
|
|
af42ba3589 |
Register the endpoints list without a trailing slash, and stop hiding its errors
An endpoint reported telemetry correctly for hours while the Fleet page showed
"No endpoints found". The PC detail page displayed its panels the whole time, so
ingest was plainly working; only the list was empty.
The list route was the one route in 331 paths registered with a trailing slash:
@endpoints_bp.route('/', methods=['GET']) -> /api/endpoints/
The browser client calls it without one, so Flask answered a 308 redirect to an
ABSOLUTE url. On dev that resolves and nobody notices. Behind the IIS subpath it
cannot: with no ProxyFix, Werkzeug builds that Location from the scheme and host
it believes it has, so it emitted http:// and dropped the /shopdb prefix. A
browser will not follow an https page to http, the request failed, and the
page's catch block rendered the empty state.
Every other route in the plugin worked throughout, because none of them carries
a trailing slash - the detail panels call /endpoints/asset/<id>/status and were
never redirected. Detail works, list empty is the signature of this bug.
Registered as '' now, so the rule is /api/endpoints. That is what
CONTRIBUTING.md's API section spells and what every other list route in the
product already did. The inventory and the generated spec are updated to match.
Also here:
- tests/test_core/test_no_redirecting_routes.py fails the build if any /api
rule is registered with a trailing slash and strict slashes. Verified by
planting the violation: it named the offending route, then passed once
reverted. It takes the shared app fixture rather than building its own
application - doing that rebinds SQLAlchemy and leaves the NEXT module
without tables, which showed up as three unrelated "no such table: settings"
failures visible only in a full run.
- The Fleet page now renders "Could not load endpoints (HTTP nnn). This is a
failed request, not an empty fleet." A list that reports a 403 and an empty
fleet identically is what made this take as long as it did.
- TRUSTED_PROXY_COUNT (default 0) applies ProxyFix when set. Off by default on
purpose: it makes X-Forwarded-* trusted, and a client can send those itself,
while the login rate limit and account lockout are keyed on the client
address. Set it to 1 on an instance behind IIS.
Full suite 2124 passed, vitest 260 passed, naming and docs gates green.
|
||
|
|
2aea2bd818 |
File the Fleet nav item under Assets, beside the PCs it describes
The endpoints plugin declared a section of its own, so the sidebar rendered a one-item 'Fleet' group at the bottom, below Information, far from the PCs the view is about. Its own docstring said it belonged just after PCs, and its position (16, against PCs at 15) already said so too; only the section disagreed. Filed under assets, which puts it directly under PCs. The now-empty fleet section is removed from NAV_SECTIONS - an unused section is an invitation to file something else there. |
||
|
|
784964c907 |
Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
|
||
|
|
dd3db50460 |
Secure Send hands over a message, not a bare URL
A link on its own invites being pasted onto text that is already in the chat box, and every chat client turns the selection into the link's caption. The secret then travels as the visible message with the secure link wrapped around it, protecting nothing. This is not hypothetical. Six LAPS passwords went out that way in production - machine names and passwords as the caption, the correct /s/<token>#<key> URL underneath, doing nothing at all. Copy message puts a whole sentence on the clipboard: what the link is, how many opens it has, when it expires, and then the URL. A sentence has nowhere to attach itself - pasted into an empty box it is a message, pasted over a selection it replaces it rather than decorating it. Plain text on purpose; a rich-text flavour is exactly what would let a secret ride along as markup. The success card now says to delete the password from the chat box before pasting. The existing hint explains that the key cannot be recovered, which is true and does not stop anybody pasting the password itself. Nothing about the link was wrong and nothing about it changed: the URL carries the token, the key rides in the fragment the browser never sends, the plaintext is dropped the moment it is sealed, and redeeming stays a POST so a preview cannot spend it. |
||
|
|
5e7e39a24c |
Share a code by link instead of by instructions
Sending somebody a label meant sending a screenshot and a list of settings to retype, and they would get one of them wrong. Copy share link hands them a URL that reopens the generator with the content, the label, the copy count and every layout value that was actually changed. The whole payload rides in the fragment. A fragment is never transmitted, so it does not reach Flask, it is not in the IIS log, and the proxy between them never sees it - which matters because the content is usually an asset number or a serial, and those have no business scattered through logs. It is NOT secrecy: the link sits in a chat message and in browser history, and anyone holding it can read the content. Secure Send exists for the case where that matters, and the note under the box says so. Only values that differ from a first-time visitor's defaults are carried, so the link stays short enough to paste into a chat box; a shared link then wins over the recipient's own saved settings, because somebody following one is asking to see THAT code rather than their own last one. An unknown key is ignored rather than refused, so a link made by a newer version still opens on an older one. The picture is never carried - a data URL runs to megabytes. Values are coerced back to the type the field holds. Everything arrives from a URL as a string, and a nudge of 0 plus '2' is '02'. Proven end to end: a link made in one browser, opened in a clean context with no saved settings, rendering the same code. |
||
|
|
8953ce8210 |
Ticking a section off crosses out the section
It struck the heading and left everything underneath looking untouched, which is the opposite of what somebody ticking "this section is done" means. The strike now covers every block under that heading, down to the next heading of the same or higher rank - so a top-level section takes its sub-sections with it. That needed the rank, and the HTML importer was throwing it away: h2, h3 and h4 all arrived as a flat 'heading', so a parent heading had no children to cross out. It was the visible half of a real loss - a converted article kept its words and lost its shape. Headings now carry their level and render at it, and a guide written before this reads as flat, exactly as it did. Code blocks, tables and images inside a finished section fade but are not struck. A line through a command makes it unreadable, and re-reading the command is usually the reason to look back at a step already done. The five imported guides were re-imported so they carry their structure. |
||
|
|
03a1151a5a |
Tech Tools is reachable by the people it was built for
Every tool on that page is usable without an account, on purpose - the routes carry no requiresAuth and say so - and Secure Send is the one write this product offers a guest. The sidebar entry still never appeared for them: it named no permission, so the filter that decides what a signed-out visitor may see had nothing to go on and dropped it. The plugin had no read permission to name, only tools.secrets, so it gains tools.view: reaching the page at all. The tools themselves run in the browser and touch no data, which is why this is about whether the entry appears rather than about what anybody can read. The item names either right. Somebody granted only Secure Send needs the entry that leads to it, and a manager who holds neither still does not see it. |
||
|
|
4e59fe30b2 |
A nav item says which permission its page needs
The sidebar hides what the caller cannot reach - that is what makes it useful to a guest, and what stops a member with a sparse role being offered links that can only answer 403. It reads the permission off the nav item to decide. Only four items ever declared one. Machines, PCs, Printers, Network, USB, Knowledge Base, Notifications, Calendar, Dashboard, Map, Applications and Reports declared nothing, so the filter had nothing to go on: granting the guest role machines.view opened the API and left the sidebar empty, which reads as the grant not working. Every item now names its read permission - in the plugins, in the core navigation endpoint, and in the frontend's fallback list, which is what renders when the endpoint is unreachable and would otherwise disagree with it. |
||
|
|
afce24379c |
Reading anything needs a permission
A sweep of the URL map found 165 GET routes that answered anybody, with no token at all: the whole asset inventory, every machine, PC, printer and network device, the USB checkout history and who is holding which drive, the staff directory with its photographs, every core report. Guest access had been argued about one feature-flag at a time while the front door stood open. Each of those now carries the read permission it belongs to. That also makes the guest role mean something: a site can open exactly the reads it chooses, and nothing follows by accident. Thirty-three endpoints stay public, and each is named with its reason rather than left to be inferred: the app shell and its bootstrap, the settings allowlist (the handler already filters an anonymous caller down to branding and a few keys), images fetched by <img src> which carries no Authorization header, what a display kiosk renders before anybody signs in, the parts and USB kiosks, the client scripts that run as SYSTEM before a user session exists, and a secure-send link whose unguessable token IS the authorisation. tests/test_core/test_public_read_surface.py is the standing gate, the companion to the mutating-route sweep that has guarded writes for months. A new public read fails CI until somebody adds it to that list in front of a reviewer. Four further reads need a login but no permission, because the handler decides from who you are - your own user record, your own API tokens, the role catalogue the grid renders - and the same test proves each still refuses an anonymous caller. Verified not vacuous by ungating one route and watching the sweep name it. The employees plugin gained employees.view; the directory is names, SSOs, photographs and reporting lines, and it had no permission of any kind. Around ninety tests asserted payload shapes against endpoints that answered anybody. They are about what the answer says, not about who may ask, so they are signed in now rather than the gate being loosened. The ones that assert an anonymous caller is refused were left exactly as they were. |
||
|
|
2331c03664 |
Tick a guide's sections off as you work through it
Only checklist items could be ticked, which answers "what have I done" but not the question a long procedure actually raises: where was I. Somebody looks away to do the thing the guide asked for and looks back at a wall of text. Every step and every heading now carries a checkbox while reading. Ticking one strikes it through and fades it rather than hiding it, because somebody scanning back up the page has to still be able to read what they did. The number is struck through with it, but the step's own "Step 3" label is not - that is the address of the thing, not part of what was done. Headings count, not just steps. A guide converted from an HTML article often has fifty headings and no steps at all, and those are exactly the guides long enough to lose your place in. That also means the card's "N steps to tick" stops reading as a broken feature on a guide that is entirely tickable. Reading only, as the checklists already were: in the editor a tick would be an edit, and an author clicking around their own draft would quietly change what the next reader sees as done. Progress stays per browser and per version - shopfloor PCs share a login, so one technician's ticks must not greet the next person on that bay, and step 4 of version 3 is not step 4 of version 2. |
||
|
|
151d7c4f44 |
Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point. |
||
|
|
8531559f22 |
A guide can be carried to another site
Guides are written on one site and needed on all of them, and the sites that matter are air-gapped from wherever the writing happens. Until now a procedure was stranded in the database it was typed into: the HTML importer could rebuild one from its original article, but anything authored in the editor had nowhere to go. flask guides export writes one .guide.json per guide, with every image the blocks reference carried inside it as base64. One file on purpose - a bundle that needs its images copied alongside it is a bundle that will eventually arrive without them. flask guides import reads them back, a directory at a time, and refuses anything that is not a bundle before it writes a row rather than half-importing a stray .json sitting in the same folder. What travels is the content: title, summary, tags, blocks, images. What does not is ids, revision history, authorship and publication state, because those belong to whichever database issued them - an imported guide starts as a draft at revision 1, owned by whoever ran the import. The SLUG does travel: it is the address somebody pastes into a work order, and the same guide should answer to the same name on every site. --replace updates the guide already carrying that slug instead of creating a second one, which is how a corrected guide is re-shipped. It drops the stored images first: the incoming bundle is the whole truth for that guide, and a guide re-shipped monthly would otherwise accumulate orphans nothing points at. Bundles refuse a version they are too old to read rather than importing the parts they recognise. A newer site's guide is not a guide this one can be trusted to render. Proven by round-tripping a five-guide export back into the same site, including one carrying two PNG screenshots, and reading it in a browser. |
||
|
|
c8efadefba |
An HTML article can become a guide
A site adopting this app arrives holding procedures already written: styled HTML pages that get mailed around, opened off a share, and edited by whoever still has the file. Those pages are exactly what a guide is for, and retyping one into the editor is how a good procedure stays on a share forever. flask guides import-html reads the page and produces blocks. It recognises the vocabulary those pages actually use - a step box with its own number, an alert in one of three colours, a code block with a copy button, a checkbox list, a table with a header row - and maps each to the block that already renders it, lifting embedded images into guideimages so a document stops carrying its screenshots as base64. Page furniture goes: the header, the footer, and the table of contents a guide builds for itself from its own headings. The conversion is lossy on purpose. Colour, fonts, width and nested layout are dropped, because storing blocks rather than markup is what makes dark mode, printing and tickable steps work at all. So an import is always a DRAFT and the default is a dry run that reports what it would produce and writes nothing. Blocks go through the same validator the API uses: an import must not be able to store something the editor could not have produced. The parser is tolerant, because real articles are not well-formed. An unclosed <p> is normal HTML and the browser closes it at the next block element; without the same rule one unclosed tag swallows the rest of the article into a single paragraph, which is what it did before the test caught it. Proven end to end against four real articles, including one carrying two PNG screenshots, read back in a browser. |
||
|
|
de3ca70592 |
Every report offers to email itself, and a long drive name stays in its card
POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.
The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.
Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
|
||
|
|
605dd6cb1b |
Kiosk: find a free drive by typing, and use the width of the panel
A cabinet of thirty drives was one long column to read through. The free list now filters from a search box and lays out in as many columns as the panel is wide - four on a wall display, two on a tablet, one on a phone - while the steps that hold a single control stay narrow, because stretching one input across a 24 inch screen reads as a broken page. A drive is found by its id, by what is written on it, or by where it lives. The words can arrive in any order, and "locker" is part of what is searched, so "locker engineer" finds the engineering locker - that is how people say it out loud. Filtering is client-side: every free drive is already on the screen, and a round trip per keystroke on a wall panel would be worse than no filter at all. The matcher lives in its own module with tests rather than inside the view. The box takes focus as the picker opens so somebody can simply start typing, and it is cleared when the kiosk resets - the next person at the cabinet must not inherit a filter and conclude there are no drives left. Also: both this view and the guides blocks coloured muted text with a `--textmuted` variable this app has never defined. The browser dropped the declaration, so every hint, subtitle and locker line was rendering at full strength. The variable is `--text-light`. |
||
|
|
f59aca5a1b |
USB check-in / check-out audit, as a report rather than a paged list
CMMC removable-media tracking is an audit obligation, and answering "who had this drive, and when" meant paging through the check-in/out list by hand. GET /api/usb/reports/audit returns the events - a take and a return are separate rows, because a count cannot answer the question an audit actually asks - with summary figures riding along for the people who only want the shape of it, and a CSV for the people who want it in a spreadsheet. The window is named rather than a day count: today, last month, six months, a year, all time. Nobody should have to work out that a year is 365. Both directory modes answer in one voice. The external path reads the cmmc_usb checkinoutlog; self-hosted expands one usbcheckouts row into its two ends, so a returned drive is distinguishable from one still out, and the window filter runs in SQL against either end of the loan - a drive taken before the window and returned inside it is a check-in the window has to show. Rows come back from the mode-specific half; the CSV, the summary and the response shape are built once in the route. Ordering is total in both modes. Timestamp alone ties for two events in the same second, and the same query then returns them in a different order each run, which reads as the log being unstable. |
||
|
|
add310e4e9 |
USB kiosk: drive lists in two columns, in the order the labels read
The kiosk was a single column of full-width rows top-aligned on the page, so a cabinet of a dozen drives meant scrolling a wall panel, and the page itself scrolled - which takes the "Your drives" heading off screen and leaves somebody looking at a list with no idea whose drives they are. The lists now sit in a grid that fills the width and scroll INSIDE themselves against a bounded height, with the panel centred as one block the way the parts kiosk beside it already is. Two kiosks on the same floor that sit differently on their screens read as two different products. Drive ids sorted as text, which puts USB-10 ahead of USB-3. Compared with a numeric collator so the list runs in the order the labels on the drives read. |
||
|
|
37a82c2650 |
Somewhere to actually turn the USB kiosk on
The kiosk page tells an administrator to enable it in Settings under USB. That place did not exist - the setting was reachable only through the API or a shell, which is the same trap the backups plugin shipped with. A settings page and a catalogue card, so the instruction leads somewhere. |
||
|
|
21e66c6749 |
Network devices: manage buttons where every other asset keeps them
Machines, PCs, printers and measuring tools all carry Print Label / Edit / Retire / Back to List in a page header. A network device had no header at all: Edit was tucked inside the hero title row, and Print Label and Delete sat in a bar at the very bottom of the page, past every card. Managing one meant looking somewhere different from every other asset type. They move to the standard header. The bottom bar is removed rather than left as a second copy, and the two delete handlers collapse into one - the route is a soft delete, so it is called Retire here as it is everywhere else, and the confirm says what is kept. |
||
|
|
933740aae4 |
Guide editor: real arrows, drag to reorder, add a section where it goes
Four things people kept tripping over. The move controls were a literal ^ and v character. They are arrows now. Sections can be dragged. The handle is a grip in the block bar, not the block itself: making the whole container draggable swallows text selection inside its own textarea, so you could no longer select a word to bold it. The type palette lived once, at the bottom of the entire guide. Adding a step in the middle meant adding it at the end and walking it up one press at a time. There is now an insert point in every gap - and at the end, so there is one way to add a section rather than two. Linking to another guide or a KB article meant knowing its slug or id by heart. A picker searches both by title and writes the markdown link at the cursor. It offers published guides only - never send a reader to a draft - and it stores an in-app path rather than an absolute URL, so the link survives the site being reached by another hostname or mounted under a subpath. The knowledge base is a separate plugin that a site may not have installed; its absence costs nothing but its results. |
||
|
|
2ad2bd1b90 |
Sidebar sections are DECLARED, not guessed from a position number
The section headings were derived from position ranges: 10-29 became Assets, 30-49 became Information. A number is for ORDER; it was never a statement about meaning, and using it as one meant a plugin landed in whichever bucket its number happened to fall in. That is how USB Devices - an asset - came to sit under Information beside GE-Enforce and Tech Tools, and 3D Parts with it. Nobody chose that. It also meant two plugins picking the same number silently reordered each other: Warranties and Guides both held 8, the tie broke on plugin load order, and Guides rendered under Warranties rather than under Slides where it was asked to go. Warranties moves to 9. Each nav item now names its section and the renderer groups by what was declared. An item with no section stays in the unlabelled group at the top, which is the safe default: it appears, it is simply not filed. Assets gains USB Devices and 3D Parts. GE-Enforce and Tech Tools move to a new Fleet section - they are neither assets nor reference material, and calling them Information was only ever an artefact of their position numbers. Also: sorting used `position || 99`, and 0 is falsy in JavaScript, so Dashboard - the only item that uses 0 - sorted to the end of its own group. |
||
|
|
7c47ae1f7c |
USB kiosk: take and return a drive without a ShopDB login
The people who actually take USB drives are standing at the cabinet, and most of them have no account here. Check-out and check-in were gated behind a login that was not doing much anyway: both modes already attribute the action to a scanned BADGE rather than to the signed-in user, so the login was gating the screen, not the record. /usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless a site sets usb_kiosk_enabled - every route 404s until then, rather than 403, so a disabled kiosk does not advertise that there is a door here. It works against the self-hosted tables or an existing cmmc_usb database. THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk there is nothing to present and the screen has to offer a list - which exposes more than a scan-first design, where you had to already hold a drive to learn anything. So the list is not anonymous: an SSO comes first and must resolve to a real person before any drive is named, which is the same bar taking a drive already had to clear. It is a POST so the badge stays out of proxy access logs. The main screen shows the caller's own drives, what is free, and a COUNT of everything else; holders of other people's drives appear only behind a second deliberate tap, so nobody reads the whole cabinet off a wall panel in passing. A badge that resolves to no employee is REFUSED, unlike the signed-in route which creates the user row. A checkout nobody can trace to a person defeats the tracking, and at a kiosk there is no operator standing there to vouch for it. Returning somebody else's drive asks first and then records the badge that was SCANNED, so the log says who actually handed it back. That is the honest record and it needs no change to a schema this app does not own. Refusing a mismatch outright is a setting but not the default: a drive left behind by somebody on leave would otherwise be unreturnable at the kiosk, which just moves the problem to a person with a login. The authz sweep caught these writes, twice - once for the two mutations and again for the two list endpoints. Both are exempted with the reasoning written out beside the parts-kiosk entry rather than silenced. The list endpoints are POSTs that change nothing. Verified by driving the real page: SSO on the keypad, tap Take, database moves. |
||
|
|
a9289ee670 |
printedparts 0003 was an unguarded ALTER, and no test could see it
Unlike its twenty siblings it ran a bare add_column plus create_index, so `flask plugin upgrade-all` failed with "duplicate column name: gagelabtag" on any database whose tables were built by create_all and stamped nowhere - and a chain stops at its first error, so 0004 never applied either. The index is guarded by COLUMN SET rather than by name: create_all names indexes by SQLAlchemy's convention and the migration by this repo's, so a name-keyed guard would have added a second index over the same column instead of skipping. The test that should have caught it was restricted to plugins whose whole chain is a single baseline, on the reasoning that a longer chain meets a second problem - an ALTER whose column is already present - that guarding a create_table does not fix. True, but the conclusion was wrong: the answer is to guard the ALTERs too, not to stop testing the chains that have them. It now covers every post-cutover chain, and was confirmed to fail against the unguarded migration before the guard went in. |
||
|
|
dffa2082be |
Backups: stop storing credentials, and fix a dedup chain that never matched
The NTLARS/DNC registry carries an FTP password in cleartext and it was stored verbatim. That put it in the backuprevisions table, on the revision panel which renders values, and in the download route which hands back a ready-to-import .reg - so anyone holding backups.view could read it off a web page. Redaction happens at PARSE, so the secret never reaches the database at all. Redacting on display would have left it at rest and one query away. What is deliberately NOT done is dropping the value silently. This codec already refuses to discard an unparseable value, precisely because a backup that looks complete and restores a machine with a setting missing is the worst outcome. A redacted value keeps its name, is listed in the projection, and renders as a COMMENT naming what must be set by hand. Emitting a placeholder as the password would produce a machine that looks restored and cannot connect, and the technician would go debugging the network. scripts/redact_backup_secrets.py scrubs revisions recorded before this, dry run by default. It leaves contenthash alone: the hash identifies the configuration a bay reported, so rewriting it would make every bay's next post look like a change and insert a duplicate revision across the fleet. Separately, sourcehostname is now normalized at ingest. samesource() maps '' to IS NULL for the query while the insert stored the raw '', so rows written by a client posting an empty hostname could never match their own next post: every post inserted, the table grew without bound, and the history filled with identical entries nobody could read. Whitespace did the same thing more quietly. |
||
|
|
cebb3e9e44 |
Guides: make images render, and stop drafts leaking to readers
Images were a broken icon for every user. The stored URL was bound straight to <img src>, and a plain image request carries no Authorization header while this app keeps its JWT in one, so every fetch was a 401. They are now fetched through the API client, which also fixes the stored '/api/...' path under a subpath mount where the API is not at /api at all. A failed image says so in words rather than rendering as a broken icon, and the editor's own thumbnails go through the same path - an author needs to see the screenshot they pasted. Revision and image routes gated on their parent guide. Both were gated on guides.view alone, and revision ids are sequential, so being able to read one guide was enough to walk the ids and read every unpublished draft on the site - the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide still serves to any reader; one that only appears in a draft does not. The guide list, the tag chips and search now match the PUBLISHED text rather than the working copy. Matching the working copy handed readers a hit on wording that is not in the page when they open it. The slug no longer follows the title. It was regenerated on every save that carried a title and the editor autosaves every three seconds, so fixing a typo in a heading silently moved the URL and broke exactly the pasted links the stored slug exists to protect. publish() now aborts when the pre-publish save failed. It ran regardless, so the author saw "Published version N" while readers got the previous revision. Link rendering: text is escaped before formatting, so a captured URL arrives carrying entities and was escaped a second time - every multi-parameter ServiceNow and SharePoint link opened with &amp; in it. Emphasis could also reach inside an href, and '//host' passed as an in-app link when the browser reads it as off-site. Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes that no test executed, which is how three of them shipped serving drafts. |
||
|
|
dba8fc6071 |
Let somebody retire an asset without an API call
Five of the seven asset types had no way to take a record out of ShopDB from the interface. Network devices and 3D printed parts had a button; machines, PCs, printers and measuring tools did not, so a scrapped tool stayed in every list and report until somebody went to the API. The endpoints were already there and already correct. Every one of these DELETEs sets asset.isactive = False and nothing more, so the record leaves the lists while its calibration history, backups and relationships stay exactly where they were. Called "Retire" rather than "Delete" for that reason: the two existing buttons say Delete, which reads as destruction of a record that is in fact kept and can be restored by setting isactive back. The confirm says so too, because the question somebody needs answered before clicking is whether the history goes with it. The button is guarded on the record having loaded, on the two pages whose action row renders before the fetch resolves - measuring tools showed Retire beside a hidden Edit while the tool was still loading, which would have called delete with an undefined id. printersApi had no delete method at all; added. USB is deliberately left out. It retires through a status column in the external CMMC database rather than isactive, has its own /retire endpoint, and needs its own decision about what retiring means there. |
||
|
|
ed5d64fa3e |
Name the record in the browser tab, not just the section
Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced. |
||
|
|
e2a226e8b0 |
Let Guides reach a site, and reach core the way every plugin must
Two faults the guides plugin shipped with, both caught by gates that were already there. deploy/site-profile-universal.json never listed `guides`. That file is the profile the released Windows installer is built from, so the wizard would not have offered Guides at all - and worse, `flask plugin prune-schema` drops the tables of any plugin absent from the profile at provisioning (ADR-014). A site installing the next release would have had its guides table dropped. plugins/guides/models/guide.py imported shopdb.core.models.base directly. Plugins reach core only through shopdb.api or shopdb.plugins.base (ADR-001), and shopdb.api already re-exports BaseModel, so this is the same symbol by the supported path. The suite was red on main because of these two; it passes again. |
||
|
|
bd0e9695e5 |
Document the guides API, and move Guides beside Slides
The repo already enforces that every served route appears in docs/api-inventory.json, and the guides endpoints did not - so the parity test was failing. They are now listed with auth, params and a working example each, beside knowledgebase because the two get read for the same reason. docs/GUIDES.md covers what the inventory cannot: the block schema and why it is blocks rather than HTML, the id rule that makes a tick rememberable, what the server refuses and why validation happens at the door, the working-copy / revision / published-pointer split that stops a live procedure changing under somebody following it, and a worked example of creating and publishing a guide from a script. It also states plainly what does not exist yet - no importer, orphaned images never pruned - and what converting existing documents will actually be like: structured HTML and Markdown map onto block types nearly one for one, while a plain-text runbook lands as paragraphs and needs marking up by hand afterwards. Generated ids have to stay stable across re-imports, or every re-import resets readers' progress. Guides moves to position 8 in the sidebar, beside Slides, rather than down among the reference lists in Information. A guide is something somebody goes and does something with, not a catalogue they look things up in. |
||
|
|
2c016de59e |
Guides answer the search box, and proofing catches what spellcheck cannot
A guide was invisible to global search. Core search has a hardcoded block per domain rather than a hook, so a new plugin stays unreachable until someone adds one - which is the same shape as the gap this test file already documents for USB devices and printed parts: records with a list page, labels in the world, and no way to find them from the search box. Guides are searched by title, summary, tags AND body. The body matters more here than elsewhere: people look for a procedure by something written halfway down it, not by a title they never read. PUBLISHED ONLY - a draft is deliberately invisible to readers, and search is exactly where that rule leaks if nobody writes it down. The test proves it with a draft sharing a tag and a body word with a published guide, so a missing filter would show up as a ride-along. PROOFING catches what a spellchecker cannot, because none of these are spelling: a smart quote in a command so it will not run, a checklist item with no text so a step is silently missing, a heading left with nothing under it after a reorganise, an image with no alt text, a TODO published by accident. Warnings, never blocks - somebody publishing at 14:40 with one empty bullet should be told, not stopped, and a procedure that exists imperfectly beats one nobody wrote. The smart-character issue reports once per block with a one-click fix, rather than once per offending character: an opening and a closing quote are one problem, and listing both makes the panel look like it is nagging. The character table itself is written as escapes. The style gate forbids non-ASCII in source, and a file whose job is detecting smart quotes cannot contain any - which the gate caught, correctly. |