85931db0facd8eabe27f0564298990323f0f4e79
263 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
68b86d459e |
One enrollment file names the device, for every bay without a registry
A bay with an NTLARS/eDNC MachineNo registry is identified by it, and a CMM by cmmid.txt. Everything else - Keyence, Genspect, wax-trace, part markers, and whatever a site declares next - had no stable identity at all, and the two mechanisms that stood in for one both key off the PC: reuse looks for a prior link from THIS PC asset, and minting builds `<PC number>-<SUFFIX>` where a PC's number is its hostname. Both survive a re-image. NEITHER survives a PC SWAP. A new hostname is a new PC asset with no prior link and a predicted number that has never existed, so the same physical device gets a second record while the first keeps its config and its backup history under a dead PC's name. That is how 43 legacy MT-#### tools ended up shadowed by minted twins, three records deep in places. The metrology path learned this and gained an explicit id file; the part-marker path was modelled on the metrology path as it stood BEFORE that fix, so it inherited the defect - and its own docstring said so, describing minting "the same way it already mints a CMM". C:\Enrollment\asset-id.txt is now that identity for both, and for anything declared later through subordinatedevice_<pctype>. It holds one line, the device's assetnumber, and deliberately does NOT record what kind of device it is: the pc-type already does, so a new device type needs no new file and no client change. Resolution puts it first, ahead of everything PC-derived. NOT machine-number.txt. machinenumber answers "which bay is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device there would silently stop every bay-gated manifest entry from matching. A part marker still files partof the operation from machinenumber; asset-id.txt changes which marker the PC controls, not which operation that marker belongs to. The wire field is `deviceid` rather than `assetid`, because assetid is already this contract's RESPONSE field for a PC's integer primary key - the two would have sat side by side meaning different things. measuringtoolid, shipped in 0.12.0, is accepted as an alias and the client still reads the older file, so a bay staged in the last day keeps reporting; deviceid wins when both arrive. Two guards, shared by both device families: a value that resolves to nothing warns instead of minting a phantom, and a value that resolves to the WRONG KIND of asset is refused with the asset named, so a machine number pasted into the file cannot be filed under a device label the collector also owns. Row creation is now one helper instead of a copy per path, because the named and minted branches both need get-or-create on the extension row and the control link. 1744 tests green, including a new file that pins the swap case both ways - with the file one device, without it two. Five of its tests failed first time because the fixtures built bare assets with no extension row and the type guard refused them, which is the guard working. VERIFIED ON WINDOWS 11 (build 26200), five cases: the new file alone; both files present, new winning; the legacy file alone; neither, sending no field; and a padded value with a trailing line. |
||
|
|
21afa0b56e |
Let a metrology bay name the instrument it drives
The server has accepted `measuringtoolid` since the adoption work landed, and it is the FIRST entry in the resolution order precisely because it is the identity that survives a PC swap. Nothing ever sent it. The reporter read the eDNC registry, cmmid.txt, machine-number.txt and pc-type.txt, and its own comment said metrology bays have no per-bay id and therefore send nothing - so a Keyence or Genspect bay fell through all four steps to minting, which the server's own docstring calls the last resort. Minting derives the asset number from the HOSTNAME, so a permanent instrument inherits the identity of whichever PC drove it that week: replace the PC and either the number lies or a second tool appears for the same physical unit. That is how 43 legacy MT-#### tools ended up shadowed by minted twins. The half that prevents it was built, tested and undeliverable. The reporter now reads C:\Enrollment\measuringtool-id.txt and sends it when present. Its own file, NOT machine-number.txt: machinenumber answers "which bay is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a tool there would silently stop every bay-gated manifest entry from matching. The paste-ready reporter in COLLECTOR-INTEGRATION.md is a second implementation of the same payload, so it gets the same resolver rather than being left to drift. The field was also missing from the payload table and from the classic api.asp mapping, and there was no prose anywhere describing how a tool is resolved - added, including why minting is last and what the two guards refuse. VERIFIED ON WINDOWS 11 (build 26200), four cases: a named instrument is read and sent; no file sends no field and exits 0; a whitespace-only file behaves as absent rather than sending an empty string; and a padded value with a second line yields the first line trimmed. |
||
|
|
3f7cc37b00 |
Spread a driver rollout across waves so the fleet cannot stampede its own share
GE-Enforce gives each PC a start offset of SHA256(hostname) % 5 MINUTES and then repeats every five minutes. That was sized for reading a few KB of manifest JSON. A driver set is 100 MB for the two universals and 226 MB for a full site, so the day a driver entry lands, every bay pulls it inside one five-minute window: roughly 30 GB across 300 bays, at something like 800 Mbps, on the same share the whole floor needs for everything else. The failure mode is not slow printers, it is a floor that stops converging. -WaveStart with -Waves spreads that out. Each PC derives its own wave from its hostname, so there is no central coordination, no per-bay configuration, and no list of who has had it yet. The hash is the same idiom Register-GEEnforce.ps1 already uses for its start offset, SHA-256 rather than MD5 because FIPS-enforced bays disable MD5 outright and would throw. Measured over 300 hostnames at 10 waves: 23 to 44 bays per wave against a mean of 30, so the peak wave moves about 4.3 GB rather than the 3 GB an average implies. Hash bucketing is uneven and the peak is what sizes a link, so do not quote the mean. THE GATE RUNS BEFORE THE MANIFEST IS READ, because the manifest is on the share too. A bay that is not due must not touch the share at all - one read is cheap, 300 bays deciding to read in the same five minutes is the entire problem. It FAILS CLOSED on an unparseable date. Failing open would restore exactly the stampede this exists to prevent, and 30 GB cannot be un-sent, whereas a typo that installs nothing says so in the log every cycle and is fixed in a minute. A bay powered off during its wave installs on its next cycle instead. The wave is an earliest-time, not a deadline, so nothing needs chasing afterwards. -TestOnly reports a bay whose wave has not opened as COMPLIANT, because not installed is genuinely its desired state today; DSC would otherwise call SetScript every pass to be told to wait. -IgnoreWave is for proving a pilot bay before opening anything. Verified on Windows 11 build 26200, six paths: not-due installs nothing and exits 0; TestOnly while not due exits 0; a garbage date exits 1 having installed nothing; -IgnoreWave installs against a future start; an opened wave installs; and no wave arguments at all installs, which is what imaging needs. |
||
|
|
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. |
||
|
|
1a5a1cd43d |
Correct a drifted print queue instead of leaving it wrong
A queue was matched by NAME alone, so a bay whose printer had moved, or whose queue was built on a driver the site has since replaced, looked converged and printed to the wrong device. Absence was fixed; drift was not. Set-ShopdbPrinters.ps1 now repoints a queue whose port does not match the address ShopDB holds, and swaps a queue built on the wrong driver. Both are things ShopDB is authoritative about: where the printer IS, and what drives it. CORRECTED IN PLACE with Set-Printer, never removed and recreated. The queue keeps its name, its sharing, its permissions, and whoever has it as their default keeps it - which is what makes this safe to run every cycle on a live floor. There is still no removal code path in this script at all. Two guards, because a repair that breaks a working printer is worse than drift: the driver is only swapped when the wanted one is actually staged, and -WhatIfOnly reports both kinds of correction without making either. Verified on Windows against a queue that had the right name, the wrong port, the wrong driver AND was the logged-on user's default: both fields were corrected and the queue was still the default afterwards. The earlier no-op guarantees were re-run and still hold - nothing assigned changes nothing, and an assignment with no default leaves the user's own default alone. |
||
|
|
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. |
||
|
|
8cedf674fb |
Resolve a driver by vendor, and converge a bay's printers from ShopDB
Two rows now cover 41 of 44 printers. printerdrivers could only bind a driver to ONE modelnumberid, so the HP and Xerox universal drivers - which between them cover almost the whole floor - would have needed 21 near-duplicate rows pointing at the same package. That is a table nobody keeps true, and it is why 42 of 44 printers resolved no driver at all. printerdrivers gains vendorid, and resolution runs most-specific-first: the printer's model, then its vendor, then the pre-vendorid convention of matching the vendor word in the driver's name so a site that populated the table before the column existed does not silently lose every driver on upgrade. A row that names a vendor is never matched by its text, because a mis-set vendor resolving to the wrong package is worse than resolving to none. Six rows now resolve 44 of 44 printers at the reference site, and the DesignJet correctly takes its own driver over the HP universal one. Set-ShopdbPrinters.ps1 is the client half: ask for-host, create the queues that are missing, record the desired default. It NEVER removes a queue - a bad minute from the API must not take printers away from a working bay - and it never fetches a driver, because downloading 48 MB while somebody waits to print is the wrong moment. The common scope stages those. Apply-ShopdbDefaultPrinter.ps1 applies the default in the USER's context, which is the only context that can: SYSTEM cannot set a per-user default for somebody else. It also turns off "Let Windows manage my default printer", without which Windows silently overwrites the choice the next time anyone prints elsewhere - a fix that undoes itself within a day. VALIDATED ON WINDOWS 11 AGAINST A LIVE SHOPDB, not only by tests. Printers were assigned to a MACHINE; a PC controlling it, holding no rows of its own, created both queues bound to the right universal drivers, recorded the default and set it, and a second run changed nothing. The first attempt failed with "Relationship types are not seeded - run: flask seed reference-data", which is the deployment trap the plan predicted, caught by an explicit error rather than silently resolving nothing. |
||
|
|
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.
|
||
|
|
03d0754fdc |
Stage printer drivers as a deployable set, for the common scope
Assigning a printer to a bay is useless if the bay cannot install it, and the fleet data says why that mattered: 42 of 44 printers could not resolve a driver. This is the delivery half - the drivers themselves, staged once per bay, so that creating a queue never waits on a download. Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1 does a site's whole set from drivers.json, and answers a compliance question with -TestOnly, which is what makes it a clean DSC Script resource rather than a fire-and-forget install. Deliberately SEPARATE from assignment. Drivers are large, near-identical across a fleet and change rarely; assignments are small, per-bay and change often. Staging the set in the GE-Enforce common scope means the assignment client only ever creates a queue - it never fetches a 48 MB package while somebody is waiting to print, or discovers the share is unmounted at the worst moment. THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it. certutil -addstore on the .cat file satisfied the Xerox package and failed every HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been established as trusted" - a coin toss, not a mechanism. The certificate is now extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for every catalog under the package rather than the first INF's neighbours. On a locked bay there is no prompt to answer, so the old failure was silent. Verified on Windows against real packages, not by reading: all six drivers this site needs install through the script, a second run is a no-op, a wrong driver name fails with the names the package actually offers, and the DSC cycle behaves - TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0. The packages themselves stay out of git: they are licensed vendor binaries, and they belong on the share beside the other imaging payloads. DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the Intune shape, plus the constraint that has cost a session before: the SFLD share is mounted only during the enforcement cycle, so this runs as a manifest entry and never as its own scheduled task. |
||
|
|
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. |
||
|
|
96f127f8c8 |
Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the adoption guide, "provided on request" - which is not a distribution mechanism for a product meant to be adopted. It now lives in plugins/computers/client/, beside the collector contract it implements (ADR-006), so the two version together. Three things named West Jefferson and no longer do (ADR-015): - The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value Install-GEEnforce.ps1 already writes and the enforcement client cannot run without - or from -ApiUrl. With neither it logs and exits 0 rather than posting somewhere wrong. Any bay running this script runs the enforcement client, so the value is present wherever it is deployed. - The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was reported, with a comment reading "update if site re-VLANs". A site may now name its ranges (-AllowedRanges, or a CollectorRanges registry value); with none configured the NIC carrying the DEFAULT ROUTE is used, which expresses "the routable NIC, not the controller NIC" without knowing any site's addressing. VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The local array was called $allowedRanges, which is the SAME VARIABLE as the [string] parameter $AllowedRanges because PowerShell names are case-insensitive; the array was silently coerced to an empty string, and .Count on a scalar string is 1. The script therefore believed a range was configured, skipped the default-route fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to $rangeList, and the four paths were then confirmed on Windows: no config skips cleanly, BaseUrl resolves from the registry, an unconfigured site picks the default-route NIC, a configured range selects or excludes as asked. |
||
|
|
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. |
||
|
|
9c1c6c5729 |
fix: page past the 100-row cap in application pickers
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports nothing about having done so, so a caller asking for perpage: 1000 gets the first 100 rows and a success response. Every picker built that way looked complete and was not. Found on a live site with 126 active applications: the 26 sorting last were absent from the knowledge-base topic dropdown, so an article could not be filed against them. Nothing was wrong with those application records, and editing them could never have helped. Adds fetchAllPages() to the api module, generalizing the one call site that already handled this correctly (modelsApi.listAll), and points the four application pickers at a new applicationsApi.listAll(): the KB article form, the KB list's topic filter, the notification form, and the report filter builder. Lists that render a page at a time are untouched - they page for a reason. Other callers still asking for more than 100 rows of vendors, locations, models, subnets and the rest are latent: correct only while those tables stay under 100, and silent on the day they do not. |
||
|
|
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. |
||
|
|
c648bdf560 |
labels: one module knows how to draw a code, seven views stop guessing
Three core pages and four plugin pages each imported qrcode and jsbarcode directly, and each carried its own answer to the same questions: what margin, what width, which error correction, how big a module must be before a scanner can read it. The answers had already drifted - margin 0 in one place and 2 in another, width 150 against 160 - and on a label that is the difference between a sticker that scans and one that does not. frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone and margin defaults, CODE128 with no printed value, and the printer-resolution arithmetic that only the Tech Tools generator had. A view passes what is specific to its own label and nothing else - MachineBadge still asks for CODE39, because the badge readers predate the shop-floor scanners and decode nothing else, and that is exactly the kind of thing a call site should say out loud. views/print/qrLogo.js is folded in rather than left as a second half-shared helper that only some of the pages reached into. The check script now fails a build that imports either library outside that module. Without it this re-forks within a month: the next label page starts by copying the nearest existing one, which is how it happened the first time. Tests cover the part no amount of looking at a screen verifies - a QR that looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch stock. |
||
|
|
035419fa51 |
ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule that only prints becomes. It now FAILS the build, and it looks where the leaks actually were: PowerShell, the installer, the seeds, generated JSON, the frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools. A line that is deliberate declares itself with an ADR-015-OK marker and a reason, so the claim is visible in review instead of tolerated in silence. What it found, fixed here: - The shadow client wrote one site's ShopDB URL into HKLM whenever the registry disagreed. At the site it was written for that reads as healing drift; anywhere else it overwrites the site's own address on every enforce cycle, and the site cannot win because the cycle repeats. The bay's value now wins, an explicit -BaseUrl seeds it, and with neither there is nothing honest to write, so it says so and skips. - The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a kiosk elsewhere quietly opened a server it has no business reaching. The fallback is now this site's site_base_url, baked in at seed time, and the dispatcher refuses rather than guessing when neither is set. Its legacy shortcut matcher derives the host from that URL instead of naming one. - The OpenAPI generator hardcoded a production hostname into every spec it generated, which then published to a public wiki. The relative mount is the only server it can honestly name; a site passes its own by environment. - Placeholders and examples in the UI and the client help offered real internal subnets and a real production URL. They now use documentation ranges. Both publication gates - the export scrub and the docs publishability test - carry the site patterns, which neither did. One plant's hostname, FQDN and internal networks are out of the documentation and the generated specs. Comments naming the reference site are reworded rather than deleted: the reasoning is worth keeping, the plant name is not what makes it true. |
||
|
|
4d6ab741cc |
backups: retention reads the key the settings page actually writes
get_setting on BasePlugin namespaces what it reads to plugin.backups.<key>, while get_settings_defaults declares - and the settings page writes - the bare key. So the retention read never found the operator's value and always fell back to 0, and 0 means keep everything. Retention was configurable in the UI and did nothing. It is the only place in the codebase using the namespaced helper. The share root also stops shipping one site's file server as its default. That put a site's internal topology in a bundled plugin and in the public mirror, and pointed a second site at a server it cannot reach. Blank now, per ADR-015, and a share kind with no configured root returns nothing rather than composing a path from somebody else's hostname - a path built on an empty root is not a lesser answer, it is a wrong one. |
||
|
|
838932a72d |
geenforce: gate publishing on the library version, not on the manifest's own
The publish gate exists because a minor version bump that NARROWS behaviour is not backward compatible: _CmmVersion arrived in lib 2.6, and an older lib does not know the field, so every gated entry looks unfiltered and it installs every PC-DMIS version it cannot detect, on every CMM, in one cycle. It was comparing the fleet's reported library versions against manifestversion. That is the manifest's own 'Version' field. For a share-imported manifest the two numbering schemes happen to coincide; for a scope authored in code they do not, and seed_display_scope writes '2.0' - which every kiosk exceeds. So the gate passed on the scope that most needed it. A scope now declares minlibversion. Unset, the requirement is DERIVED from what the manifest actually uses, so a scope written before this column existed is still judged on its contents rather than on a number that says nothing about the library. Only features that narrow behaviour belong in that table; an addition an old lib ignores harmlessly needs no floor. manifestversion remains the last fallback, which preserves what share-imported manifests already relied on. |
||
|
|
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. |
||
|
|
d830dd49a9 |
collector: adopt an instrument before minting one, and stop the 500 loops
Minting derived a measuring tool's asset number from the HOSTNAME, so a permanent instrument inherited the identity of whichever PC drove it that week: replace the PC and either the number lies or a second tool appears for the same physical unit. And because idempotency was keyed on the collector's own label, it could not see a tool it had not created - on prod that left 43 legacy MT-#### tools shadowed by minted <HOST>-CMM twins, three records deep in places. Resolution order is now most-stable-identity-first: the instrument named by measuringtool-id.txt, then a prior collector link, then a tool this PC already controls that somebody else created, then the reported machine number, and only then mint. What minting produces should be read as a placeholder until a real identifier is recorded. Three separate 500 loops came out of the same mistake, looking a relationship up by LABEL when assetrelationships is unique on (source, target, type): - On a CMM the instrument IS the reported bay, so the machine sync has already made a row for that exact triple - and it finds its own rows by that label. Relabelling hid the link, so the next cycle built a second row for the same triple and MySQL rejected it: 200 once, then 500 forever. The machine link is now recognised and left alone; adoption only needs the identity. - A part-marker PC hit it twice over, once on its partof row and once because the marker's asset number is derived from the PC and could already be taken. Both are get-or-create on the triple now, and an existing asset of that number is adopted rather than duplicated. A named instrument also supersedes a minted twin properly: the stale link is archived by TARGET, not by object identity, which is what left a PC reading as the controller of two instruments. Reported identifiers are matched exactly rather than with ilike. They arrive from a text file on a shopfloor PC, and ilike reads _ and % as wildcards, so MT-600_ adopted MT-6001 and a bare % adopted whatever active asset came first. A named id that is not a measuring tool is refused with a warning rather than linked as one. |
||
|
|
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. |
||
|
|
ab301df9ac |
network: generate a device's asset number instead of asking twice
Every network device on this fleet already follows one convention, applied by hand: AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. 45 records, no exceptions. The create form demanded the asset number anyway, so the same value was typed twice and the convention held only as long as everyone remembered it. The prefix now lives on the device type, and a blank asset number is generated as <PREFIX>-<name>. Left explicit, an asset number always wins: a device carrying a real identifier of its own - a vendor tag, a controller name, a serial - keeps it. That is the platform rule, adopt where an identifier exists and derive only where none does. The prefix is NOT derived from the type name. "Access Point" and "Access Panel" both initialise to AP, and assetnumber is unique, so the second type would collide with the first on every device it created. It is nullable, so a type that wants no prefix generates the bare name rather than needing one invented. Names are sanitised before they reach a business key - the existing data already shows why, with IDF-Telco-Demarc-#1 carrying a '#' into an identifier. An existing prefix is never stacked: IDF-03 under type IDF stays IDF-03. |
||
|
|
1078ac03df |
geenforce: shadow runs inside the enforce cycle, not on its own clock
Test bays reported 0 installed / 0 skipped / 0 failed and nothing was being processed. The share is a TRANSIENT mount: GE-Enforce.ps1 maps it for the length of its cycle and unmounts at the end - mounted 12:10:02, unmounted 12:10:27 on FB9TP7V3. The shadow task ran on its own 15-minute schedule, so it woke with the drive gone, handed the engine a path that no longer resolved, and the summary zero-filled. A silent nothing, indistinguishable from a healthy no-op, which is why it read as "not processing" rather than as a failure. Register-ShopdbShadow.ps1 is replaced by Invoke-ShopdbShadow.ps1, which runs AS a manifest entry and invokes the runner directly. The share is mounted because the enforce cycle is what called us; there is one cadence instead of two; and there is no task to register, drift, or heal. It also unregisters the superseded task, so bays that already carry it clean themselves up rather than keeping a run that cannot see the share and overwrites the real result with 0/0/0. The runner gains the Test-Path guard that should have been there: an unreachable -ShareManifestPath now logs, writes an event, and reports a real failure naming the reason, instead of running the engine against nothing. That silent zero is what made this expensive to find. Verified on the win11 VM with a substituted drive removed between runs - the same call gives real counts while mapped and the guard when not. The earlier task self-heal is moot now; it was a fix at the wrong layer, and testing against a local path is what hid a mount I had already noted was dynamic. |
||
|
|
dfbc12a172 |
printers: part numbers for the HP 3201 / 3301 series
A 3201dw is monitored and alerts correctly - Zabbix reports its cartridge names and levels straight off the printer - but the Toner Report had no reorder chip for it, because part numbers come only from modelsupplies and no family matched. lookupsupplies returns [] for a model with no rows, so the printer showed levels with nothing to order. Adds the 218 family: 218A standard and 218X high yield in all four colours, matched on 3201 and 3301 so the MFP siblings are covered by the same entry. Part numbers and yields cross-checked against three independent sources rather than inferred from the series - a wrong toner number means someone orders a cartridge that does not fit, which is worse than the blank chip it replaces. No drum or waste unit: none is listed as a separately orderable part for this series, so the family is toners only. |
||
|
|
4bded210e4 |
geenforce: the backup hover names both dates, because they mean different things
"Checked 13 Aug 1:20 PM. Verified the backup taken 12 Aug 5:20 PM is still current." Two facts, and one date could not carry both. lastseenat moves on every successful confirmation and proves the check is still running. collectedat moves only when the configuration actually CHANGED and says what is stored. Showing lastseenat alone hid what is in the backup; showing collectedat alone read as "last backup was a month ago" on a machine that is perfectly healthy, because a stable config writes no new revision. The hover now states the check and the capture separately and says outright that the second being older is the normal case. The stale wording gets the same treatment: it names the check that stopped AND the newest copy that exists, which is the thing someone recovering from it actually needs. collectedat is exposed as backupcollectedat. Both stay tooltip-only - the badge is still just the kind and a colour, so nothing here changes what a new backup kind has to do to inherit it. |
||
|
|
1d7191c2d3 |
geenforce: the fleet table links where it says, and judges backups instead of dating them
Two fixes to the same table, in the same regions of the same files. ASSET LINK POINTED AT THE WRONG RECORD. The Asset chip linked /machines/<assetid>, but /machines/:id keys on machineid - the plugin extension id - as MachineDetail itself does everywhere. So the link landed on whichever machine happened to carry that number: a wrong page that looks right, which is worse than a 404. Same for /measuringtools/. The API now returns machinepluginid / toolpluginid beside the asset ids and the view links on those. Both lookups are import-guarded, and with no plugin id the number renders as plain text rather than a link that misleads. AssetRelationships already resolved this correctly; this brings the reports table in line. BACKUP COLUMN READ AS NEGLECT. It showed a raw date, and a revision is only written when the config CHANGES - dedup means a machine stable for months has a months-old newest revision and is perfectly healthy. The column already used lastseenat, the last time the collector CONFIRMED the config, but a bare timestamp says "nothing has happened since", which at the default 24h collection interval IS the healthy steady state. It made a working system look stalled and made the reader do arithmetic against a setting they would have to go and find. It now returns backupok and shows a badge naming the kind, green when confirmed recently, red when not, with the date and an explanation in the hover. backupok is tri-state on purpose: null means no revision at all, and renders as NO badge rather than a green one, because "never seen" must not read as healthy. The threshold is the backups plugin's own backups_staledays, read through its service so there is one definition of stale rather than a second drifting here. Nothing in the badge is kind-specific, so a backup kind added later inherits it by existing. docs/BACKUP-KINDS.md records that, the BackupKind contract, and why the rule is time-based rather than per-kind. |
||
|
|
6dc363411d |
geenforce: the shadow task actually runs, and says so on disk
Registered fine and never fired once. Three faults, all found on the win11 VM. A `-Once -At (Get-Date)` trigger does NOT run immediately: its first run is the start boundary PLUS the repetition interval, 15 minutes out. This ships as a DetectionMethod=Always entry, so it ran every enforce cycle, 5 minutes apart, and each Register-ScheduledTask -Force reset the start boundary to "now" - pushing the first run back to +15 before the previous +15 could elapse. 5 < 15, so the task sat Ready at LastTaskResult 267011 (SCHED_S_TASK_HAS_NOT_RUN) forever. It now registers only when the task is missing or its arguments changed, and starts it once on first registration rather than waiting out the first interval. A bay provisioned by the broken version carries a task with correct arguments that has never run, so "leave it alone if it matches" would have stranded exactly the machines that hit the bug. If the task has never run it is kicked once; after that LastRunTime is set and the check is a no-op. None of this was visible. The engine records only "ps1: <path>" and an exit code for a PS1 entry, so Write-Host reached nothing, and with the fail-safe `exit 0` on every path a silent early-out was indistinguishable from success. It now also writes C:\Logs\Shopfloor\shadow-setup-<date>.log. Scope is no longer hardcoded either: this script is shipped by more than one scope now, and a wrong value would shadow the wrong manifest silently. It is derived from the script's own directory, the same way the share manifest path already was, so the two cannot disagree. |
||
|
|
3d83806135 |
Make the toner forecast an order, not a table
The report answers a purchasing question, and it was answering it in seven columns, two tables and a rowspan. What someone actually needs from it is a short list of what to buy. So it opens with that list, grouped by part number with a quantity. Two cartridges of the same part in different printers is a quantity of two, which is the number an order needs and the one a per-printer table made the reader count by hand. It covers what is empty plus what goes within a fortnight - ordering only what is already empty means running empty. There is a copy button, because it ends up pasted into a mail. Below it the cartridges sit in urgency bands rather than in one long list sorted by a number. The question is which pile a thing is in, and a pile that is empty is worth seeing as empty. Everything past "empty" starts collapsed; the order list above already covers the same ground in a tenth of the height. The row is a cartridge now, not a printer, so it can carry its own part number, its own level bar and its own countdown. Nesting supplies under a printer meant opening a printer to find out whether anything on it needed doing. Cartridges with no part mapped are counted on a single line rather than given one each. They cannot be dropped, since that would quietly shorten the order, and they cannot be ordered from here either - the job they represent is mapping them, which is one job however many there are. Bands and the order horizon are decided server-side, next to the arithmetic that produces them, so a heading cannot disagree with what got added to the list. Checked against a fleet of 43 dev printers with real part mappings, driven by a stub Zabbix - live Zabbix is not reachable from the dev box. |
||
|
|
6cdbea449a |
geenforce: ship shadow mode through the share, and correct the cutover doc
The cheap cutover the doc described - manifest over API, payloads still on the
share - does not work. Measured on the win11 VM against lib 2.6 with the same
manifest and engine, varying only InstallerRoot: the payload cache gives
0 installed / 2 failed ("CMD/BAT not found" under the cache), the scope
directory gives 2 installed / 0 failed.
Two causes. Invoke-ShopdbEnforce.ps1 takes no -InstallerRoot and hardcodes the
payload cache outside shadow mode, which is right for displays where every
entry is inline and wrong for every smb entry. And -IncludeCommon cannot work
for smb payloads at all: they live under their own scope directory, so a merged
manifest needs two roots where the engine takes one, and Join-InstallerPath
rejects rooted values by design so absolute paths are not a way out. The real
dispatcher never merges - it calls the engine once per scope with that scope's
root - and a fix should follow that shape.
Both claims are corrected in the doc rather than deleted, because reading it
was what made this look like a twenty-minute job; it would have failed 29 of 31
entries on the pilot bay.
Shadow mode is unaffected - it points InstallerRoot at the share scope dir,
which is the working path - so the pilot proceeds. Register-ShopdbShadow.ps1
is the reference copy of the script that puts a share-attached bay into shadow:
it writes BaseUrl, then registers the task as SYSTEM. It derives the share
manifest from $PSScriptRoot rather than hardcoding a drive, because GE-Enforce
mounts the share to a letter it picks at run time and SYSTEM has no standing
UNC access. Delivered as a manifest entry gated to one hostname, so the pilot
needs no hands on the bay and heals if it drifts.
|
||
|
|
7d66551622 |
Forecast from the right end of the window, and from the level shown
Four defects stacked into one nonsense report: cartridges at 20% claiming four days, cartridges at 1% claiming weeks. The root cause is a Zabbix API detail. `limit` caps the whole result set rather than each item, and the query sorted ascending, so the cap kept the OLDEST rows in the window. A four-cartridge printer polled every five minutes writes over 100k readings in 90 days; the forecast was fitted to the first few days of that and nothing since. Every rate was real and every rate described a cartridge thrown away three months ago. Nothing in the output looks wrong, which is why it needed pinning in a test rather than a comment. A 90-day burn rate does not need every individual poll, so a long window now reads hourly trends - the table meant for this, a tenth of the rows, and kept longer. Raw history serves short windows and any item a site keeps no trends for. Both are fetched newest-first with the budget scaled per item. Second, the countdown was computed from the last stored reading while the level displayed was the live one, so the two could disagree by a whole cartridge. The live level is now what the countdown divides. A live level far above the stored run means it was swapped since the last reading, and that is reported as a replacement rather than as a collapse in the burn rate. Third, at or below 5% a cartridge reads as empty rather than as a slow drain. At 1% losing a tenth of a point a day the arithmetic says ten days. The printer is out of toner, and it is the first thing to order. Fourth, the days-left column spanned the printer's rows, so the printer's soonest figure was printed beside every supply it had. That alone accounts for the shape of both complaints: a healthy cartridge wearing its neighbour's deadline, and an empty one wearing a number that belonged to nothing on its row. Also fixes float-typed supplies vanishing from any printer that also had an integer-typed one - they live in different history tables and the fetch stopped at whichever answered first. Not verified against live data: Zabbix is not reachable from the dev box. |
||
|
|
1e884dc02a |
geenforce: judge silence on both clocks, not just the server's
The fleet table has two time columns and staleness only looked at one. A PC can go quiet in either direction: silent to the server, or still posting while its own cycle has stopped advancing. Only the first was caught, so a machine whose Last check-in had not moved since the previous morning still showed 'ok'. The two are tested independently rather than by taking the newer of them. receivedat is the server's own record and cannot be argued with; lastcheckin is asserted by the client, so a wrong clock there must not be able to vouch for a PC the server has not actually heard from. Checking them separately means a future-dated lastcheckin cannot mask real silence. The tooltip now names both times, since which one stopped says what kind of failure it is. |
||
|
|
962979d483 |
geenforce: a PC that has gone quiet stops reading as healthy
A report records how ONE cycle went. Nothing ages it, so a PC that stops reporting keeps the status of its last good cycle: switch a machine off after a clean run and it shows 'ok' indefinitely. One had been offline more than a day and still read 'ok'. Silence is a different fact from the last cycle's outcome, so it is computed separately rather than by rewriting the stored status. receivedat - the server's own clock, not anything a client asserts - is compared against geenforce_reportstaleminutes, default 30, which is roughly two missed cycles at the usual cadence. Set it to 0 to turn the check off. In the table 'stale' takes the badge, because a status from a machine that has not spoken since is not evidence of anything. What it last reported stays in the tooltip with the time it was heard. The stored status is untouched: it is still a true record of that cycle, just not proof the PC is alive. A site whose scope enforces less often than the threshold will read stale while healthy, which is what the setting is for. |
||
|
|
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. |
||
|
|
5de3594425 |
displays: the client module updates itself
Install-ShopdbKiosk.ps1 lays the enforce client down once at bootstrap and never refreshes it. So a client change rode the code deploy to the server and then sat one directory away from where kiosks actually fetch, waiting for someone to re-stage the installer bundle by hand - which is how the new display-type reporting reached prod and changed nothing on any kiosk. The module now ships as a manifest entry like everything else in this scope: inline over HTTPS, Hash detection against the exact bytes shipped, written to the same path the installer uses so bootstrap and self-update cannot disagree. Ordered first, so a stale client refreshes before anything leans on it. The installer keeps its real job - a fresh kiosk still needs something that can talk to shopdb - it just stops being the update path. Self-modifying by design: this module is what stages payloads, but PowerShell loads it into memory at start, so rewriting the file mid-run is harmless and lands on the next cycle. Pilot a client change on ONE kiosk before the fleet: a broken module cannot fetch its own replacement, and on a share-less display that means a site visit. |
||
|
|
84bf5d04ed |
geenforce: let a kiosk say what it is instead of guessing
A display knows whether it is a Dashboard, a Lobby screen or the 3D print room - the dispatcher reads C:\Enrollment\display-type.txt to choose which page to open. It never told shopdb, so the fleet table inferred it from the DashboardDefault fqdn mapping, which is empty unless somebody added a row per kiosk. The column was blank for every display. The client now reads that file and reports it, the report stores it, and the API prefers the reported value with the old mapping left as a fallback for hosts still on an older client. Reported by the device beats inferred from a lookup table, the same way enforcerversion already works. A PC with no display-type.txt reports nothing rather than something invented, and an empty string lands as NULL. Two guards had to learn about it. The DDL parity check read only the 0001 baseline, so a column added by a later revision looked like drift even though its migration existed; it now runs the whole chain, which is what 'do the models match what the migrations build' means. 0002 added a whole table rather than a column, which is why this is the first time it bit. |
||
|
|
d572c913e5 |
geenforce: let the wide pages use the page
Two caps, one inside the other: the section shell at 1400px and the reports view at 1100px. Fine for nine columns; the reports table now carries twelve, including the asset a PC drives, its location and its backup state, so it was being squeezed while empty page sat to the right. Both are gone, and the manifests editor gets the width too. The Asset column no longer falls back to the PC's own asset number. The collector stores a PC's hostname AS its assetnumber, so for any PC that drives nothing that column just repeated the Host column next to it. It now shows a dash, and carries content only when it says something Host does not: the machine a bay PC controls, the tool a measuring PC controls, or the role of a display. |
||
|
|
f1f573862d |
geenforce: order backup revisions in Python, not in MySQL
The reports table 500'd on every load: the backup lookup ordered with ORDER BY lastseenat DESC NULLS LAST, which SQLite accepts and MySQL rejects outright. Every test passed and the real database refused the query - the tests run on SQLite, so the dialect difference was invisible. Sorting in Python removes the dependency for nothing: the rows are one per host per kind. The regression test pins which revision wins, including that one never confirmed does not, and says why the sort lives here so it does not get helpfully moved back into SQL. |
||
|
|
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. |
||
|
|
598c2c98bc |
geenforce: reports say what the PC is, where, and whether it is backed up
A report row carried a hostname and some counts. Everything an operator wants next hangs off the asset behind that host, and none of it was there. Host now links to its PC page. Beside it, what the PC IS or DRIVES: the machine number for a bay or part-marker PC, the measuring tool for a tool PC, the role for a display - each linking to that asset's own page. A map pin appears only when the asset has coordinates and hovers to the same floor-plan preview the asset pages use; an icon that opened an empty map would be worse than no icon. The backup column is LAST CONFIRMED, not last changed. Dedup means an unchanged config writes no revision, so a machine stable for six months has a six-month-old newest revision and is perfectly healthy - the one to worry about is the machine whose backup stopped running. It reads lastseenat and names the kind rather than assuming ntlars, since udc/file kinds on the share are coming. Resolution is bulk, never per row: this table shows the whole fleet, so a lookup inside the loop would be one query per PC. It reads the collector's existing 'controls' relationship rather than re-deriving which machine a PC drives - that same resolution living in two places is what put a wrong subtype filter on the map. Every plugin it touches is optional, so each lookup is ImportError-guarded and a lean build renders the table without those columns. A host ShopDB has no asset for still shows: the enforcement result is real even when the inventory is behind. |
||
|
|
523e3e4ecc |
geenforce: give the manifest entry rows room to breathe
Every column in the entries table was sized to its button text with nothing spare, so with flex-wrap on the action cell the Edit and Delete buttons wrapped out of their 128px column and sat on top of the entry description. The order column was tighter still, and its Up/Down buttons carried almost no padding, which made them hard to hit as well as hard to read. Widened the order, type and action columns to fit their contents on one line, stopped the action cell wrapping, gave the buttons real padding, and added vertical cell padding with top alignment so a two-line entry no longer runs into the row beneath it. |
||
|
|
9e34fafce5 |
geenforce: refuse to publish a manifest the fleet's lib cannot read
The engine treats a minor-newer manifest as backward compatible and carries on. That holds for additions which WIDEN behaviour - an old lib skips a Type it does not know - and inverts for one that NARROWS it. _CmmVersion arrived in lib 2.6 as a minor bump, so a PC on 2.5 does not recognise the field, reads every gated entry as unfiltered, and installs every PC-DMIS version it cannot detect, on every CMM, within one cycle. The share runbook already says push the lib first. A runbook is not a control, and the failure is silent, fleet-wide and about five minutes fast. ShopDB already had the evidence and was not using it: every enforcement report carries the enforcer version, and publish_scope had no gate at all. It now compares the scope's manifest version against the versions PCs actually report for that scope and refuses when any is behind, naming the hosts. force=True for someone who knows why. A report with no or an unreadable version counts as behind - that field arrived with the summary-emitting engine, so its absence IS an old lib, and treating unknown as safe is precisely how this fails open. A scope nobody has reported for still publishes, or a fresh site could never publish anything. Versions compare numerically, since as text '2.10' sorts below '2.9'. Also exposed as a preflight endpoint so the UI can warn before someone clicks publish, and as a 409 with the offending hosts rather than a 500. |
||
|
|
787f475208 |
displays: clear a pending Edge update without waiting for 02:00
Every kiosk was sitting on Edge's restart-to-update prompt. The scope already sets RelaunchNotification=2 so Edge restarts unattended, but RelaunchWindow defers that restart to 02:00-04:00, so during the day the update waits and there is nobody on site to dismiss the prompt. A one-shot entry runs the Edge updater and stops the browser. It does not relaunch it - the enforce task is SYSTEM in session 0, where a launched browser is invisible - so it leans on the watchdog that already relaunches the kiosk from the Startup shortcut. That relaunch is what applies the staged update. One-shot is DetectionMethod=MarkerFile. The engine writes the marker only after a 0 exit, so a failed run retries next cycle instead of being recorded as done, and the script exits 0 when no Edge was running - that is a success, and failing it would withhold the marker and re-kill Edge on every cycle from then on. The marker path carries a date, which is the re-arm mechanism for a future update. Ordered after the watchdog entry: a display seeing both for the first time must have its relauncher registered before anything stops the browser. |
||
|
|
d109314123 |
Forecast when a printer runs out, and count what it has been through
The toner report says what is empty now. It could not say what to order, and nothing recorded how fast anything drains - every level read was cached for five minutes and then discarded. Zabbix has been keeping the history all along; we simply never asked. One history.get gives both answers, because a cartridge only goes DOWN while it is in use: a rise is a replacement. Count the rises and you have how many cartridges a printer has been through; fit a slope to the readings SINCE the last rise and you have days-to-empty. Fitting across a replacement averages a spent cartridge with a fresh one and describes neither. Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day needs ordering before one sitting at 8% that has not moved in months, and a level-sorted list ranks those backwards. It refuses to guess. Too few readings, a level that has not moved enough - many printers report in 10% steps and sit on a plateau for a fortnight - or a recent replacement each produce no estimate and say which. Those printers are listed separately rather than sorted in as 0 or as 999, since a printer without an estimate is neither urgent nor safe. Estimates show what they rest on, because "9 days from 21 days of readings" and "9 days from 2 readings" are not the same claim. A separate report card, not an extension of the toner report: that one is an exceptions list a tech acts on today, this is an ordering view read monthly, and the history query is heavier than the live read it would have slowed down. The analysis is pure arithmetic over a list of readings, so the 14 tests cover the noise wobble, the plateau, the swap, junk rows and division by zero without needing Zabbix. Zabbix being unreachable is reported as such rather than rendering an empty table that reads as "nothing is due". |
||
|
|
2fce81f33f |
Attach proof of cover to a warranty
A provider lookup answers whether a unit is covered. It does not produce the invoice or the extended-warranty certificate, and a manually entered warranty had nowhere to keep one - so the proof stayed in somebody's mailbox until they left. Two columns rather than one: the served URL of the stored document, and the name the vendor sent it under, because "Dell invoice 4471.pdf" is what a person recognises a year later and "warranty-12.pdf" is not. The download route sends the original name back. Authenticated in both directions, unlike an asset photo: an invoice carries pricing and a service tag. One document per warranty, replacing any prior extension so a re-upload as .pdf does not leave the old .png behind claiming to be current. Capped at 25MB - a certificate is a document, not a disk image. Office formats are allowed because purchase records genuinely arrive as .msg and .xlsx, not only as PDFs. |
||
|
|
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/. |
||
|
|
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. |
||
|
|
f66763e6df |
tools: a Tech Tools section, starting with codes on label stock
A place for the small utilities a technician reaches for at a bench. The plugin owns no API and no tables: every tool runs entirely in the browser, so an air-gapped site gets them for free and a bad network cannot break them. Adding the next tool is a view, a route, and one entry in tools.js. First tool is a barcode/QR generator. Content is typed text, a URL, or a CSV (content,label,copies - quoted fields and an optional header both handled), so a batch of a few hundred is one paste. Label stock is adjustable in inches with five presets, and the code renders to an SVG data URI rather than a PNG: a bitmap gets downscaled to label size and smears the module edges a scanner reads, where SVG rasterizes at the printer's resolution with hard edges. It also carries the dot-grid rule that is easy to get wrong by eye. A thermal head cannot render a fraction of a dot, so a code sized off the grid gets uneven modules; pick a DPI and the page says what the current size lands on and what to use instead. The quiet zone is blank label rather than white baked into the code, so it can be tuned - and it applies to CODE128 too, which needs clear space at each end and was letting bars run into the caption. Tech Tools is the first bundled plugin that owns no schema, which two guards did not model: it belongs in the universal installer profile, and upgrade-all reports it 'no-migrations' where every plugin was assumed to report 'ok'. The migration test now asserts that status explicitly for schema-less plugins, so a table-owning plugin whose chain went missing still fails. |