d60ed602a13e26100be3ce100391cf58b3a7a5f0
153 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
adb30c8875 |
Release 0.12.0
Printers become a property of the bay, and the container stops throwing away the site's own files every time it is updated. Printer assignment is now end to end. The assignment belongs to the MACHINE and reaches whichever PC controls it, so a reimaged or swapped bay comes back with its printers and nothing had to be saved off the old box. One picker serves both forms rather than two implementations of the same override. A relationship type can declare itself singular, so setting a second default REPLACES the first instead of silently losing to the older row. Alongside the assigned half there is now an observed half: a bay reports what it actually has, kept strictly apart from what it is told to have, because a drifted bay's own state becoming its desired state would make every configuration error permanent. The client script corrects a drifted queue in place rather than only installing a missing one, and a driver rollout can be spread across waves so 300 bays do not pull 30 GB through one five-minute window. DOCKER SITES SHOULD READ THIS PARAGRAPH. The compose stack never persisted the instance directory, so `docker compose build api && up -d api` - the update path the docs themselves gave - discarded plugins.json and every upload with it. It is a volume now, and DEPLOY.md carries the one-time rescue for a stack that predates it. The image also could not be built at all: the frontend stage never copied the plugin staging script its own prebuild hook runs, so every build since that script landed failed. Air-gapped sites need a fresh offline bundle, because the stack moves to MySQL 8.4 LTS and an existing tarball carries only the 8.0 image. The Windows installer already bundled MySQL 8.4, so this closes a gap between the two halves rather than moving anyone. Adds a topology migration guide: IIS to Docker, Docker to a new host, and back. The data moves cleanly; what costs time is that the server address is baked into GE-Enforce manifests, the generated collector script, the printer client scripts and printed QR codes. 1736 tests green. Contract stays at 0.20.0 - nothing under shopdb/api changed. |
||
|
|
2d09fa3201 |
Collect what bays actually have, separately from what they are told to have
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the observed half makes a rollout a review instead of a typing exercise: the floor reports itself in, you look, and you adopt. The collection uses the mechanism that already exists rather than a new one. POST /api/collector/printers dispatches to the printers plugin's apply_collector_payload, the same ADR-006 hook the computers and backups plugins implement. New client script, new plugin-owned table, no new transport and no new credential. OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A collector report can never write an assignment row: _reconcile_edges is the only function that writes usesprinter/defaultprinter, it has two call sites, and both are authenticated routes a human calls. If a drifted bay's own state were allowed to become what it is told to install, every configuration error would become permanent the next time that PC checked in. Seeding an assignment from observed state is explicit - POST /assignments/seed-from-observed - because a rollout adopts many machines at once. It routes through the same _reconcile_edges as the editor, so there is one write path with two doors, and a queue matching no known printer is REFUSED rather than guessed into an assignment. That last rule is the lesson from the measuring tools: adopting on a weak key produced 43 duplicate instruments. Two fixes on top of what the agents built. The replace deleted a host's previous rows by exact case-folded name while the read path treats a short name and its FQDN as one machine, so a PC that changed spelling appeared to hold every queue twice - which reads as drift that is not there. And the client sent 'reportedat' where the declared schema said 'observedat'. Also here: the legacy loader now imports machines.printerid, the classic system's record of each machine's default printer, which it silently dropped - the production import would have lost every one. And Set-ShopdbPrinters.ps1 finally registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to C:\ProgramData first because the share it lives on is mounted only during the enforcement cycle and the task runs at logon when it is gone. VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these scripts happily and executes none of the spooler branches. The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl and CollectorKey from HKLM when given no arguments; suppresses the virtual queues by port; resolves port addresses; and reads the CONSOLE USER's default out of HKU rather than SYSTEM's own, which is a different and usually wrong answer. Two results matter more than the rest. With the spooler stopped, both the cmdlet and the CIM path fail and the script posts NOTHING - verified against a capture server that recorded zero requests, where an empty list would instead have erased that host's observed rows and read as a bay that lost its printers. A genuinely empty host still posts [], because that is a real and different fact. The logon task registers as the Users group at Limited, and falls back to the well-known SID S-1-5-32-545 when the group name will not resolve, as it will not on localised Windows. It was then run with the source directory RENAMED AWAY, to stand in for the share being unmounted, and it still moved the user's default - which is the whole reason the script is staged to C:\ProgramData rather than run from where it lives. The guarantees against damage were re-checked rather than assumed: an empty assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly leaves no queue, no task, no staged file and no registry value behind, and a drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their default keeps it. Not covered by any of this: the driver-staging path, which needs a real vendor package rather than the class drivers a VM ships with. |
||
|
|
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. |
||
|
|
72b3904f71 |
Release 0.11.3
The last of the buildings-and-levels bugs, and the shop-floor clients brought into the product. Anyone on 0.11.0 through 0.11.2 should take this. Every hover mini-map in the product reported "this asset has a position but no level" - the levelid prop added in 0.11.0 was passed by none of its seven call sites - and the map PDF printed markers from every floor onto one sheet, which nobody can correct once it is carried onto the floor. The legacy import loader, still to run against production, created markers with no level at all. The gate that should have caught all three asked whether a FILE mentions levelid rather than whether each position does. It now checks per occurrence. Also: printers can be assigned to a MACHINE and reach whichever PC controls it, so a reimaged bay reinstalls its own printers with nothing saved off the old PC; printerdrivers can name a vendor, so two rows cover 41 of 44 printers instead of twenty-one near-duplicates; and the collector reporter and EventSaver now live in the repository with no site baked into either, the reporter generated per site on request. The client scripts were validated on Windows 11 against a live ShopDB, not only by the suite: a bay with no rows of its own created both queues from its machine's assignment, bound them to the right universal drivers, and set the per-user default. The version and the changelog are the release; the detail is in the entry. |
||
|
|
0dc0ac13c8 |
Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.
New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.
Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.
GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".
PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.
One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.
printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
|
||
|
|
2083029ff2 |
Generate the collector script per site, and bring EventSaver into the repo
A site adopting ShopDB had to be handed two files and told what to edit in them. Both are now the product's, and one of them the server writes for you. GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with this site's values already in it: site_base_url becomes the -ApiUrl default and the new computers_routableranges setting becomes -AllowedRanges. Only the PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift from the first - and everything stamped stays overridable by argument or registry, because a bay may need to differ from its site. Settings > Computers > Asset reporter edits the ranges, downloads the script and shows its SHA-256. The collector key is deliberately not stamped in, and a test fails if it ever is. That file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly; it stays in the registry, provisioned per ADOPTING-AT-ANOTHER-SITE.md. The routable ranges are the last thing that was hardcoded in that script. They are now a setting, so West Jefferson's two CIDRs move out of source code and into that site's own configuration - which is what ADR-015 asks for - and a site that sets nothing still works, because the script falls back to the NIC carrying the default route. EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and EventSaver.ini, no compiled .scr - a binary is a release asset, like the installer exe. The share path that was compiled into Config.Folder is gone. It used to be the fallback when the ini was missing, which silently pointed a new site at the reference site's file server; it is now empty, and failing visibly beats displaying another site's slides. Verified by compiling the edited source in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0. Also: the DSC example in the adoption guide gains a CollectorRanges resource and stops passing -ApiUrl to a script that already reads BaseUrl from the registry the same example writes, and the guide points at the generated download instead of hand-editing a URL. The contract test caught the endpoint importing shopdb directly for the version string, which ADR-002 forbids a plugin from doing. The product and contract versions are in app.config now, which a plugin reads through current_app. Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and let the bay install them, with what the fleet data says about drivers - HP and Xerox cover 41 of 44 printers with universal drivers, there are no Brother printers at all despite 208 files of Brother inkjet drivers in the installer, and printerdrivers holds one row pointing at a per-model folder instead of a universal driver. |
||
|
|
8bde89c47e |
Release 0.11.2
Bug fixes for the buildings-and-levels work in 0.11.0, every one of them found by using the feature rather than by the suite. Anyone running 0.11.0 or 0.11.1 with more than one level should take this release: the map drew markers from every level on whichever floor plan happened to be showing, which is the failure ADR-017 exists to prevent, and it was in the one component that draws the map. The viewer also had no way to choose a level, and the editor never accepted a click at all - its handler was bound only if the map was already a picker at mount, and the editor opens with nothing selected. Also: a level's native size could not be set (the settings page had no height field), the same search could return different rows because fourteen searchers truncated without an ORDER BY, and both map pages carried a scrollbar from subtracting the wrong page chrome from the viewport height. No schema change, and the plugin contract stays at 0.20.0. The version and the changelog are the release; the detail is in the entry. |
||
|
|
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. |
||
|
|
dd503be4ba |
Release 0.11.1
A patch release. The pagination-cap fixes, and the documentation that missed the 0.11.0 installer by a few minutes. docs/UPGRADE.md is one of the documents the Windows installer puts on the server, and its buildings-and-levels section was committed after 0.11.0 was tagged. So 0.11.0 shipped the page an operator reads WHILE upgrading without the guidance about that upgrade - which matters most on an air-gapped box, where there is no other way to reach it. The installer now also ships FLOOR-MAP.md for the same reason the Windows runbooks ship. No application code changed on the server side and the plugin contract stays at 0.20.0. The version and the changelog are the release; the detail is in the entry. |
||
|
|
24647aa8e8 |
Release 0.11.0
The floor map became a set of drawings instead of one picture, and search now reaches what the forms have been collecting all along. A site can hold more than one building and a building more than one level, and every map position records which drawing its coordinates belong to. An existing single-map site upgrades into one building and one default level carrying the blueprint it already had, and nothing moves on screen. Landmark recalibration moves a level's markers onto a redrawn plan, solved per axis from the landmarks and never from the image dimensions, dry run by default, snapshotted before it applies. Two identifiers the UI collects were findable in almost no way and two plugins' records in no way at all: gaugelabreference matched only measuring tools, maintenancereference matched nothing anywhere, and USB devices and printed items were absent from search entirely because neither is an asset. All four are searchable now, from the global bar and from every list's own search box. Contract 0.20.0 is additive: a plugin that writes a map position must write its level with it. The version and the changelog are the release; the detail is in the entry. |
||
|
|
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. |
||
|
|
7d9a54ca0f |
Release 0.10.0
A privilege-escalation fix in the Windows installer, the multi-site portability work, and a documentation and API surface that a second site can rely on. The installer created C:\shopdb-flask, C:\Python314 and C:\MySQL84 without breaking inheritance from the drive root, whose default DACL grants Authenticated Users an inherit-only Modify. Two files inside those roots are executed by someone else - shopdb-admin.ps1 self-elevates, and instance config.py is loaded unconditionally by the app - so any logged-on user could replace either and wait. Verified fixed as SYSTEM in the Windows 11 VM against the shipped functions. Contract 0.19.0 is breaking: get_dashboard_widgets returns data and shape, not a component name. The change itself shipped earlier without a bump, with the contract docs still teaching the dead shape; both are corrected. The version and the changelog are the release; the detail is in the entry. |
||
|
|
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. |
||
|
|
2df5028883 |
relationships: the cleanup tools stop acting on links that were deleted
Deleting a relationship is soft, so the row survives with isactive False, and three things read them without knowing that. Re-adding a deleted link answered 409 "this relationship already exists" about a link the page no longer shows, and there was no way forward from the UI at all - the row cannot simply be inserted again, since the triple is unique. Reactivating IS the create for an inactive row. The inverse guard blocked on a deleted inverse, which made "remove the existing one first" - the instruction in its own message - fail to unblock anything. fix-controls-direction retired the reversed row whenever a correctly-directed one existed, without checking whether that one was itself deleted. So it removed the only live link and reported a successful clean-up. It now reactivates the row pointing the right way before retiring the one pointing the wrong way. These are the commands the docs tell an operator to run against production. |
||
|
|
c7dffce81e |
Serve an uploaded file as data, not as a document that can run
An SVG is an XML document that may carry a script, and it is an accepted image type because floor-plan maps and branding genuinely want vector. Loaded through an img tag that script never runs, so the tiles and maps were never the risk. Opening the file's own URL is - and the application image route is public, so that URL needs no session. Every route that serves an upload now goes through one helper that sends Content-Security-Policy: default-src 'none'; sandbox, and nosniff. Seven routes across core and five plugins, so a new one added later starts from the same place rather than repeating the reasoning. Banning the format instead would have cost the maps their only sensible one. The app also sent no security headers at all. It now sets nosniff, frame-ancestors self (as X-Frame-Options too, for the display bays' browsers) and a referrer policy. Deliberately NOT a page-wide CSP: this serves an SPA with inline styles, so a real script-src policy is a change worth making with the frontend in front of you, and a permissive header claiming one would be worse than having none. Contract 0.19.0. send_upload is on the shopdb.api surface, because a plugin serving user-supplied bytes should not have to remember these headers. The same bump records that get_dashboard_widgets has taken data and shape rather than a component name since the dashboard was rebuilt - that shipped without a bump, while BasePlugin and PLUGIN-HOOKS.md both still documented the shape nothing renders, which is how five plugins came to declare widgets pointing at components nobody had written. |
||
|
|
38deefe619 |
migrations: commit the plugin chains too, and bound what re-ran
Core's Alembic env got connection.commit() when the stamp bug was found; the per-plugin template did not. MySQL commits DDL implicitly, which flushes everything queued before it including the previous migration's version stamp, and the LAST migration of a run has no DDL after it - so its stamp rolled back at close while its schema change survived. flask plugin upgrade-all then exited 0 having silently re-run that migration, and re-ran it again on every deploy after. Invisible for exactly as long as every plugin head happened to be idempotent. Two were not. backups 0003 cleared lastseenat for EVERY row, which is correct once and destroys evidence on each repeat. It is now scoped to the backfill's actual signature, COALESCE(collectedat, createdat) - the expression 0002 wrote - plus a date bound. Both conditions are needed. Matching on collectedat alone misses every row whose collectedat is NULL, so precisely the rows carrying the most invented value would have kept it forever; and value equality is not a signature on MySQL, where db.DateTime is second-precision and the collector writes both stamps in one statement, so a genuinely fresh revision would read as a backfill and be wiped. SQLite keeps microseconds, which is why no test could show it. geenforce 0003 added a column unconditionally, so it failed on a fresh database built from the models and on any re-run. Guarded like network0003prefix. |
||
|
|
e67fe47fe2 |
relationships: refuse links that cannot both be true, and report the ones already stored
Prod grew rows saying both "PC controls 2005" and "2005 controls PC", and a CMM PC showing "<- controls from CMM4" beside its own outgoing link. Only one direction can be true: a PC drives a machine, never the reverse. Nothing stopped it. The duplicate check was keyed on (source, target, type), so the inverse inserted cleanly, and the Add Relationship dialog offers an incoming direction that writes exactly that. The legacy import stores controls the wrong way round as well. Directional creates now refuse the reverse with a 409 naming the row that already holds it, and refuse self-links, which render as a duplicate on the asset's own page and mean nothing. Symmetric types are exempt: Dualpath stores both directions on purpose and the card collapses them. The propagation fan-out got the same guard so a rail meant to spread one direction across sibling bays cannot manufacture a pair. fix-controls-direction only matched source assettype 'machine', so every measuring_tool, printer and network_device row it was written to clean survived it - which is why running it would never have fixed the CMM. It now matches any non-computer controlled BY a computer. New `flask relationships audit` reports what is already stored: reciprocal pairs, self-links, and PCs controlling several assets of one type. Read-only, and it prints each row's label because that usually names the writer outright - collector:* means this code made it, anything else means a person or the import did. That distinction decides the fix for duplicate device assets, which is not in this commit: the collector keys idempotency on its own label, so a device somebody created by hand is invisible to it and it mints another, and the adoption rule needs the audit run against prod before it can be written. Two false positives were found writing it, against the dev database, and both would have made the report useless. A self-link is its own inverse, so it was counted as a reciprocal pair AND printed twice. And Dualpath siblings looked like duplicate devices - a dual-bay machine is one physical machine with one controller and controls is propagated to both bays deliberately. That was 30 of 32 findings, consecutive bay numbers pair by pair. |
||
|
|
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. |
||
|
|
52eb10f5ca |
contract 0.17.0: expose DashboardDefault to plugins
The enforcement reports needed to name what a display IS, and reached straight into shopdb.core.models.dashboarddefault to do it. Plugins may only touch core through shopdb.api, and the contract test said so. The role belongs on the surface rather than behind it: it lives in core, no plugin owns it, and a plugin reporting on displays has no other way to resolve it. Added there and the version bumped, which the docs test pins. |
||
|
|
c28b02e45b |
Upload an application's image and installer instead of typing paths
Adding an application meant typing an image FILENAME and trusting someone had dropped the file into the frontend's own directory by hand, and typing an install path from memory. Both are uploads now, following the model-image trio that models and part photos already use. The two differ deliberately. The image is public, because application tiles render before anything is authenticated. The installer is not: it is licensed vendor software, an open URL would publish it to anything that can reach the site, and it is always sent as an attachment rather than rendered. Installers are capped at 500MB and the size is measured by seeking the stream rather than trusting Content-Length, which a chunked upload does not send and a client can understate. Anything larger belongs on the share, and the error says so rather than just refusing. Files are chosen before a new application exists, so they are held and uploaded once there is an id to attach them to. A failed upload leaves the saved record alone and reports, rather than losing what saved fine. Removing an installer only clears installpath when it pointed at the upload - a share path was typed by a person and is not ours to wipe. The detail page reads both shapes, since entries from the classic site hold a bare filename that is still served from /images/applications/. |
||
|
|
94d8d6c9b6 |
dashboard: numbers that agree, a map on hover, wider cards
"All assets 704" sat beside "all assets in use 737", and both were correct about different populations. The totals summed five specific asset types and subtracted dual-bay secondaries; the status counts took every asset row of any type with no collapse, so USB devices and hidden secondary bays inflated one side of a comparison the layout invites. Status is now counted over exactly the same assets the totals describe. Warranty rows fell back to asset.name when the covered asset had no hostname, and an asset's name is usually the MACHINE's descriptive name - which is how a column meant to identify a PC ended up showing a machine. Hostname, else the asset number, never the name. The machine number loses its label too: the row is hostname, machine, state, and "machine 3015" spends a word on what position already conveys. Printer names now carry the floor-plan preview on hover, the same LocationMapTooltip the printer's own page uses - a location name tells you the room, the map tells you where to walk. Declared as map.maphover on the card, so any card with coordinates gets it; a row without them shows a plain link rather than being dropped. Cards are four across rather than five. At five columns a row holding a hostname, a machine number and a state truncates on exactly the rows that matter. auto-fit, so two cards fill the width instead of leaving empty tracks. Not covered by a test: the count fix. I started one and it was interrupted, and I have not gone back for it - the assertion worth having is that in-use can never exceed the total. |
||
|
|
105345fb3d |
Release 0.9.0
Product version to 0.9.0, frontend in lock-step, Unreleased notes moved into a dated section per ADR-007. The plugin contract stays at 0.16.0: it moved after v0.8.1 and is already recorded in this release's notes, and nothing since touched the contract surface. A minor rather than a patch: collector behaviour changed in ways an integrator must know about. A reported machine number no longer becomes the PC's asset number, it builds a controls link instead; a second PC claiming a machine is treated as a claim rather than a handover; and a backup revision chain is now per source PC rather than per asset. |
||
|
|
c90ebcbc7c |
computers: declare subordinate devices instead of coding each one
A PC that drives a device which is its own asset had been implemented twice. METROLOGY_TOOL_MAP covered CMM, Keyence, Genspect and wax-trace, minting a measuring_tool. A separate path keyed on one hardcoded pc-type minted a Part Marker machine and filed it under its operation. Both create a device, link the PC with controls, and archive that link when the PC is re-imaged: one mechanism with different nouns, written out twice because the second case arrived later. That is the same trap as the site literals in ADR-015 - a pattern implemented per instance rather than declared - and it has a known next occurrence. Part markers already share operation numbers, and any site with two marking lasers or two wax-trace units on one number needs identical treatment. One SUBORDINATE_DEVICE_MAP now declares asset type, type name, naming suffix, whether the device files partof the operation, and the relationship label. The labels are unchanged per case on purpose: those values are in the production database and only rows carrying them are archived by a collector push. A site overrides or adds an entry through subordinatedevice_<pctype> settings, per ADR-015, so the next case needs no code. A malformed override falls back to the default rather than failing the push, because a bad setting must not stop a bay reporting its inventory. metrology_tool_for stays as a shim over the same map: filters.py and the older tests read it, and unifying must not change what it returns. A test pins that. Also adds flask relationships check-shared-machines, which finds the next 0615 rather than waiting for someone to notice duplicate backups. Several devices legitimately sharing a number and two PCs mis-numbered at imaging look the same from outside; the difference is whether child assets exist, so that is what it reports. Read-only. |
||
|
|
fca775c737 |
backups plugin: per-asset config backups with revision history
Adds a kind-pluggable backups plugin. Configuration captured from a PC is filed against the MACHINE it controls, with a revision history and download back to the native format. NTLARS/DNC is the first kind. Settings live in the controlling PC's registry but describe the machine, so revisions attach to the machine's asset and carry no foreign key to the PC: history survives a PC being replaced or deleted, and sourcehostname records the handover. Storage splits by kind. Parseable kinds store a dialect-neutral JSON projection in ShopDB and re-render on download; opaque vendor formats (part marker and similar) keep their bytes on the SFLD share with ShopDB holding metadata and the UNC pointer. Two .reg dialects exist in the wild: NTLARS's own Save... export omits the WOW6432Node path segment, scripted exports include it. Parsing strips whichever root matched, so a stored revision commits to neither and download offers both (NTLARS Load... by default, WOW6432Node for direct reg import). Getting this backwards is silent, so the dedup hash deliberately excludes sourcedialect and both dialects of one config dedup to a single revision. Dedup is load-bearing: the collector runs every GE-Enforce cycle across the fleet, so a revision is inserted only when the content hash differs from that asset's latest for that kind. A freshly imaged PC opens NTLARS with a blank General tab. Recording that would make an empty config the newest revision exactly when someone needs the last good one, so a blank MachineNo is rejected rather than accepted as a change. Two of the 320 known-good backups on the share already have that shape. DNC Info card summarises the latest revision on the machine page: General (Cnc, NcIF, HostType), eFocas, Serial, NTSHR when populated (only 18 of 147 machines), and MARK when the machine is a marker. MARK is gated on Cnc=MARKER or the ShopDB machine type, not on the MARK key having content: MARK carries serial defaults on 145 of 147 machines and DncPatterns reads YES on 103 including ordinary lathes, so neither identifies a marker. The info card is owned by the kind (BackupKind.infopanel/buildinfo) and served by a generic endpoint, so the expected successor to DNC ships its own card by adding a class rather than changing the plugin or the panel wiring. Also: schedule and retention settings with a prune that never drops the newest or the oldest revision, and scripts/import_ntlars_backups.py to seed history from the existing per-machine .reg files (144 of 147 resolve to assets). Codec verified against all 320 real backups: round-trips clean through both dialects. Bay-side generation verified on Windows against reg.exe export. |
||
|
|
593dd46525 |
Show the kiosk label prefix, and let a plugin declare the settings it owns
Three defects, all found on printedparts_label_prefix, all one root cause: nothing in the framework knew that setting existed. The parts kiosk runs logged out. An unauthenticated read of a setting is limited to an allowlist, the key was not on it, so the kiosk got a 404 and fell back to no prefix. An admin previewing the same page while logged in saw the prefix, which is why it looked like it worked. The same setting also looked like it would not save. The row did not exist on a site that installed the plugin before the setting was added, so the first save created it - under the placeholder category the settings API uses for keys it does not recognise, where the plugin's settings page, which lists by category, could no longer see it. The value was in the database the whole time. And the row was missing in the first place because seeding ran from on_install / on_enable, which fire only on a state transition. Neither runs again on an upgrade, so a setting added in a later plugin version never reached a site that installed an earlier one. The comment claiming enable ran every upgrade cycle was simply wrong. A plugin now declares the settings it owns in get_settings_defaults(): key, default, type, category, description, and whether a logged-out page may read it. The framework seeds declared keys at install, at enable, and on every flask plugin upgrade-all; files a first-time write under the declared category; re-homes any row left in the placeholder category, value untouched; and answers an anonymous read for keys marked public. Core carries no list of any plugin's keys. Contract 0.16.0 (additive optional hook). printedparts and printers move to the hook and floor their core_version at 0.16.0. The dev database had two rows in the misfiled state (printedparts_alert_email, employee_db_host); the first repairs itself on the next upgrade pass. |
||
|
|
959db2922b |
Release 0.8.1
The installer on the share was stamped 0.8.0 and contained sixteen commits that were not in the v0.8.0 tag. A build that misreports its own version is exactly what the version check in docs/RELEASING-WINDOWS.md exists to prevent, and it would have left two sites unable to say what they were running. Everything in 0.8.1 landed after v0.8.0 was tagged this morning, driven by two sites entering real data for the first time: the blank-code 500, the two-slide display that never rotated, modals discarding a part-filled form, filters returning an empty page, model photos that could not be saved, and the shared equipment catalog that lets a new site start with vendors, models and printer supply part numbers already present. CHANGELOG gains a 0.8.1 section, and the OpenAPI document follows __version__ rather than being restated. |
||
|
|
367bc56a6d |
Ship the equipment catalog so a new site does not start empty
`flask seed reference-data` wrote a dozen generic model types and no vendors or models at all, so adopting this platform began by retyping a catalog another site had already spent a year building. That is the largest single obstacle to standing a new facility up. scripts/export_catalog.py dumps the catalog from a live instance to shopdb/data/catalog.json, and `flask seed catalog` loads it. What travels: vendors 53, models 128, modelsupplies 146, modeltypes 35, machinetypes 21, computertypes 10, printertypes 9, networkdevicetypes 5, locationtypes 11, operatingsystems 14, measuringtooltypes 8, notificationtypes 3, accessprotocols 3 The 146 printer supplies are the most useful part after the models themselves: every toner, drum and maintenance kit with its part number, colour, capacity tier and page yield, already matched to the right model, instead of somebody reading them off spent cartridges. IDEMPOTENT and ADDITIVE. Records match on a natural key - a vendor by name, a model by vendor plus model number, a supply by model plus part number - so a second run adds nothing, and it never updates or deletes: a site that corrected a description or pointed a model at its own photo keeps its version. Catalog only. No assets, locations, employees, business units or anything with a serial number: nobody wants one plant's machines appearing at another. Vendor contact details are excluded too, since a rep's name and number belong to whoever holds that relationship. supportteams, printerdrivers and customfields are site-specific and deliberately absent. Models and supplies reference their vendor by NAME rather than id, because ids differ between databases and an id-keyed catalog would silently attach part numbers to the wrong printer. The installer offers it as a tick-box on a new "Starter data" page, defaulting to on, passing -SeedCatalog to stage 3. Offered rather than assumed: a site that machines nothing does not want 21 machine types cluttering its dropdowns. Verified by loading into an empty database and running twice: every group populated on the first pass, "Catalog already present, nothing to add" on the second. |
||
|
|
ead5bd8f58 |
Give the console a repair verb, and something real to check
A server whose migrations or seeds never finished does not fail politely. Most
pages answer 500 and settings endpoints answer 404 for keys that were never
created, which reads as a broken application rather than an unfinished install.
One site spent a morning being debugged that way.
`shopdb-admin.ps1 repair` runs what stage 3 of the installer runs: db upgrade,
plugin upgrade-all, and the three seeds. Every step is idempotent, so running it
on a healthy server changes nothing, and each step runs independently so one
failure does not silently skip the rest.
`check` now says so before anyone has to infer it:
THIS SERVER IS NOT FULLY PROVISIONED
- seed data is missing (permissions, settings or reference data)
Most pages will answer 500 until this is fixed. Run:
shopdb-admin.ps1 repair
That needs a real test to sit on, so `flask db-utils seed-state` reports each
seed group and exits non-zero when any is missing. Verified by emptying the
settings table inside a transaction: MISSING, exit 1, rollback clean. Without it
the console check would have looked reassuring while testing nothing - an older
build with no such command reports UNKNOWN rather than healthy, for the same
reason.
|
||
|
|
705dd771bd |
Store a blank optional unique field as NULL, and answer a duplicate with 409
A site reported "internal server error" adding a second business unit. It was reproducible: create one with a blank code, create another with a blank code, 500. A column that is unique and nullable accepts any number of NULLs - that is what makes "optional but unique" work - and exactly ONE empty string. The form sent '', so the first blank code saved and every one after it collided with it. The field showed no asterisk because it genuinely is optional; the database just behaved as though it were not. This is not specific to business units. A dozen columns across core and the plugins are unique and nullable - asset numbers, hostnames, item codes, subnet names, gage-lab tags - and each was one blank form away from the same 500. Fixing them an endpoint at a time would have left the next to be found by a user, so a before_flush listener normalises blank to NULL on any unique nullable text column. Listening on Session rather than on individual mappers covers plugin models imported later, and avoids mapper-event semantics that differ between SQLAlchemy versions. A genuine duplicate is now a 409 with a readable message rather than a bare 500 with a traceback in the log: reusing a code that is taken is the caller's mistake, not a server fault. Verified against the development database: three business units with blank codes all save, the blank stores as NULL, and a real duplicate code returns 409. |
||
|
|
89e880afc3 |
Release 0.8.0
The Windows installer has never shipped under a version: v0.7.0 was tagged before any of it existed, so every build handed out so far stamped a server with 0.7.0. Two servers running different builds were indistinguishable, and the installer logged each upgrade as "same version already installed" rather than recording what changed. This cuts the release that fixes that. 0.8.0 rather than a patch: the air-gapped installer is a new capability, and pre-1.0 semantic versioning puts that in the minor slot (ADR-007). CHANGELOG gains a 0.8.0 section covering the twelve defects a real Windows Server 2019 install surfaced, the move from inferring "is this a re-run of my install?" to recording it, and the operator documentation. deploy/site-profile-universal.json is now in the repository. Released builds were being produced from a profile in a temporary directory, so the next release could not have been reproduced once that file was cleaned up. docs/RELEASING-WINDOWS.md points at the committed profile and says why. scripts/gen_openapi.py reads __version__ out of shopdb/__init__.py instead of restating it. Its hardcoded copy had already drifted a release behind, which is the same mistake that once shipped an installer stamped with the wrong version. |
||
|
|
4a8bd138a9 |
feat(import): load a site's data from spreadsheets
Adopting a site means getting its asset register in. The HTTP import API suits a
site with a source system and someone to script against it; a sister site with a
spreadsheet and no developer needs something else, and that is the common case.
FOREIGN KEYS TAKE NAMES. This is the whole design. A CSV row has to say where an
asset is, and the database stores locationid, an integer. Requiring the number
means importing locations, reading back the generated ids and pasting them into
the asset sheet - a workflow nobody finishes. Every foreign key here accepts
either a numeric id or the referenced row's name:
assetnumber,assettypeid,statusid,locationid
CMM-01,Measuring Tool,Active,Gage Lab
The column keeps its database name, per CONTRIBUTING.md; the value is whatever
the operator actually knows. Names resolve across files in one run, so
assets.csv can reference a location that only exists because locations.csv was
read moments earlier. A name that does not resolve is reported with its line,
column and value, not as a foreign key violation from three layers down.
Dry run is the default, and writes go into the transaction either way - the
rollback is what makes it a dry run. Skipping the writes instead made every
cross-file reference fail, which is the one thing a folder-wide check exists to
verify. Validation covers every row before anything is written, so a typo on
line 400 cannot leave 399 rows imported. Files are matched on a natural key, so
correcting a spreadsheet and re-running updates rather than duplicates.
TEMPLATES ARE GENERATED, NOT MAINTAINED. "flask csv templates" builds them from
the live schema, annotated with required/optional and which file each foreign
key refers to. The prompt for this was a hand-written template set that had
invented columns on seven of eleven tables and named a table that does not
exist, while looking entirely plausible - and described an import mechanism
(a Data Import page, a flask import-csv command) that had never existed. A test
fails the build if a generated template ever offers a column the schema lacks.
User accounts are deliberately not importable: passwords do not belong in a
spreadsheet in either direction.
Verified end to end against MySQL 5.6 - a folder dry run catching one bad
reference, the fix, the commit, and a re-run reporting updates rather than
inserts. 16 tests.
|
||
|
|
2c415a1712 |
fix(installer): correct a false security claim, and clear the should-fix list
CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the IIS rewrite rule made the allowlist fail closed and that it does NOT become spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the rule is the only thing that does. Remove it and IIS still forwards whatever X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from 127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller can fetch manifests from anywhere on the network. The document and the _trusted_client_ip docstring now say so, waitress runs with --trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than assuming it. The wizard question is rephrased to something an operator can verify with their network team instead of guessing at. NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation accumulated em-dashes, arrows and box-drawing characters against this repo's own convention - including in files added this week. Cleaned, and the gate now uses INCLUDES_ALL so Markdown, JSON and YAML are covered. PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of which ship default_enabled=false, so every site taking the defaults installed and enabled them against their manifests. Inno has no JSON parser so the list must be hardcoded, but tests/test_installer_defaults.py now fails when it drifts. UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept its code forever - which defeats a lean build and leaves core's optional-import guards succeeding for a plugin the site no longer has. Stale plugin directories are now deregistered and removed before the copy. add-plugin used 'plugin install', which for the five default_enabled=false plugins left them installed but DISABLED - and printed a green success line anyway. It now goes through apply-profile, and the success line is gated on the exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps a stale value when flask.exe is missing and no native command runs. CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it covered the CORE chain only: plugin baselines inherited the server default, which on a latin1 server means two charsets in one database. It is now shopdb/utils/mysql_charset.py, imported by both, and preflight reports the database's default charset. BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded branding and floor-map images live in instance\ on disk, not in the database, so a restore from the .sql alone comes back with no map. backup now archives instance\ alongside it and says both are needed. VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version. Both builders now generate version.iss from shopdb/__init__.py. Smaller: rollback overwrites .env before deleting it, as uninstall already did; appcmd unlocks are scoped to this site's location rather than server-wide, with the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README plugin list gains printedparts; prune-schema --force is documented as first-provisioning-only; HTTPS is documented as not-the-default with the steps to add it; the DBA SQL is on the wizard's database page; the features page says unticking does not remove an installed feature; and the installer README states that bundle-lock cannot vouch for the exe itself - that needs signing or an out-of-band hash, neither of which is wired up. |
||
|
|
a7fe2c8353 |
fix: navigation dying after an app-pool restart
Two independent ways a restart leaves the SPA unable to navigate, both of which look identical to a user - a click that does nothing. 1. The router awaits loadEnabledPlugins() to gate plugin routes. An app-pool restart leaves that request hanging (IIS queues it while the worker starts) and axios sets no timeout, so the navigation never resolves. Worse, the promise is cached, so every later navigation awaited the same dead request and stayed frozen long after the backend recovered. Bound the wait and fail open on expiry, and drop the cached promise when an attempt times out or fails so the next navigation retries. The setup-state probe in the guard gets the same bound (it already fails open, defaulting to "complete"). 2. A deploy replaces the content-hashed chunk files, so a tab open across it asks for chunks that no longer exist and the dynamic import rejects with nothing handling it. Reload once on a chunk-load error, via router.onError and Vite's preloadError, guarded by a sessionStorage flag against a reload loop and cleared on the next successful navigation. Also stop index.html being cached: it names the hashed chunks, so a stale copy points at files the deploy already deleted. It now revalidates while the hashed assets under assets/ cache for a year. |
||
|
|
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. |
||
|
|
ea6fae91c3 |
notifications: correct timezone handling + configurable site timezone
Notification start/end times displayed and stored wrong by the tz offset (a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored UTC as naive ISO (no offset) so the browser read it as local, and the form filled the datetime-local input from toISOString() (UTC). Fix and generalize to a configurable site timezone (multi-site): - New setting site_timezone (default America/New_York), public, editable in Settings > Site > Localization (common-zone dropdown). - Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC (_parse_utc); daily-reset expiry uses the site zone (_next_site_time); calendar allDay events key off the site-local day (_site_date). - Shared frontend util datetime.js (Intl-based, DST-safe) converts between a UTC instant and a site-zone wall clock. Notification form, list, and calendar all render/enter in the site zone. |
||
|
|
b507884ad6 |
api: serve interactive OpenAPI docs at /api/docs (offline) + llms.txt
Generate docs/openapi.json (3.1, 362 operations) from the API inventory via scripts/gen_openapi.py, and serve it with a self-hosted Redoc bundle at /api/docs - no CDN, works on the air-gapped box. Also serve docs/llms.txt (a concise LLM entrypoint) at /api/docs/llms.txt. New core 'docs' blueprint; staticdocs/ excluded from the naming check (vendored minified JS). |
||
|
|
cb075a278f |
reports: pc-relationships matches PC<->machine links in either direction
Prod had 331 relationships, 268 computers, 204 machines, but the report came back empty. The query only matched computer(source) -> machine(target), while the import stores the general machinerelationships as machine(source) -> PC(target) (only the synthetic measuring-tool links are PC -> tool). So the real shop-floor edges never matched. Make the query direction-agnostic (UNION of both orientations); a PC-runs-machine report is conceptually undirected. Also drop the comtypeid=1 filter so the IP is taken from the primary communication regardless of its type. Test: a machine(source) -> PC(target) edge now appears in the report. |
||
|
|
ced356882c |
net: strip the ephemeral source port from the forwarded client IP
IIS ARR sets X-Forwarded-For to clientip:port, and the port changes every connection. Left in, the audit log showed IP:PORT, the dashboard IP fallback never matched a stored (portless) DashboardDefault.ipaddress, and login rate limiting keyed per-connection instead of per-host. Add an IPv6-safe clientip.client_ip / strip_port helper and use it in the audit log, the dashboard resolver, and the login rate-limit key. |
||
|
|
174c6c0b9a |
slides: gate management on slides.manage permission (grantable to non-admin curator)
The lobby-display and screensaver slide manager was admin-only. Add a shared slides.manage permission so a curator can manage both surfaces without full admin. Admins keep access via the require_permission admin bypass. Backend: - plugins/slides/api/routes.py: all 5 management routes require slides.manage - plugins/slides/plugin.py: declare it via get_permissions(); nav item carries the permission so the frontend can gate visibility - shopdb/core/api/auth.py: login response now returns the user's permissions (matches /me) so the frontend authStore has them on fresh login Frontend: - stores/auth.js: hasPermission(name) getter (admin true, else granted list) - router/index.js: guard supports requiresPermission - views/AppLayout.vue: hide nav items whose permission the user lacks - plugins/slides/frontend/routes.js: slide manager gated requiresPermission Tests: no-perm user 403, curator role with the perm 200 (+ login advertises it), admin 200 via bypass. Deploy: run `flask seed permissions` to create the row, then grant it to a role in Settings > Users & Roles. |
||
|
|
7a7c7f37d5 |
search: multi-word queries match by word, not the exact phrase
Global search did a single ilike('%CSF Roles%'), so any query with more than one
word required the exact contiguous phrase and usually returned nothing. Add
_word_match: split the query into words and AND them (OR across the searched
columns per word), so 'CSF Roles' matches a record with both words in any field,
any order. Applied across every domain (assets, applications, KB, employees
[selfhosted + external HR], notifications, hostnames, IP, custom fields,
vendor/model/type). External HR path uses a parameterized per-word LIKE.
|
||
|
|
3ba808028c |
dashboarddefaults: key display mappings by stable FQDN (from BIOS serial), IP fallback
A display's DHCP IP can change; its FQDN (F<serial>.<domain>, domain from the display_fqdn_domain setting) is stable and the collector already reports the serial. Add a nullable unique fqdn column (varchar191 so the index fits utf8mb4 without innodb_large_prefix), make ipaddress nullable, and require fqdn OR ip. visitor-location + display-role resolve by FQDN first, then IP; create/update accept fqdn. Core migration 7d31, verified up/down/idempotent on MySQL 5.6. 'Business unit' wording -> 'location' in the validation messages. |
||
|
|
75386d2f51 |
geenforce: resource-scope binding for fetch tokens (0.15.0)
A geenforce.fetch token can now be pinned to specific manifest scopes so a fleet-wide key (a display's, delivered by DSC or baked into the image) is not a skeleton key for the whole content store. NULL binding = unrestricted, so every existing service token keeps working. Core: - ApiToken.resourcescopes column + resourcescopelist property (migration 7d30_apitoken_resourcescopes; NULL = unrestricted). - apitokens API create/update accept + persist an optional resourcescopes list (a resource-name allowlist; not permission-catalog names). - New contract helper authorized_service_token(scope): same check as service_token_authorized but returns the ApiToken so a plugin can read its binding. Contract 0.14.0 -> 0.15.0; also export SupportTeam. GE-Enforce enforcement: - get_manifest: a bound token requesting a scope outside its allowlist -> 403. - get_payload: a bound token may only pull a blob its own scope(s) reference (service.blob_referenced_by_scopes); anything else -> 404 (no hash probing). - Decorator stashes the authorized token on g for the route to read. Also fixes a pre-existing contract-surface violation: the printers/printedparts alert helpers imported shopdb.core.models / shopdb.extensions directly; now via shopdb.api (SupportTeam newly exported). Docs: GE-ENFORCE-DISPLAY.md provisioning note, PLUGIN-HOOKS.md, CLAUDE.md. 9 new resource-binding tests; full suite 1131 passing. |
||
|
|
9d65ef103d |
geenforce: display-readiness batch (server hardening, PS client wiring, display scope)
Get GE-Enforce closer to running on credential-less Intune/Entra display PCs that pull manifest + payloads over HTTPS instead of SMB. Server (plugins/geenforce/api/routes.py): - Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*). - New tests: payload hardening, manifestblobs model-vs-migration parity, and a report-contract test locking the lowercase per-entry report keys. PS client (plugins/geenforce/client/): - Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/ exitcode/message) to match what the server reads; the engine emits PascalCase. - Enforce TLS 1.2 in the network functions. - Fetch + merge the fleet-wide common scope alongside the pctype scope (pctype wins on conflict; -NoCommon opt-out). - Normalize whatever the engine returns into a well-formed summary. - Make the empty-cache fail-safe observable: event-log entry + report ping instead of a silent exit 0. Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md): - Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt). Kiosk EXEs stay image-baked; the manifest heals policy/config drift only. - Documents the common SMB-payload audit (entries needing http/inline before a share-less display can inherit common). Migration registry (shopdb/plugins/alembic_template.py + test): - Register the pre-existing manifestblobs and the new printersupplyalerts tables in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs), printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted. |
||
|
|
fb188fd302 |
alerts: per-support-team webhook; printedparts routes low-stock to a chosen team
Support teams gain a webhookurl (migration 7d29 + API + settings-page field), so a team is a notification target. send_webhook(url=) lets a caller override the site default with a team's webhook. Printedparts gains a 'alert support team' setting (printedparts_alert_supportteamid) + selector on its settings page; low-stock alerts post to that team's webhook, falling back to the site alert_webhook_url. Email leg unchanged. Same pattern extends to other alerting plugins (printers low-toner next). |
||
|
|
38c7ec347b |
alerts: Teams webhook fan-out (contract 0.14.0) + printedparts detail revision column
send_webhook(title,text) posts alerts to an optional webhook (Teams Incoming Webhook / Workflow, or generic JSON) via alert_webhook_url + alert_webhook_format settings; send_alert fans out to it alongside email; exposed on shopdb.api (0.13.0->0.14.0, PLUGIN-HOOKS synced); low-stock posts on its custom-recipient path too. Also: recent-transactions table shows the consumed print-file revision. |
||
|
|
d141fef203 |
printedparts: show revision at kiosk; low-stock email uses gage tag + item link
Kiosk now displays the scanned revision (badge, quantity, and done screens) so the operator sees which revision they checked out - it was already recorded on the take, just not shown. Low-stock email now uses the gage lab tag (was the internal item code) and links to the item page when site_base_url is set (new core setting, category email; emails have no request context to derive the URL). |
||
|
|
60e2947fc7 |
displays: single display type with IP-driven role (dashboard/lobby/kiosk)
One 'display' image resolves what it shows from its own IP, like the existing
visitor-location BU mapping. Extend DashboardDefault with displayrole
(dashboard|lobby|partskiosk; migration 7d28, businessunitid now nullable since
only the dashboard role needs one) + a role->path map. New unauthenticated
GET /api/dashboarddefaults/display-role returns {role, path, businessunitid}
for the caller IP. Settings UI gains a Display selector, showing the business
unit only for the dashboard role.
|
||
|
|
a7ff882e21 |
Add per-role badge colors
Role badges rendered gray for everything except admin, with no way to tell roles apart. Add an optional color per role, matching how statuses and types carry one: new roles.color column (migration 7d27_roles_color), color threaded through the role API and the user serializer, and a ColorSwatchPicker in the role editor. Badges use the role's color with contrast-aware text and fall back to the old admin/gray classes when unset. |
||
|
|
c386e211df |
ADR-014 Phase 2: flask plugin prune-schema for lean per-site DBs
A lean site still gets every plugin's tables from the shared core Alembic baseline. prune-schema drops the tables of plugins not installed on this site, leaving core + chosen-plugin tables, with no edit to any released migration (the relocate-into-plugin-baselines alternative would mean rewriting ~15 released core migrations for a cosmetic gain - see ADR-014). - shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to execute; refuses non-empty tables without --force. Drops by table name (no plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine (db.engine's pooled connections sit idle-in-transaction in a CLI context and would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the prior foreign_keys pragma so the StaticPool connection is not left changed. - tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op, refuse-non-empty, force-drops-non-empty. - docs/DEPLOY.md: lean provisioning step after upgrade-all. - ADR-014 ACCEPTED; index updated. Verified on MySQL: full install then prune = no-op (86 tables); lean install (machines+printers) then prune drops the other 19 plugin tables; second run no-op. Full suite 1077 passed. |