b5fc5c01d1a900ad98fb288aaecca28a67e79b8f
36 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
de3c5aa562 |
Stop re-creating a controlling-PC link that was already there
Reported: moving machine 3016 on the map and saving answered "this relationship already exists" and refused the save. The map had nothing to do with it. Every save of the machine form re-POSTed the controlling-PC link, and the server was right to refuse it - the row was already active, and assetrelationships is unique on (source, target, type). Any edit to a machine that has a controlling PC hit this; a position change is just the one somebody happened to be making. The form asked the wrong question. It used "do I know the existing link's id" as a stand-in for "is there an existing link", and read that id from rel.assetrelationshipid - a key the API has never emitted. to_dict serialises column names and the column is relationshipid, so the read was undefined on every load while controllingPcId was populated from the same relationship. The two answers disagreed, and the disagreement was invisible: undefined is not an error, it is just a falsy id. That single conflation caused both failures. Saving anything re-created the link, and CLEARING the controlling PC did nothing at all, because "no selection and no known id" reads as "nothing to do" - so removing a machine's controlling PC silently kept it. Only the first of those was reported, and the second is the one somebody would have trusted. Fixed by making the LOADED PC the authority on whether a link exists, with the relationship id used only to delete a row. If that id ever goes missing again the worst case is a link that cannot be removed, never a duplicate create. The decision moved to controllingPc.js as a pure function so it could be pinned by tests, following labelPages.js. Unchanged is now explicitly no-op rather than delete-then-create: re-linking a machine to the same PC opens a window where it has no controlling PC at all, and resolve_asset_position walks that edge, so a failure between the two calls would drop the machine's inherited position along with its link. The specs also cover a select handing back '42' against an API that gave 42, which would make an untouched link look like a swap and reach the same duplicate POST by another route. Not the printer assignment, which was the first guess: PrinterAssignmentPicker makes no relationship calls, and its save is wrapped in a catch that only logs, so it cannot surface an error from this form at all. |
||
|
|
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. |
||
|
|
d60ed602a1 |
Stop three ways the collector and the forms wrote things nobody asked for
A review of last week's device-identity work found these; two were writing bad data and one was reproduced against a live server before being fixed. AN UPDATE COULD BLANK AN ASSET NUMBER, on all six asset update paths. Create validates it and the column is NOT NULL, but the conflict check only runs when the value DIFFERS, and '' collides with nothing - so an empty assetnumber went straight through to a required column. This is the likely source of the assets found with no number: a form that loaded blank and was then saved. MACHINEFORM COULD LOAD BLANK AND LET YOU SAVE IT. One try/catch wrapped eight reference loads AND the machine fetch, so a single transient failure among them - one page of listAll() timing out during a collector cycle is enough - rejected the whole block and rendered a fully editable EDIT form with every field empty, the error banner far below next to Save. Typing an asset number and saving then wrote the blanks over a real machine. The record now loads in its own try, and a failure shows the reason INSTEAD of the form: an empty edit form is indistinguishable from a record whose fields are genuinely empty. NAMING A DEVICE THAT DID NOT RESOLVE STILL MINTED A TWIN. Both device paths warned "not linked" and then fell through to mint <HOST>-PARTMARKER or <HOST>-CMM - the hostname-derived twin the resolution order exists to prevent. The warning was true about the typo'd number and false about the twin. Naming a device is a commitment: if the name does not resolve, or resolves to the wrong kind of thing, link nothing and say so. Silence still means "work it out", so a bay with no file keeps the reuse-then-mint behaviour it always had. TWO PCS COULD BOTH HOLD ONE DEVICE, ACTIVELY, WITH NO WARNING. Verified against a live server: report as one host, then as another naming the same marker, and both controls rows stayed active. Neither device path had ever looked at who else held the target - only at links whose source was THIS PC - so a replaced PC kept its link forever and an asset-id.txt copied to a second bay claimed the device silently. It now reuses the machine link's rule rather than inventing a second one: an incumbent that has gone quiet past the claim window or been moved off In Use has yielded and is archived, never deleted; a live incumbent keeps the device and the challenger is recorded dormant. The swap test asserted the old behaviour and now asserts the new one, split in two: a live incumbent keeps it, and handover completes once the incumbent yields. Two other tests were passing while their names lied - the unknown-device one checked only that the typo'd asset was not created, not that nothing was linked, and it passed while a twin was minted beside it. |
||
|
|
85931db0fa |
Network devices: filter models by vendor, and style the hero like every other page
Three fixes, all of them a page not doing what its siblings already do. MODELS WERE NOT FILTERED BY VENDOR. PCForm, MachineForm and PrinterForm each narrow the model list once a vendor is chosen; NetworkDeviceForm bound the whole catalogue, so picking Palo Alto still offered every Dell and Zebra model. Same computed as the others, including the same rule that no vendor selected shows everything - an empty dropdown reads as "no models exist" when it means "pick a vendor first". Audited the rest: this was the only gap. The other views holding a modelnumberid have no vendor picker to filter against, and settings ModelsList is where a model's vendor is ASSIGNED, where filtering would be circular. THE HERO RAN THE LABEL INTO THE VALUE - "Asset #FW-OAV..." as one string. The page used detail-item / label / value, which match nothing in the stylesheet, so the two spans got no layout at all. Every other detail page uses hero-detail / hero-detail-label / hero-detail-value, which stacks a small uppercase label above the value. Renamed to those; no CSS added, because the styles already existed and this page simply was not using them. It was the last page using the unstyled names. MACHINES LIST LINKED BY THE WRONG ID on its fallback path. `/machines/:id` keys on machineid, the plugin extension id, and the row click and View button fell back to `item.assetid` - which lands on whichever machine happens to carry that number: a wrong page that looks right, which is worse than a 404. That defect has been fixed twice before in other views (AssetRelationships, then the GE-Enforce reports table) and BackupHistory carries a comment warning about it; this was the fourth copy. The list endpoint always sets item.machine, so the fallback could not actually fire here - it is removed as a latent trap rather than a live bug, and with no machineid the cell now shows plain text rather than a link that misleads. |
||
|
|
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. |
||
|
|
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. |
||
|
|
aede5ac621 |
Match the optional identifiers in every list's search box
gaugelabreference and maintenancereference are collected on the form and shown on the detail page for all five asset types, and no list's ?search= matched either. Someone holding a physical tag could read it off the machine, type it into the list they were already looking at, and get nothing back - while global search, fixed in the previous commit, found it. The clause is copy-pasted once per plugin, which is how all five came to omit fields their own forms collect, so the test is one parametrized pass over all five lists rather than five per-plugin tests that would drift the same way. It also pins that widening the clause did not turn the box into a pass-through. The api-inventory entries enumerate the fields each search matches, so all five were stale the moment the queries changed; updated with the OpenAPI spec. Not touched: the collector does not send either identifier, correctly - a lab-assigned tag is not something an agent on a PC can discover. The legacy import loader does not map them either, and the classic schema has no gauge-named column to map from. |
||
|
|
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. |
||
|
|
27f76ee964 |
frontend: declare the empty toplevel export the codegen reads
routes.gen.js spreads `.toplevel` for every plugin uniformly, but only a few own full-screen routes, so Rollup warned on eleven of them every build. The `|| []` guard was always doing its job - the warning was noise, and noise in a build log is where a real warning goes to hide. |
||
|
|
c34815b87e |
dashboard: overflow links somewhere, tiles say what they count, rows stay inside
Four fixes, all from looking at the real board. "and N more" now links to a page showing them all. Telling someone 35 more PCs are silent and leaving them to find the list is worse than not saying it. Each card names its own destination and a test checks it against the routes that actually exist - a viewall pointing at a route nobody wrote is the same rot the endpoint check already guards, just failing in the browser instead of the API. PRINTER ROWS ESCAPED THE CARD. A flex child will not shrink below its content width unless told to, so text-overflow never engaged and a row carrying three cartridge readings plus a location simply ran past the border. min-width:0 on the row parts is what enables the ellipsis; meta shrinks first because it matters least, and the card clips as a backstop. THE STAT TILES WERE INCOHERENT. Two counted asset TYPES, two counted asset STATUSES, and nothing said which - with the status one labelled "Active", which reads as "not deleted" but meant status = In Use across every type. Each tile now counts one thing and its label says so. PCs GONE SILENT IS NARROWER, and better for it. A PC that never reported at all is usually a hand-made or imported record rather than a bay that broke, and a PC that is not In Use is silent ON PURPOSE - that is the status doing its job. Both were burying the real signal: a machine that was working, is not now, and nobody has marked as anything else. |
||
|
|
5eb84873e8 |
dashboard: convert the last dead widgets, and delete the one that had nothing
Three plugins still declared widgets naming Vue components nobody wrote. Converting them honestly meant three different answers, not one. notifications gets a real card: the active notifications themselves, not a count. "4 active" tells an admin nothing; knowing WHICH message the shop is looking at is the point, and it is how a stale one gets noticed and taken down. machines gets machines out of service - anything not In Use, excluding Inventory, because a spare on a shelf is stock rather than a problem. Someone is supposed to be chasing each of those and today they are visible only to whoever thinks to filter the list by status. network gets NOTHING, and its declaration is deleted rather than converted. Network devices carry no live status - no polling, no reachability check, nothing that can be wrong - so the only possible card is a count of how many exist, which is precisely the always-true number this dashboard exists to get away from. A comment records that, so the next person does not re-add it. If reachability is ever collected, that is the card. Also adds a contract test over every declared card: no component names, a valid renderer and severity, and - the one that matters - the endpoint must be a REAL route. A declaration pointing at a route nobody wrote is exactly how the old widgets rotted unnoticed for months, and now it fails the build instead. |
||
|
|
d8fe0a48b2 |
Stop a stray click outside a modal discarding what was typed
Operators reported losing a part-filled form by clicking slightly outside it. Every data-entry modal closed on a backdrop click with no warning and no way back - the worst possible response to a misplaced click, and it happens most to someone adding their first records at a new site. Close-on-overlay is removed from 35 modals across 30 files: anything containing an input, textarea, select or v-model. They still close by Cancel or the X. Confirmation dialogs keep it, because a delete prompt holds nothing to lose and dismissing one by clicking away is the behaviour people expect. VendorsList shows the distinction - its edit form no longer closes that way, its delete confirmation still does. The shared Modal component now defaults closeOnOverlay to FALSE. Every current caller holds a form, a checkout, a stock adjustment or a map position being picked, and not one passed the prop, so all of them had the same fault. A modal that genuinely wants dismissing that way opts in explicitly. Also regroups the operator console menu, which had grown to numbers 1-9 plus three letters bolted on with no order to them. Actions are now grouped by what they touch, keyed by their first letter, and the old numbers still work so nobody who has used it for months is stopped by a rearrangement. The menu also warns when the server is not fully provisioned and names the key that fixes it, instead of reporting it as ordinary status lines that read as normal unless you already knew what to look for. That check is cached for the session because it shells out to flask twice and the answer does not change while somebody reads the screen. |
||
|
|
e22322dcc9 |
Show model type in the machines list
The machine's own type is blank for the 134 machines that came from the classic ASP database on machinetypeid=1, a LocationOnly placeholder the import refuses to carry across as a real subtype. The catalog model knows what those machines are, and its type is populated, so the column reads modeltypename under a heading that says so. Where both values exist they are identical - all 262 machines in the development database match exactly - so nothing is lost by showing the one that is reliably filled in. This does not fix the underlying gap. A null machinetypeid also excludes a machine from the map's subtype filter and drops its marker to the default colour, and no column heading affects that. Only populating machinetypeid does, which is what the backfill script is for. |
||
|
|
cb18d170cf |
Say whose type it is
Two fields on the same page were both labelled "Type": the asset's own, and the catalog model's. Only one of them was vague. "Model type" already says exactly what it is; the bare "Type" did not say whose. So the unqualified one is the one that changes. No new vocabulary, and "Model type" reads correctly against it: Type -> Machine Type (machines) Type -> PC Type (computers) Type -> Printer Type (printers) Type -> Device Type (network devices) Left alone everywhere the word is not ambiguous - measuring tools, subnets, VLANs, notifications, supply types and the manifest editor have no model type on screen to be confused with. This is a labelling change only. It does not address the blank type column on machines imported from the classic ASP database, which is a data gap the backfill script fills; renaming a column heading was never going to put values in it. |
||
|
|
24266146d8 |
Show the model's type only when it differs from the asset's own
Adding a "Model type" row next to "Type" put two rows in the Hardware section that read identically. They come from different tables - modeltypes is the catalog-wide list spanning every kind of asset, machinetypes is machine-only - but the names line up in practice: all 262 machines in the development database match exactly, which is the same fact that makes the type backfill safe. So the row now appears only when the two disagree, which is the case worth seeing: a model catalogued as one thing fitted to an asset recorded as another. When they agree it says nothing and is hidden. Applied to machines, PCs, printers and network devices, each compared against its own type table. |
||
|
|
3f320fcc8b |
Derive an asset's vendor from its catalog model, and show the model's own type
An asset that carries a model but no vendor was showing a blank the database could already answer: the model records its vendor, and both sides reference the same vendors table. Machines, PCs, printers and network devices now fall back to it. The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and the detail pages render "(from model)" beside the value, because the record itself is still empty: the edit form shows an empty vendor box, and a page implying the vendor is stored would be lying about where it came from. The model's type is exposed under its own name, modeltypename, and shown as a separate "Model type" row. It is deliberately NOT used to fill in the asset's own type. modeltypes is the catalog-wide list covering every kind of asset - it holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries - so it is a different taxonomy from machinetypes. Only about two thirds of the names overlap, and mapping one onto the other would mistype the remainder, with the failure mode being a machine labelled "Desktop PC". scripts/backfill_vendor_from_model.py writes the derived vendor down for real, since the display fallback leaves reports that read vendorid still seeing nothing. It is a dry run unless given --commit, fills only rows where the asset's vendor is NULL and the model names one, and never overwrites a vendor somebody chose. It skips a table lacking either column, so it runs against a server whose network migration has not been applied yet. Verified against the development database by nulling one machine's vendor inside a transaction: it was detected as fillable, restored to exactly its original value, and the rollback left the row untouched. FLASK_ENV is not forced by the script. The app already reads it from .env, and overriding it demanded a SECRET_KEY the environment had no reason to supply. |
||
|
|
b16f143467 |
search: asset lists search the type column they display
Every asset list shows a Type column (and printers a Model, machines and network a Vendor), but the search filters only looked at the asset number, name, serial and hostname. Searching a type returned zero rows: 'Part Washer' on machines, 'Standard' on PCs, 'Thermal' on printers. Extend the search on machines, computers, printers, network devices, measuring tools and the unified asset list to cover the type name plus the vendor/model where the list shows them. Joins are outer joins so an asset missing a type or vendor still matches on its own fields; the core list uses a correlated EXISTS instead, since its type-name filter already joins AssetType. |
||
|
|
31139267d1 |
Fix model images 404 under a subpath mount (withBase)
Model image URLs are root-relative (/api/models/image/...), so on an /ops subpath deploy the raw <img src> resolved to the server root and 404'd. Wrap every model-image src in withBase(): machine/printer/PC/network detail heroes, the models settings preview, and the machine-badge / asset-label print pages. withBase leaves external http(s)/data URLs untouched. |
||
|
|
592ff49abe |
ADR-013 Phase 4: extract the 11 plugin routes embedded in core.js
core.js still routed plugin-owned pages directly. Extracted all 11 into the owning plugin's route file + moved their views into plugins/<name>/frontend/: - computers: reports/pc-relationships, settings/pctypemapping - printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply monitoring) - machines: settings/machinetypes - network: settings/networktypes - warranty: settings/dellwarranty - slides: settings/slides (its route file gains a default export; it was toplevel-only) - employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) - employees had no route file before; its pages lived only in core.js. core.js now holds only core routes; all 14 bundled plugins are self-contained under plugins/<name>/frontend/. Verified live: the extracted Machine Types settings page renders in the settings rail from the machines plugin frontend. Build + 58 vitest + naming green. |
||
|
|
23dc9fa379 |
ADR-013 Phase 4: relocate 4 self-contained plugin frontends
Relocate applications, geenforce, knowledgebase, and machines - each owns only its own views dir, so a clean move to plugins/<name>/frontend/ (views/ + routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its vitest spec move with it (ManifestEditor imports it as a sibling). Machinery fixes this batch surfaced: - routes.gen.js codegen uses namespace imports (import * as p_x). A route file without a `toplevel` export is undefined on the namespace instead of a strict- ESM missing-binding build error. - vitest gains a `pretest` stage so plugin-frontend specs (now under plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/. Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper) renders fully from its staged frontend. Build + 58 vitest + naming green. |
||
|
|
cd353b6432 |
Review safe-polish: docs accuracy, dead imports, no-emoji, geenforce robustness
From the full multi-agent review (0 high, 7 medium, 17 low findings). Applies the mechanical, low-risk items; design/policy findings left for a decision. Docs accuracy: CLAUDE.md contract 0.10.0 -> 0.11.0 and both stale Alembic head citations -> 7d24_customfield_searchable / 31 migrations; Dockerfile bundled- plugin comment fixed (drop nonexistent "equipment", add machines + measuringtools, count eleven). Style/naming (LOCKED rules): remove a CSS-escaped pushpin emoji before location search results (no-emoji policy); rename ManifestEditor shareRoot -> shareroot (variable mirrors the API field verbatim). Dead code: remove confirmed-unused imports across ~20 modules (require_role/ require_permission scaffold residue, stray db/Vendor/Model/current_user/Optional/ error_response); drop unused build_scope import + a stale GEENFORCE_API_KEY docstring clause in geenforce. Migration files left untouched. Correctness: geenforce ingest robustness - record_enforcement_report now 400s on a non-dict counts / non-list results instead of 500; _apply_app_link ignores a non-numeric appid per its docstring instead of 500. Regression tests added. Backend query.get sweep finished: auth.py refresh -> db.session.get (last one). 910 backend tests pass; pyflakes clean; naming green; frontend build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7dfbe7bf8a |
Add the get_permissions plugin hook (contract 0.10.0)
Plugins declare their own RBAC permissions instead of core accumulating them: 36 permissions moved out of the core catalog into the 9 owning plugins (core keeps the 19 its own blueprints enforce). The catalog is resolved dynamically (core + enabled plugins) and feeds the roles grid, the token scope picker and ceiling, and flask seed permissions; installing or enabling a plugin seeds its permissions automatically. A disabled plugin drops out of the assignable catalog while existing role links keep working. New plugins - bundled or external - now bring their permissions with zero core edits. 781 tests pass; live-verified with a machines.edit-scoped token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b130ef43f3 |
Add the dualpath-as-single-machine site toggle
Most facilities consider a Dualpath pair one physical dual-bay machine. New site setting dualpath_single_machine (default on): the machines list, dashboard counts, machines-by-type report, and floor map collapse each pair to its primary bay (lower assetnumber), with combined 2007 / 2008 labels; pagination totals stay honest. Detail pages remain per-bay and always show a dual-bay sibling banner linking the partner. Pair resolution lives in core services and joins the plugin contract surface (0.8.0 -> 0.9.0). On the WJ dataset: 31 pairs collapse, machine counts 262 -> 231, map 470 assets. Toggle verified live in both states, left on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
46e50c07ff |
Add the API import surface for legacy migrations (contract 0.8.0)
Goal: an LLM or script can migrate an entire legacy database using only the HTTP API - original history preserved, safely re-runnable. - X-Import-Mode header (admin only): create/update endpoints across 15 timestamped entity types accept original createddate/modifieddate; helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0). - Exact-match natural-key lookup filters on 13 list endpoints for the lookup-then-upsert recipe. - Selfhosted USB checkout/checkin accept backdated event times in import mode. - docs/IMPORT-API.md: operator manual grounded in the real legacy schema - order of operations, full table-by-table mapping including the machines fan-out, idempotent Python importer with dry-run, parity checks, and decided dispositions for unmigrated tables (DNC config stays live-fed via the collector; supportteams/appowners map to the upcoming supportteams model). 635 tests pass; naming green; frontend untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
48d3160bc5 |
Rename the equipment domain to machines; retype the models catalog (ADR-011)
The equipment plugin is now the machines plugin, ending the UI-vs-code vocabulary split while the contract is pre-1.0 and nothing external depends on the old names. - plugins/equipment -> plugins/machines: manifest, class, /api/machines, machines.* permissions, registry key (with an auto-migrating load shim for existing installs). - Tables: equipment -> machines (equipmentid -> machineid) and equipmenttypes -> machinetypes, renamed in the plugin's own migration chain (machines0002rename), idempotent for both upgrading and fresh installs. - The legacy core machinetypes lookup actually types the vendor MODELS catalog, so it is renamed losslessly to modeltypes (models.modeltypeid, /api/modeltypes, Model Types settings page) rather than collapsed, freeing the machinetypes name. Core migration 7d17_machines_rename also flips data in place: assettypes row equipment -> machine, auditlog entitytype, identifier_/search_ settings keys, permission rows, and renames alembic_version_equipment. - Frontend: machinesApi/modeltypesApi, item.machine response shape, assettype value compares 'equipment' -> 'machine' (map, search, custom fields, relationships), routes machines.js with plugin gating retagged, /print/machine-badge, Machine Types (subtypes) and Model Types (catalog) settings pages, machines-by-type report id. - Docs swept; ADRs left as history per the authoring rule. Upgrade: flask db upgrade then flask plugin upgrade-all. Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models retyped, zero equipment tables remain); fresh scratch-MySQL install produces the new names; 341 tests green; naming/style green; frontend builds; live E2E on machines list/detail, PC relationships, map, reports, and both settings pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |