5f27f81e88189b52ffd3c06f30697f1b503bc049
243 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4590156f30 |
Add endpoint software delivery: run_script and update_app push
Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built. |
||
|
|
511c3ffcee |
Show every status the asset total contains, and what it excludes
Asked twice what "All assets" covered, which is the signal a label is underspecified rather than a reader being slow. The endpoint already counted every status to build its rollup and then returned two of them, so a reader could see In Use and had no way to learn where the rest of the total went. It now returns them all, and the panel lists them under the total - indented and quieter, because they break the total down rather than adding to it. Three things this makes visible that were not guessable: Retired assets are IN the total. Retired is a status on an active record, not the same as deactivated, and seven of them sit inside this site's 610. There is an "Inventory" status. I would have hardcoded In Use, In Repair and Retired and silently dropped it. The rows are built from whatever the API returns, sorted by count, so a status a site adds appears rather than disappearing - which matters for sites adopting with their own seed data. Rows held back by isactive are in NO status bucket, so they cannot be found by adding the buckets up. That gap is exactly what makes a dashboard total disagree with a list page, so the count is reported and captioned. The caption states the counting policy - active records only, dual-bay pairs counted once - and the dual-path clause renders only when that site toggle is on, since asserting it unconditionally would be false at a site that has it off. Full suite 2182, frontend 314. |
||
|
|
4c8291628f |
Call the inventory panel Assets, not Estate
The panel had no group label before it was one panel, so grouping it needed a heading and it got 'Estate' - RMM idiom, and the word the leadership documents already used for the whole of what a site owns. Wrong register for this surface. The sidebar already groups exactly these things under a section named assets, so the dashboard had invented a second word for a group the app already names, and the panel's own total row says 'All assets' - the heading and the total disagreed inside one panel. 'Estate' is also enterprise-IT and largely British idiom; it earns its place in an executive summary and not on a screen read at 6am. The scoped classes follow the label, so the markup does not keep a word the UI stopped using. The documents keep 'estate', where it is doing real work for a leadership reader. Scope: product |
||
|
|
cd86c27969 |
Show a guest what they can see, not a blank page
A guest got a heading and nothing else.
THE STRUCTURAL FAULT WAS MINE
Moving the Estate panel into DashboardCards' rail slot put it behind that
component's root v-if, which was `liveCards.length > 0`. A guest is permitted
zero cards, so the whole component disappeared and took the slot with it. Before
that move the tiles were top-level and always rendered. The wrapper no longer
vanishes with the cards.
TWO LIES THE EMPTY STATE WAS TELLING
"Nothing needs attention" is now shown only to somebody who can see the checks.
A reader permitted none of them is not looking at a healthy fleet, they are
looking at nothing, and saying otherwise is the same fault as reporting Low
Supplies 0 while the monitoring is down. The count of PERMITTED cards is now
tracked separately from the count that currently hold rows.
The Estate panel no longer renders zeros for a caller who cannot read totals.
/api/dashboard/summary needs assets.view, and the failure was swallowed into an
empty object that drew a tidy column of zeros - an estate of nothing, stated
with confidence.
WHAT A GUEST GETS INSTEAD
Where they can go: the same permission-filtered navigation the sidebar uses,
which the server already filters to the guest role for a signed-out visitor. So
it cannot offer a link that dead-ends at the router guard, and this page names no
plugin it had to know about. Verified against the running instance - a guest is
offered Guides and Tech Tools, matching guides.view and tools.view.
ALSO: the measuring tools row
totalassets counts five types - machines, PCs, network, printers AND measuring
tools - but only four had rows, so the total stopped matching the addends above
it wherever gages exist: 610 of visible rows under a stated 729. That is exactly
the fault this panel was built to remove, and it looked right here only because
this database has no measuring tools. Every counted type now has a row.
Frontend 314 passed.
(cherry picked from commit
|
||
|
|
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. |
||
|
|
e562b405d4 |
Collapse the relationships card by default, with an honest count
The triage band now answers the common case above the fold - what an asset serves, or what drives it - so the full edge list below is reference rather than the first thing to read. On a machine showing two relationships, both already appeared in the band and the banner, and the card was a third copy. Collapsed by default, using the same caret, roles and keyboard handling as the collapsible panels on the same page rather than a second, slightly different one. The card is NOT redundant with the band and is not removed. It is the only surface with Add Relationship and the per-row delete, and it keeps each edge's exact type where the band deduplicates by asset. The printer page is the proof: four rows there - usesprinter and defaultprinter, for each of two machines - collapse to two band chips, and which machine DEFAULTS to that printer is a distinction the band deliberately drops. Fine for triage, wrong for management. THE COUNT IS OF WHAT IS RENDERED, NOT OF EDGES A collapsed card that just says "Relationships" cannot be told from an empty one, so the header carries a count. The first version counted raw edges and was wrong on the first machine it was shown: a symmetric Dualpath link is stored in BOTH directions in the legacy shape, so it is two rows in outgoing+incoming and one "Connected" entry on screen. The badge read 3 above a card that shows 2 when expanded - exactly the mismatch the badge exists to prevent. It now counts rendered items. Add Relationship expands the card before opening the modal, so a new row cannot land behind a closed caret and read as the add having failed. Verified on screen: machine reads 2 and matches, printer reads 4 and matches. Frontend 280 passed. |
||
|
|
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. |
||
|
|
828a62063b |
Filter chips on any card that shows a health dot
Reported from the Activity card: the header dot was red while every visible row was info, because the critical event was older and sat further down the list. The dot and the list appeared to contradict each other, and the only way to find the critical was to scroll. The dot is computed from all rows the panel received, so a red dot always means a critical IS in the data. The chips are computed from that same array, which means the count sits directly under the dot and the two cannot disagree by construction: the card now reads 'Critical 2 Warning 1 Info 12', and one click surfaces them wherever they sit in time. Derived from the panel's EXISTING status declaration rather than a new one per panel. A card that shows a health dot has already said what it dots on, so Tracked Services and Tracked Processes get chips with no plugin change, and any future list panel with a status rollup does too. Two declarations of the same thing would eventually disagree. Details that matter: a colour-keyed panel is labelled by meaning, so services read Critical and OK rather than Red and Green; chips are ordered worst first rather than alphabetically; and no selection means show everything, not hide everything - a card that emptied when you deselected the last chip would read as broken. Filtering to nothing says so, and gives the hidden count. vitest 267 passed, including the exact reported case. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
69178c0218 |
Stop sending the redundant per_page parameter on every list request
get_pagination_params reads 'perpage' and only falls back to 'per_page', and this client has always sent 'perpage', so the interceptor's duplicate never did anything except make every list URL carry a parameter twice. Removing it also clears a live production problem. The endpoints list route used to answer a 308, which is a PERMANENT redirect: the browser caches it against the exact URL including its query string. Clients that hit the old route keep that rewrite after the server is fixed, because the request no longer leaves the machine - nothing deployed can reach it, and it fails as mixed content when the cached target is http. Confirmed in the field: the page failed on load with the default query string while a search, whose query string differs, worked in the same session. Changing the query string changes the cache key, so an affected client misses the poisoned entry and reaches the corrected route without the user having to clear site data. Plugin views that pass per_page explicitly still work - the backend fallback is untouched. vitest 260 passed. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
11f39253c2 |
A plugin report renders as a report, not as a JSON dump
The reports page ran a hardcoded switch over five core report ids and fell
through to `default: console.error('Unknown report')`, and its results panel was
a matching chain of per-id templates ending in a `JSON.stringify`. So a plugin
could register a report, watch it appear in the catalogue, and get a wall of
JSON when it was opened - which is where the three printedparts reports have
been since they were added.
Any report that declares an endpoint is now fetched from it, and any report
returning {columns, rows} renders as a table. That shape is already the
convention on the server side, so a plugin needs no core code at all to have a
working report. The per-id templates stay for the core reports whose payloads
are not tabular.
Export CSV goes through the api client rather than window.open. A new window
carries no Authorization header, so every report behind a real jwt_required -
which is every plugin one - answered the export button with a 401. The core
reports are jwt-optional, which is the only reason the old way ever appeared to
work. It also followed /api/reports/<id>, which does not exist for a plugin
report, instead of the endpoint the report itself came from.
A report that fails now says so instead of leaving an empty panel, and the page
offers the time window the endpoints already accepted - printedparts-consumption
was permanently stuck on its 30-day default because nothing could send one.
Verified in a browser against both plugin report families and the core reports.
|
||
|
|
3c9b400db4 |
Granting somebody a permission now takes effect without a re-login
fetchUser() existed and was called from nowhere. The signed-in user - including the permission list every v-if in the app reads - was written once at login and cached in localStorage, and nothing ever refreshed it. So an admin granted somebody guides.create, the person was told they had it, and the New guide button stayed hidden until they happened to log out and back in. Nothing on screen explained why, and the natural conclusion is that the permission system is broken. Called on app boot, so a permission change lands on the next page load. Reproduced before and after: button hidden, permission granted mid-session, plain reload, button there. Only a 401 logs out now. The old handler logged out on ANY failure, so an API restart or a network blip would have thrown people out of sessions they still held - a cached user is stale, not invalid. |
||
|
|
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. |
||
|
|
ab5b48e3f4 |
Put the USB kiosk in the sidebar next to the parts kiosk
The kiosk route lives outside AppLayout, so nothing linked to it - a full-screen page with no way in from the app is a page nobody finds. It joins the Displays section beside Parts Kiosk, TV Slideshow and the Shopfloor Dashboard, gated on the route existing so a build without the USB plugin does not advertise it. |
||
|
|
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. |
||
|
|
e60d6b74d8 |
Search: a domain that FAILED no longer looks like one that found nothing
Every domain catches broadly and returns no rows so one broken table cannot take the whole search box down. That part is right. On its own, though, it makes a DOWN domain indistinguishable from an EMPTY one: the technician sees no results, is offered a spelling correction, and concludes the record does not exist. It does - the search just could not look. Failed domains are now named in the response and stated above the results. The suggestion is deliberately still offered on a degraded run. Suppressing it was tried first and is worse: a chronically unreachable domain, an external HR directory say, would silently disable spelling help for every search for ever with nothing saying why. Naming the failure is the honest fix; withholding a second useful signal is not. Guides search also moves to the published revision. It correctly limited itself to published GUIDES, but matched and displayed title/summary/tags/blocksjson from the Guide row - which is the WORKING copy. A published guide with unpublished edits matched on wording that only exists in the draft, and the reader opened it to find the word absent. |
||
|
|
9d96006eae |
Audit log retention: a way to purge, off by default
The audit log is append-only and nothing ever removed a row, so it grows for as long as a site runs. This site reached tens of thousands of pages, which is past the point of being useful: nobody pages through that to find anything. Retention is OPT-IN and the default keeps everything. An upgrade that silently started deleting a site's audit history would be indefensible, so audit_retentiondays defaults to 0 and nothing is removed until somebody sets a number. The Site & Facility card shows the log's size and exactly how many entries a purge would delete BEFORE anything is pressed. Deleting happens in committed batches. That is not an optimisation: one DELETE over a million rows builds a single enormous transaction and holds locks across the table while it runs, which on this server means blocking the collectors posting inventory. The API caps its batch count so the request cannot outlive a proxy timeout, and a run that stops early reports how much is left rather than claiming it finished - a purge reporting success over 900k remaining rows would be the same silent half-failure this codebase keeps getting bitten by. `flask audit stats` and `flask audit purge` for the server, the latter reporting by default and deleting only with --apply, with no batch cap because it is not behind a proxy and a purge that stops halfway leaves the operator guessing. audit.purge is a separate permission from audit.view. Reading the log and destroying it are different acts, and plenty of people need only the first. Chunking selects a page of ids and deletes those rather than using DELETE ... LIMIT, which is MySQL-only while the suite runs on SQLite. |
||
|
|
3c68ac2850 |
ADR-015: the site-literal check was matching nothing, and had been all along
The patterns are assembled from fragments so the published script does not
contain the strings it greps for. They used the apostrophe-escape form
('a'\''b'), which EMBEDS a literal apostrophe, instead of the concatenation form
('a''b'), which joins them. Every pattern therefore carried a stray quote in the
middle and could never match a line of real code, so the check passed everything
from the day it was written.
Verified the only way that means anything: plant a violation, watch it fail,
revert. Fixed, it immediately found 25 real hits.
Among them, in seven places, the default ServiceNow URLs - one organisation's QA
tenant, shipped with the integration ENABLED. Any site that had not configured
ServiceNow still rendered ticket links, pointing its technicians at a tenant
they cannot reach; an adopting site would have been pointed into somebody else's
instance entirely. There is no sensible cross-site default for "which ServiceNow
is yours", so the URLs ship blank and the integration ships off, matching the
Zabbix block that got this right.
A named SaaS tenant is as site-specific as a hostname, so it joins the patterns.
Colocated .spec.js files are excluded the same way tests/ already is: sample data
naming a facility is not a shipped literal.
|
||
|
|
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. |
||
|
|
03094e93b2 |
Let Guides be switched off in Settings > Search like every other domain
Global search has per-domain toggles (search_<type>_enabled), enforced server-side and edited from Settings. Guides answered the search box but had no toggle, so it was the one domain nobody could turn off - and the omission would only have surfaced when somebody went looking for the switch and could not find it. Missing key still means enabled, so nothing changes until somebody decides otherwise. |
||
|
|
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. |
||
|
|
98458672eb |
Hand somebody a password without leaving it in a chat window
The habit this replaces is pasting a credential into Teams, where it stays until someone deletes the conversation. Tabled on 2026-08-21 pending a decision about scope; the decision was text only, so this is the half that survives it. BACKEND ONLY. There is no page yet, so nothing here is reachable by a person. TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the export-control question entirely - a password is not technical data - along with blob storage, size and MIME handling, and the need to authenticate the recipient so a document fetch could be pinned to a name. TWO DOORS. Creating needs a login AND securesend.create: anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what, and the creator is the only person these rows can ever name. Reading needs neither, because the recipient usually has no account here and the link is handed over on a call - the unguessable token IS the authorisation, which is why it is 128 bits of randomness and why the row is spent on first read. REDEEMING IS A POST, and that is the detail that would have broken this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware, so a GET that redeemed would let a preview window consume a secret before its recipient ever saw it. The GET route that remains reports only whether a link is still live, so the read page can say "opening this will destroy it" without spending the only view to find out. THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment, which browsers never transmit, so the server holds ciphertext it cannot read and cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not a tuning knob: raising it quietly turns this into the document transfer it was scoped not to be. The refusal says what the limit is FOR, because somebody hitting it is usually trying to send a file and the answer is a different tool. Line counts, file signatures and refusing a pasted file belong in the browser, guide an honest person, and are bypassable by anyone willing to POST directly. NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them apart would confirm to anyone holding a guessed token whether it ever named anything. A spent or expired row is deleted on the way past rather than waiting for a sweeper, because it has no reason to keep its ciphertext. Its own plugin rather than a Tech Tool: that plugin is browser-only by design, and this needs storage, expiry and a view counter. Not an asset extension either, so no assetid and no asset type - the scaffold assumes one and it was removed. FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats an explicit zero as absent, so "expire immediately" silently became "expire in a day". A caller who sends a number is now told when it is not allowed rather than quietly given a different one. BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL fragments: create a secret, send the link to yourself in Teams, click it from the message, and check the key is still after the #. If Safe Links strips it the secret is unrecoverable and the server cannot help, which is the point of the design. The fallback is passphrase mode, worth building only if that test says so. Three product gates caught what a new plugin has to declare, and each was right to: the route-parity gate wanted the endpoints described in api-inventory.json, the lean-build guard wanted the plugin listed in the universal profile the released installer builds from, and the authz sweep wanted the public redeem route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a mutating route that answers an unauthenticated caller is a decision somebody has to write down, not a default, and reading a secret is a mutation because it spends it. |
||
|
|
5001aedcf9 |
Offer a spelling on every list, not just the global search
Correction only helped on the global search page, and people search from the list they are already on. Extending it turned out to be a shape question rather than a volume one: thirty routes take a search parameter across a dozen files, so carrying a suggestion in each of their responses is a large change today and one more thing every future plugin author has to remember. So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page can call after rendering no rows. A page that has not adopted it shows nothing, which is exactly what it showed before - nothing breaks by omission. The plumbing lives in useListQuery, which already owned the search term, so a list needs three lines: take `suggestion` and `reportCount` from the composable, call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the empty state it already has. A list that never calls reportCount never offers a suggestion. Wired: global search, machines, printers, PCs, network devices, measuring tools, knowledge base, vendors. NOT wired, deliberately: the type and reference lists (machine types, PC types, VLANs, subnets, operating systems and the rest), which are small controlled vocabularies nobody typo-searches, and USB, whose empty state has a different shape and wants doing by hand rather than by pattern. TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a suggestion by calling setSearch alone updates the box and the URL and does NOT reload the list. setSearch only syncs the URL, and the watcher that would reload is suppressed because search.value already holds the new term - the same trap the global search page documents in performSearch. Each page calls its own load function directly. The composable guards a stale answer arriving after a newer search was typed, never offers back the word that was typed, and swallows its own errors: a search that found nothing is already the answer, and failing to improve on it is not worth an error in front of anyone. The route-parity gate caught the new endpoint being served without an entry in docs/api-inventory.json, which is hand-written on purpose; added, and the spec regenerated from it (283 paths, 418 operations). That regeneration also carries the openapi version to 0.12.0, left over from the release. |
||
|
|
1abb6430f5 |
Put a code on the back of a card, a picture on the front, and the page in the app
The back side landed last commit carrying text by default, and a back laid out as text-only renders no code box at all. So a two-sided run came out with a blank reverse, which reads as the code having failed rather than as a setting doing exactly what it said. The back now defaults to carrying a code, takes its own content per row, and chooses its own symbology - a QR on the front that opens the record, a CODE128 on the back that the bench scanner already reads. Text-only is refused while the back carries a code, and the contradiction is reconciled on load, so a saved setting from the broken combination repairs itself rather than printing another blank box of cards. A picture can go on a label: above the code, beside it, or behind everything as a watermark, on either side or both. The file is read in the browser and never sent anywhere, which is what makes it usable at an air-gapped site, and it is deliberately not saved between visits - a data URL runs to megabytes and would take every sizing value with it when the quota gave out. The watermark says plainly that it costs the scanner contrast. The generator was a full-screen route with no navigation and a narrow column of controls against the left edge. That shape exists so a print cannot put the sidebar on label stock, which is a real hazard and the reason core's label pages are built that way - but it is a hazard the print stylesheet can answer, and style.css now hides the shell at print time. A tool someone spends ten minutes in gets the navigation and the full width; the stock still gets nothing but labels, confirmed against a print PDF rather than a preview. Two smaller things found while checking the above. A test print paired each page with a rendered code by loop position, while the codes are keyed to position in the whole run, so a two-sided test card could carry the wrong code; the index now travels with the page. And the back's dotted preview outline outspecified the print reset, drawing a dashed rule down the edge of every second card. |
||
|
|
41d875da4e |
Put the hover marker back in the box's coordinate space, letterbox and all
|
||
|
|
2bf1531d5b |
Measure a hover marker against the drawing, not the box around it
Reported from the floor: some assets showed their hover mini-map marker way off to the left, outside the map, while the SAME asset sat correctly on the main map. The data was right; the preview was drawing it wrong. The preview is a fixed 390x300 box and the drawing was painted into it with object-fit: contain, which letterboxes or pillarboxes to keep the drawing's own aspect ratio. The marker, though, was positioned at a percentage of the BOX. So the marker and the drawing were measured in two different coordinate spaces, and they only agreed when the level's aspect ratio happened to be 390:300. A level near that ratio looked perfect - 3300x2550 is 1.294 against the box's 1.300 - which is why this read as "some devices" rather than "the feature is broken". A taller drawing pillarboxes, every marker's x shifts, and one near the left edge lands in the grey gutter beside the map. The image now SIZES the frame instead of filling it: the transform box shrink-wraps the image and the image scales down to fit. A percentage of the frame is therefore a percentage of the drawing by construction, at any aspect ratio, and the letterboxing moves out to the preview around it where it does no harm. dimensionsFor still converts the stored pixels to percentages - that part was always right. NOT visually confirmed against a live instance. The reasoning is traced and the build is clean, but the shape of this bug is that it looks correct on any level close to the old box ratio, so it wants a look at an asset that was landing left and one that was already fine. Related and NOT addressed: the wheel zoom applies translate percentages that resolve against the element and are then multiplied by scale, so a zoomed preview may still drift. At rest, which is the reported case, it is exact. |
||
|
|
39ede87d55 |
Give a label its own gap, its own position, and a back side
Three things the code generator could not do, each of which had someone
working around it with a control that meant something else.
The gap between a code and its caption was the quiet zone. That is the
clear space a scanner needs around the code, so closing up a caption meant
shaving the thing that makes the code readable, and the only way to get a
tight label to scan was to push the caption further away than it looked
like it should be. Quiet zone now means the code's own margin and nothing
else; gap means the distance to the caption, and adds to the quiet zone
rather than eating into it. Each stock preset carries the gap that suits it.
Position had one pair of controls and they moved the label box on the
stock. That is the fix for a printer whose origin is off, and it is not the
fix for a code sitting in the wrong place on a label - but it was the only
lever there, so it got used for both. The label div is now the media and
holds still; everything printed sits in a content wrapper that Align,
Vertical and Content X/Y move within it. The old pair kept its behaviour
and is labelled Media X/Y.
A badge card has two sides and the generator only ever emitted one page per
card. The back can now carry the same code again, a fixed block of text,
its own per-row content, or nothing. Page order is a choice because the two
kinds of printer want opposite things: interleaved for a duplex card
printer, which takes pages two at a time, and grouped for a single-sided
one whose stack gets flipped and reloaded. A row that leaves its back
column empty still gets a blank back page - a page count that varies per
row shifts every later back onto the wrong front, which prints a box of
perfectly readable cards that are all wrong.
CSV parsing and page building moved to plugins/tools/frontend/labelPages.js
(committed in
|
||
|
|
e2c45d33bc |
One printer picker for machines and PCs, and one default per asset
The assignment belongs to the MACHINE, and until now there was no way to set it except the generic relationships card or the API - the form for the thing the feature is about did not exist. MachineForm now carries the picker, and PCForm uses the SAME component rather than its own copy: the PC's set overrides the machine's, and two implementations of that would drift, with the two ends of an override disagreeing being exactly the bug nobody would spot. The shared picker also fixes what PCForm did on save. It wrote row at a time through the generic relationship endpoints, which is a non-atomic reconcile: an HTTP failure part way left a PC half-assigned with nothing recording what was meant. It now calls the reconcile endpoint, which validates the default before writing anything. A relationship type can now say it allows one active row per asset (relationshiptypes.issingular, migration 7d34), and defaultprinter says it. Cardinality belongs to the type rather than the printers plugin: core's create path is where every hand-made link passes, and the next type meaning "exactly one" gets the rule for free. Setting a second default REPLACES the first instead of refusing, because "make this the default" means that - and a card answering 409 would leave the user hunting for the old row. Without it the schema was happy to hold two defaults: the unique constraint is (source, target, type), so two different targets are two valid rows, and the resolver takes the OLDEST - the new default silently lost. Proven by disabling the new rule and watching the tests fail. FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read .data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages has already unwrapped every page. The whole parallel load threw into the catch, so every dropdown on the machine edit form came up empty and the machine's own values never loaded. A build cannot see this; only opening the page can. GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment, without inheritance, because the editor must show what this asset's rows say - otherwise a machine's printers appear ticked on the PC that inherits them and unticking one silently creates an override. |
||
|
|
f34b9ca710 |
Carry the level everywhere a position is drawn, and gate it per occurrence
The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label. |
||
|
|
afd3dce493 |
Give the map editor's search box room for its own placeholder
The previous commit stopped the panel header from colliding with the "Assets" heading, but left the search box sharing a row with the type dropdown: 139px of input for a placeholder needing 216px, so it still read "Search name o". A control whose own label does not fit is not a narrower control, it is an unlabelled one. One control per row in a 318px panel. Measured in the browser: the input is now 286px against 216px of text, so the placeholder reads in full. |
||
|
|
89248407e7 |
Make the map editor accept a click, and unclutter its panel
Clicking the map in the editor did nothing, ever. The click handler was bound inside `if (props.pickerMode)` at mount, and the editor mounts with no asset selected, so the handler was never attached; selecting an asset flipped the prop but nothing rebound it. The asset forms were unaffected because their picker mounts inside a modal that is already in picker mode, which is why this looked like an editor-only fault. The handler is now bound unconditionally and handleMapClick keeps its own picker-mode guard. Verified in a browser against the dev instance, both ways: with the old binding a click on a selected asset produced no position at all; with the fix the same click reports 1652, 1138. Markers are now drawn in picker mode too. Placing one relative to the machines already on the floor is the entire task, and the old code skipped rendering them whenever the map was a picker. Two things that looked like stray widgets: - The editor's panel header put a heading and three controls on one row inside a 320px panel, squeezing the search box until its placeholder read "Search na". The heading takes its own row and the controls share the next. - The legend drew its bar and border even with nothing to put in it, which read as an empty input box under the toolbar. It renders only when it has entries. |
||
|
|
aa6db94179 |
Fix the levels viewer: markers were drawn on whichever plan was showing
Five defects, four of them mine from 0.11.0, found by using the feature. THE SERIOUS ONE: ShopFloorMap never checked a marker's level. It skipped null coordinates and drew everything else on whatever blueprint was displayed, so level 1 markers appeared on level 2 - the exact failure ADR-017 exists to prevent, in the one component that draws the map. The build gate did not catch it because that rule checks files EMITTING mapx, not the component consuming it. Markers are now filtered to the drawn level, and a position with no level is omitted rather than approximated. The map page had no way to choose a level at all. It read currentlevelid only to title the PDF, so a second floor was unreachable from the viewer that most people use. Adds a level selector (hidden when a site has one level), passes it to the map, and switches the drawing, the bounds, the coordinate space and the markers together - swapping the image without the bounds would place every marker against the wrong scale. Searching the map now follows results across levels: a search whose matches are all on another floor showed an empty map while the filter counted them. Floor map settings had NO height input - only width - so a level's native size could not be set even while empty, which is the one time it is editable. Both fields are there now, and size is editable on a level that has markers, because refusing it blocked the case the feature was built for: a new blueprint of new dimensions on a floor already full of markers. It confirms first and points at landmark recalibration. Search results differed between the sidebar box and the results-page box: - 14 of 16 searchers truncated with .limit() and no ORDER BY, so the database could return a DIFFERENT subset of matching rows for the same query. Every searcher now ends in a total order (display key plus primary key). - Searching a term already in the URL was a duplicate navigation the router aborts, so the route watcher never fired and the button did nothing. The sidebar never hit this, because it always navigates from another page - which is why the two boxes appeared to disagree. Also removes a scrollbar from both map pages. They subtracted 2rem and 40px from 100vh for the page chrome, which is really 90px of padding on .main-content, so each overflowed by the difference. The padding is now a CSS variable both the layout and the pages read. Measured in the browser before and after: 1058 vs a 1000px viewport, now 1000. |
||
|
|
62f4a42210 |
fix: page past the 100-row cap in batch label sheets and asset pickers
Follow-up to the application-picker fix. Three of these were already wrong on data that exists today, not merely latent. Batch label printing is the worst of them: AssetLabelBatch asked for 500 machines or PCs, got 100, and printed a sheet that looked complete. With 262 machines and 290 PCs in the catalogue that is a physically short run with no error anywhere - the operator finds out at the label printer, or later at the bay with no label on it. PrinterQRBatch, USBLabelBatch and PrintedPartsLabels had the same shape and are fixed alongside it, before their tables cross 100 too. MachineForm's "controls" PC dropdown offered the first 100 of 290, so a machine could not be linked to a PC sorting late in the list. NetworkDeviceForm had it for models, which are already past 100 - and the same file already called modelsApi.listAll() correctly two lines away. Adds listAll() to the machines, computers, printers, network, measuring-tools, USB and printed-parts APIs, all delegating to fetchAllPages(). Still outstanding: callers of vendors, locations, business units and the type catalogues that ask for more than 100. Those tables are all well under the cap today, so they are correct for now and wrong the day they are not. |
||
|
|
9c1c6c5729 |
fix: page past the 100-row cap in application pickers
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports nothing about having done so, so a caller asking for perpage: 1000 gets the first 100 rows and a success response. Every picker built that way looked complete and was not. Found on a live site with 126 active applications: the 26 sorting last were absent from the knowledge-base topic dropdown, so an article could not be filed against them. Nothing was wrong with those application records, and editing them could never have helped. Adds fetchAllPages() to the api module, generalizing the one call site that already handled this correctly (modelsApi.listAll), and points the four application pickers at a new applicationsApi.listAll(): the KB article form, the KB list's topic filter, the notification form, and the report filter builder. Lists that render a page at a time are untouched - they page for a reason. Other callers still asking for more than 100 rows of vendors, locations, models, subnets and the rest are latent: correct only while those tables stay under 100, and silent on the day they do not. |
||
|
|
3324dbd91e |
Buildings and levels for the floor map, and make every identifier searchable
The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database. |
||
|
|
c648bdf560 |
labels: one module knows how to draw a code, seven views stop guessing
Three core pages and four plugin pages each imported qrcode and jsbarcode directly, and each carried its own answer to the same questions: what margin, what width, which error correction, how big a module must be before a scanner can read it. The answers had already drifted - margin 0 in one place and 2 in another, width 150 against 160 - and on a label that is the difference between a sticker that scans and one that does not. frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone and margin defaults, CODE128 with no printed value, and the printer-resolution arithmetic that only the Tech Tools generator had. A view passes what is specific to its own label and nothing else - MachineBadge still asks for CODE39, because the badge readers predate the shop-floor scanners and decode nothing else, and that is exactly the kind of thing a call site should say out loud. views/print/qrLogo.js is folded in rather than left as a second half-shared helper that only some of the pages reached into. The check script now fails a build that imports either library outside that module. Without it this re-forks within a month: the next label page starts by copying the nearest existing one, which is how it happened the first time. Tests cover the part no amount of looking at a screen verifies - a QR that looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch stock. |
||
|
|
035419fa51 |
ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule that only prints becomes. It now FAILS the build, and it looks where the leaks actually were: PowerShell, the installer, the seeds, generated JSON, the frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools. A line that is deliberate declares itself with an ADR-015-OK marker and a reason, so the claim is visible in review instead of tolerated in silence. What it found, fixed here: - The shadow client wrote one site's ShopDB URL into HKLM whenever the registry disagreed. At the site it was written for that reads as healing drift; anywhere else it overwrites the site's own address on every enforce cycle, and the site cannot win because the cycle repeats. The bay's value now wins, an explicit -BaseUrl seeds it, and with neither there is nothing honest to write, so it says so and skips. - The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a kiosk elsewhere quietly opened a server it has no business reaching. The fallback is now this site's site_base_url, baked in at seed time, and the dispatcher refuses rather than guessing when neither is set. Its legacy shortcut matcher derives the host from that URL instead of naming one. - The OpenAPI generator hardcoded a production hostname into every spec it generated, which then published to a public wiki. The relative mount is the only server it can honestly name; a site passes its own by environment. - Placeholders and examples in the UI and the client help offered real internal subnets and a real production URL. They now use documentation ranges. Both publication gates - the export scrub and the docs publishability test - carry the site patterns, which neither did. One plant's hostname, FQDN and internal networks are out of the documentation and the generated specs. Comments naming the reference site are reworded rather than deleted: the reasoning is worth keeping, the plant name is not what makes it true. |
||
|
|
20a95013ad |
contract 0.18.0: one name per display role, the kiosk's own
Core called the roles dashboard / lobby / partskiosk. The kiosks call them Dashboard / Lobby / 3DPrintRoom, which are the literal contents of C:\Enrollment\display-type.txt, read by the GE-Enforce dispatcher to pick a target. Two vocabularies for three kiosks, each with its own copy of the same route map. That is not cosmetic. A display reporting its own type sends what its file says, so it could report a role core would not accept, and core could store 'partskiosk', a value no dispatcher would ever match. The enforcement report column would have shown one vocabulary from the device and the other from the DashboardDefault fallback, in the same column. The machine's file wins, because that is what a person edits. DISPLAY_ROLE_PATHS takes the kiosk spelling and the display scope now uses that dict rather than holding a second one, so the two cannot drift again. normalize_display_role resolves any casing and the retired 'partskiosk' forward; the dispatcher already matched its map case-insensitively and the server now agrees with it. Nothing is turned away over a capital: the API accepts any spelling and stores the canonical one, displaypath resolves through the normalizer so rows written before this keep working, and the settings dropdown canonicalises on open so an old value does not render as a blank select. A reported subtype is normalised on the way in, but an UNRECOGNISED one is kept verbatim. That is a kiosk with a typo in its file or a role nobody declared, and both are worth seeing in the fleet table rather than blanked or guessed at. Contract bumped for the added names. DashboardDefault is finally listed in __all__ too - 0.17.0 put it on the surface and never exported it. |
||
|
|
ea35a134fe |
Show the fiscal week beside the site name
The classic ASP site prints a week number under the site title, and people quote it in conversation and on paperwork. Anyone with both sites open needs the two to agree, so this is a port of the old arithmetic rather than a fresh interpretation of what a fiscal week is: ISO 8601, week 1 contains 4 January, and the week's Thursday decides which year it belongs to. That rule is what makes late December and early January land in the right year, which is exactly where a naive day-of-year count goes wrong, so it is what the tests cover. Worth recording: a true GE fiscal calendar need not follow ISO weeks. Nobody has asked for a different rule, and inventing one here would silently disagree with the site people compare against. Computed in local time on purpose. The number people quote is the one on the wall where they stand, and a UTC week rolls over hours early in the evening at a US site. The sidebar re-checks every half hour; the shop-floor board picks it up with the clock it already ticks. |