e67fe47fe2c5f75c385aab1ce07e824539a47eb9
171 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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/. |
||
|
|
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. |
||
|
|
221bbb226e |
employees: an endpoint that says WHY a name did not resolve
The shopfloor board lost every photo and started showing lowercase SSOs where names belong, and nothing in the system could say why. Both resolvers returned None on any problem and three separate bare excepts threw the cause away, so an unreachable HR host, a rotated credential, a renamed column and a genuinely unknown SSO all produced identical output and no log line. That is not a bug in the resolution so much as a hole where the diagnosis should be. GET /api/employees/resolve/<sso> returns the same answer the board gets, plus which source produced it (directory, hrdirectory, useaccount), which mode the directory is in, and the exception text when a source failed. It is the difference between "the board is broken" and "the HR host refused the connection". The two bare excepts in the shared resolvers now log rather than pass. The external-directory branch is the one that fails on a live site; it was the one saying nothing. No behaviour change to the board itself - it still falls back exactly as before. What changed is that the fallback is now visible. |
||
|
|
c34815b87e |
dashboard: overflow links somewhere, tiles say what they count, rows stay inside
Four fixes, all from looking at the real board. "and N more" now links to a page showing them all. Telling someone 35 more PCs are silent and leaving them to find the list is worse than not saying it. Each card names its own destination and a test checks it against the routes that actually exist - a viewall pointing at a route nobody wrote is the same rot the endpoint check already guards, just failing in the browser instead of the API. PRINTER ROWS ESCAPED THE CARD. A flex child will not shrink below its content width unless told to, so text-overflow never engaged and a row carrying three cartridge readings plus a location simply ran past the border. min-width:0 on the row parts is what enables the ellipsis; meta shrinks first because it matters least, and the card clips as a backstop. THE STAT TILES WERE INCOHERENT. Two counted asset TYPES, two counted asset STATUSES, and nothing said which - with the status one labelled "Active", which reads as "not deleted" but meant status = In Use across every type. Each tile now counts one thing and its label says so. PCs GONE SILENT IS NARROWER, and better for it. A PC that never reported at all is usually a hand-made or imported record rather than a bay that broke, and a PC that is not In Use is silent ON PURPOSE - that is the status doing its job. Both were burying the real signal: a machine that was working, is not now, and nobody has marked as anything else. |
||
|
|
294ddbb38e |
backups: show that a check happened, not just that a change did
lastseenat already recorded it and the API already returned it; nothing displayed it, so from the UI a healthy machine still looked abandoned - one revision from last spring and no sign anything had looked at it since. The history page gains a Last verified column beside Captured, and the asset panel a Verified field. Only the CURRENT revision carries one: an older revision was superseded, so saying it was verified today would be false - what was verified is the configuration the PC holds now. A current revision with no check yet says "not yet checked" rather than showing a blank or borrowing the captured date. That state is real and temporary: the column is new, so every chain reports it until its PC next posts. |
||
|
|
e0e4cce8bd |
dashboard: fix what a real fleet showed, which tests could not
Three faults, visible only once the board ran against production data. BACKUPS SAID THE WHOLE FLEET HAD STOPPED. The lastseenat backfill was wrong. It seeded from collectedat, reasoning that the last change was the last provable moment - but an unchanged config writes no revision, so a machine whose settings last changed nine months ago got a nine-month-old lastseenat and was instantly reported as a dead backup. Every chain lit up at once, which is worse than no card: it says the site is broken when it is fine. The honest value is NULL. Before the column existed nothing recorded when a config was last confirmed, and inventing a date does not change that. Migration 0003 clears the backfill, and staleness now IGNORES a NULL chain rather than substituting timestamps that mean something else. A chain becomes measurable the first time its PC posts, which for NTLARS is within a day. TONER READ "None%". The supply dict has no 'percent' key - it is 'remaining'. Supply names are also shortened, because "Black Toner Level 4%" spends three words saying what the card already says. THE CARDS READ AS WALLS OF TEXT. Rows wrapped into paragraphs and a card with forty PCs pushed everything below it off the screen. Now: at most five rows with "and N more", one line per row that truncates rather than wraps, meta pushed right and dropped first since it matters least, and severity reduced to a small dot beside an uppercase label instead of a coloured card - six severity-painted cards read as a crisis, which is how a board stops being read. Worth recording that none of this could fail in a test. Every one needed real data on a real fleet. |
||
|
|
5eb84873e8 |
dashboard: convert the last dead widgets, and delete the one that had nothing
Three plugins still declared widgets naming Vue components nobody wrote. Converting them honestly meant three different answers, not one. notifications gets a real card: the active notifications themselves, not a count. "4 active" tells an admin nothing; knowing WHICH message the shop is looking at is the point, and it is how a stale one gets noticed and taken down. machines gets machines out of service - anything not In Use, excluding Inventory, because a spare on a shelf is stock rather than a problem. Someone is supposed to be chasing each of those and today they are visible only to whoever thinks to filter the list by status. network gets NOTHING, and its declaration is deleted rather than converted. Network devices carry no live status - no polling, no reachability check, nothing that can be wrong - so the only possible card is a count of how many exist, which is precisely the always-true number this dashboard exists to get away from. A comment records that, so the next person does not re-add it. If reachability is ever collected, that is the card. Also adds a contract test over every declared card: no component names, a valid renderer and severity, and - the one that matters - the endpoint must be a REAL route. A declaration pointing at a route nobody wrote is exactly how the old widgets rotted unnoticed for months, and now it fails the build instead. |
||
|
|
7151b68bdd |
dashboard: printer supplies, expiring warranties, mis-numbered bays
Wave one complete. Three cards, no new data and no migrations. Printer supplies reuses the existing low-supplies query and its five-minute cache; a Zabbix round-trip per printer on every dashboard load would make this the slowest page in the app. One row per printer listing every depleted cartridge, criticals first - a row per cartridge would report one printer three times and read as three problems, and showing only the worst class would hide a low cartridge behind a critical one on the same machine when whoever walks out there wants to carry both. While there: the low-supplies REPORT itself was including healthy cartridges. A printer with one empty black and three full colour ones listed all four, so the reader had to find the problem inside the row. It now lists only what needs replacing, and the test that asserted the old behaviour now asserts the new. Expiring warranties keeps already-expired entries on the list rather than dropping them the day they lapse, which is how they get missed. Horizon is warranty_expiringdays, default 90, because that suits a site budgeting quarterly and nobody else. Mis-numbered bays promotes check-shared-machines out of a CLI command nobody will remember to run - it found seven bays that had been wrong for weeks. It reports only numbers with NO child assets, so part markers legitimately sharing an operation stay silent: that distinction is the whole card, and without it it would list correct data beside faults and be ignored. Printers also loses its dead component-named widget; notifications, network and machines still have theirs. |
||
|
|
6c975a107c |
backups: record that a config was checked, not only that it changed
The stale-backup card could not be built as designed, and the reason is more important than the card. Dedup means an unchanged configuration writes no revision, so collectedat moves only on a CHANGE. A machine stable for six months has a six-month-old newest revision and is perfectly healthy. Keying a staleness card on revision age would have flagged most of the fleet - exactly the noise that makes a board worth ignoring. Underneath that: ShopDB could not distinguish those cases at all. On a no-op the server returned "unchanged" and wrote nothing, so "we checked yesterday and it matched" was discarded. That fact is the one thing a backup system must be able to prove, and the only record of it was a line in a log file on the PC. lastseenat records the check rather than the change. Touched on every matching post including the no-op; set on creation, since a new revision has by definition just been seen; backfilled from collectedat or createdat so existing rows start from the last moment the config can be PROVEN current, rather than from now - claiming a check that never happened would be worse than silence. The card keys on it, one row per CHAIN rather than per asset: a machine with two part markers can have one still reporting while the other stopped, and a per-asset view would report the machine as fine. It stays deliberately silent about assets never backed up, because whether one SHOULD be is a question only the manifest can answer, and guessing would list a hundred healthy machines. The rule lives in services/staleness.py rather than the route, so it is testable without an auth layer in the way - the same split retention.py uses. Threshold is backups_staledays, default 3, and 0 disables the card. |
||
|
|
1ca8a9b8e8 |
dashboard: PCs not reporting, and the card styling standard it broke
Second wave-one card. GET /api/computers/dashboard/quiet lists two populations and deliberately does not merge them into one count. A PC that reported and went quiet is probably off, moved or broken. A PC that has NEVER reported is worse: not enrolled, or enrolled against the wrong pc-type, so nothing enforces anything on it and no backup of it exists. That one hides indefinitely because nothing about it fails loudly - the same shape as the bay that carried a wrong machine number for weeks. Never-reported sorts above the merely quiet, then longest silence first: the order someone should work down the list, not the order rows left the table. A soft-deleted PC is excluded - a decommissioned machine is silent on purpose, and listing it would train people to ignore the card, which is the failure this whole board exists to avoid. The window is computers_quietreporthours, default 24, because every site will disagree with any number picked here (ADR-015). A malformed value falls back rather than failing the card. This also replaces the computers plugin's old widget declaration, which named a component nobody ever wrote. Four such declarations remain and will convert as their cards arrive. Two fixes to the renderer found while wiring this up. Meta specs now support a trailing unit, so a row reads 'quiet for 3 days' rather than 'quiet for 3'. And the card styles hardcoded hex colours against the frontend standard, including a var(--card-bg) that DOES NOT EXIST - the variable is --bg-card - so the fallback would have painted every card white and broken dark mode entirely. Now --bg-card, --border, --danger, --warning, --primary and --link throughout. |
||
|
|
8b50e6fe2a |
geenforce: first dashboard card, and the widget contract it proves
Wave one of the dashboard proposal, built as a vertical slice so the contract is proven by something real before the other five cards follow. GET /api/geenforce/dashboard/failures lists entries that FAILED on their PC's most recent enforcement cycle. Per ENTRY, not per report: "three PCs failed" is a number, while "Install OpenText failed with exit 1603 on WJSF1234" is something a person can act on. Only current reports count, so a failure that has since been fixed clears itself instead of needing dismissing. Hostnames resolve to computerids in one query so each row links to the PC, and a PC shopdb does not know still appears - the failure is real even when the inventory is behind, and that is the bay most likely to be misconfigured. The data has been there all along. The only way to see any of it was to open one PC's report modal, one PC at a time. The widget declaration is the contract change. The old shape named a Vue component per widget, which cannot survive a lean build where a plugin's component may never be staged into the bundle - which is exactly why five plugins declare widgets pointing at components nobody ever wrote. This declares data, a generic renderer, a permission and a link template, the way ADR-010 already does for asset panels. A test asserts no 'component' key, so the old shape cannot creep back. empty: hide is part of the contract, not decoration. A card reporting "nothing wrong" daily teaches people to stop reading the page, which is how a fleet log reached 3,234 lines with 17 that mattered. Frontend rendering comes next; the endpoint and declaration stand alone and change nothing that exists. |
||
|
|
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. |
||
|
|
6516e76bf4 |
backups: the collection interval was never actually readable
Backup-NtlarsSettings reads backups_intervalhours from /api/settings/public, because it runs before it holds any credential, and its Get-IntervalHours falls back to 24 on any failure. The plugin never declared the key public, so the endpoint did not return it, the fallback fired on every PC, and the setting looked configurable in the UI while changing nothing. The fleet log shows the symptom plainly: "Throttled: last attempt under 24h ago", every cycle, regardless of what the setting said. Declared public. A collection cadence is not a secret. backups_shareroot stays private - it is internal topology - and the test asserts both directions so a later edit cannot quietly widen it. Same defect as the 3D parts kiosk label prefix already in this changelog: a logged-out reader against an allowlist its key was not on. Worth noticing that the pattern has now bitten twice. |
||
|
|
738f30dca3 |
computers: never file a part marker under its own PC
A PC first seen before the machine-number fix was created with the machine number as its OWN asset number, and the fix deliberately does not overwrite an existing PC's asset number. So resolving the reported number can return the reporting PC itself, and the marker was then filed partof its own PC - which reads, on the machine page, as the PC being the operation. The machine-link path already guarded this case; the marker path did not. It now refuses and says why, naming the repair: rename the PC asset to its hostname, or create the operation asset. |
||
|
|
a61739d1ab |
computers: a part marker is its own asset, under the operation it serves
Several Telesis markers serve one operation number - 0613, 0615 and WJPRT each have more than one - so treating the operation as the marker collapsed separate devices into a single record. Their configurations differ by COM port, so in the backup history they overwrote each other, and no question about an individual marker could be asked at all: how many there are, which port one is on, which one failed. There is one marker per PC, which makes the PC the marker's identity, so the collector can mint the marker the same way it already mints a CMM or a Keyence unit for a metrology PC. A marker PC now gets a Part Marker machine asset, the PC controls it, and the marker is partof the operation whose number the PC reports. An operation holds any number of markers. A marker PC therefore does not claim the operation directly. controls propagates through partof, which reference-data already seeds, so control of the operation still follows from controlling its marker - without two markers contesting a link only one of them can hold. Backups from a marker PC resolve to the marker rather than the operation, and fall back to the machine number whenever the marker cannot be resolved: no hostname on the payload, a lean build without the computers or machines plugin, or a marker PC that has not reported to the computers collector yet. Filing under the operation is the old behaviour and beats rejecting a backup. Moving a marker to another operation archives the old membership rather than deleting it, so where a marker used to live stays answerable. |
||
|
|
d429c882b4 |
backups: a revision chain belongs to a PC, not just a machine
Dedup compared a posted config against the latest revision for the ASSET, which is only correct when a machine number means one PC. Several PCs share one here: the part markers on 0613, 0615 and WJPRT are separate devices, differing by COM port, filed under one machine number. Each marker's post therefore differed from whichever marker had posted last, nothing ever deduped, and the table grew by one row per PC per collection cycle. A chain is now (asset, kind, source hostname). An unchanged config is a no-op again, and each PC keeps its own history against the machine. NULL sources - rows written before the column was populated, and hand-loaded ones - form their own chain via IS NULL; `column == None` never matches in SQL, so without that those rows would have re-posted forever. Two consumers assumed the old key and are fixed with it. Retention pruned per asset, so a busy marker's revisions could evict a quiet marker's only backup; it now prunes each chain separately, protecting the newest and oldest of each. The revision diff compared against the previous revision on the machine, which across two markers reported one device's COM port as a change on the other; it now compares within the source's own chain. scripts/collapse_duplicate_backup_revisions.py cleans up what the old rule wrote. It removes only a revision whose hash repeats the one before it in the same chain - rows the fixed code would never have written - and keeps every genuine change, every chain's newest and oldest, and every source. Dry run by default. Its --report mode explains what grew each chain, which separates a legitimately shared machine number from two PCs wrongly carrying the same one, and from a value inside the config that changes on its own. |
||
|
|
5108ba8aaa |
computers: a second PC claiming a machine is a claim, not a handover
Treating "another PC is linked to this machine" as proof of replacement was wrong. A PC imaged for machine 3010 carries that number from the bench, before it has replaced anything, and several PCs sharing one machine number is a normal state at this site: the part markers do it. Both PCs then reported on their own schedules, each report moved the link and raised an alert, and the pair traded the machine back and forth for as long as both were alive. The PC holding a machine now keeps it while it is still alive. Alive means it has reported within MACHINE_CLAIM_QUIET_HOURS and its asset is still In Use. A challenger is recorded as a dormant link instead, which doubles as the marker saying the claim has already been announced, so a PC sitting on a bench does not alert on every collector cycle. The handover still happens on its own once the old PC has been quiet for a day, which is what a PC pulled off a machine does. Moving the old PC off In Use - Retired, Inventory, In Repair - hands the machine over on the next report, which gives IT a one-step way to force a swap the moment it happens rather than waiting out the window. A day is long enough that a PC switched off overnight, or one behind a network outage, never loses its bay to a spare. Alerts for both cases are gated on a new computers_machinelink_alerts setting and ship OFF. Several part markers legitimately share a machine number here, so the alerts would fire on correct data. Links, warnings in the collector response, and archived history are unaffected; only the sending is gated. Also: the alert goes through send_alert rather than resolving recipients by hand, which had missed the SMTP_ALERT_RECIPIENTS environment fallback, so a site configuring SMTP by environment would have got the webhook and no email. |
||
|
|
9512b0bdb3 |
computers: the machine number identifies the machine, not the PC
A bay reporting machinenumber 3015 got a 500 from the collector every five minutes since it was imaged, and would have forever: the reported number was written to the PC's own assets.assetnumber, which is uniquely indexed and already held by machine 3015, so the insert failed with "Duplicate entry '3015' for key 'ix_assets_assetnumber'" and the entire report was discarded. Operating system, boot time, applications, printers and access protocols never landed. Every retry did the same thing, so there was no path out of it. A new PC now takes its hostname as its asset number, which is what the data already shows: of 289 computers none has a numeric asset number and 214 use their hostname. An existing PC's asset number is left alone; overwriting it renamed the PC onto the machine's identifier, changing how that PC is identified everywhere else. The machine number instead does what it was collected for. It resolves the machine and links the PC to it with a 'controls' relationship carrying a collector:machine origin label, the same discipline the printer and measuring-tool links use, so a link made by hand is never archived by a collector push. Reporting a different machine archives this PC's previous link; a machine ShopDB does not know is reported as a warning rather than invented. When another PC was already linked to that machine it has been replaced. The old link is archived rather than deleted, so which PC ran a machine in a given month remains answerable, and an alert goes out by email and webhook. The retired PC's status is deliberately not changed: the collector cannot tell whether it was shelved, sent for repair or re-imaged for another bay, and guessing would overwrite what a person set. |
||
|
|
b8398a36eb |
backups: an empty value in a .reg export must not fail the file
A line of the form "Name=" with nothing after the sign is not strictly legal, but it occurs in real exports - the part marker's WJPRT.reg has KRelay1 like this. The parser raised on it, which failed the whole file, which meant that machine could never be backed up at all. Read it as an empty string so the value name is still preserved. |
||
|
|
c0a7655aab |
geenforce: kiosk watchdog must see a WINDOW, not just a process
Reported from a real display: Edge closed on the desktop but still listed in Task Manager, and the watchdog never relaunched it. That is this watchdog's own bug. It asked only whether a --kiosk process existed. After an Edge update the window can be gone while the process lingers, so the check said "kiosk is up" and returned - every cycle, forever. Matching on the command line was chosen to stop a stray renderer masking a dead kiosk; it does not help when the orphan is the parent. A kiosk now counts as up only when a --kiosk process still owns a visible window (MainWindowHandle). Windowless ones are killed BEFORE relaunching: leaving them would satisfy the next cycle's check again, and a second browser would fight the first for the display. Also refuses to run as SYSTEM. MainWindowHandle is session-scoped, so a SYSTEM caller reads 0 for a perfectly healthy kiosk and would kill and relaunch it on every cycle. The task uses an interactive Users principal so this cannot normally happen; the guard makes a mis-registered task fail loudly instead of thrashing a display in a hallway. Verified on Windows this time by letting the SCHEDULED TASK do the work rather than invoking the script by hand - which is what the first version was missing: - task fired unattended (rc=0) and launched the kiosk into session 1 - the next cycle saw the healthy kiosk and did nothing, no relaunch loop - a windowless --kiosk process was killed and replaced - run as SYSTEM, it refused and the healthy kiosk survived |
||
|
|
efe34034e3 |
computers: collect remote-access protocols (RealVNC and friends)
The accessprotocols / computeraccess tables replaced the old isvnc/iswinrm booleans and the PC page already badges what a machine exposes, but nothing kept them current: the 574 rows in place all came from the legacy migration and have not moved since. The collector schema had no field for them. Adds 'accessprotocols', a list of catalog names, synced with the same discipline as the printer links. A reported protocol is activated; a catalogued one the PC did NOT report is deactivated rather than deleted, so a manual portoverride survives a service being briefly down. An unknown name warns and is skipped: the catalog is admin-managed, and a typo on one bay must not invent a protocol for the whole site. Presence of the key is what drives the sync. A payload without it leaves every existing row untouched, which is what protects the migrated rows from a collector that does not report protocols yet. Six tests cover recording, case-insensitive matching, deactivation on removal, the omitted-key no-op, an explicit empty list meaning "exposes nothing", and that an unknown name never creates a protocol. |
||
|
|
939cdd0882 |
geenforce: relaunch the display kiosk when Edge goes away
Kiosks were updating, closing Edge, and never coming back - the display stayed dead until the next logon or reboot. The kiosk is launched by an all-users Startup shortcut, which runs ONCE at logon, and nothing supervised the browser afterwards. RelaunchNotification=2 was meant to cover the update case and does not: that policy drives Edge's own update-restart, which depends on session restore to return to where it was. Kiosk mode restores no session and has no UI to show the notification in, so Edge honours the close and never the relaunch. The same gap swallowed crashes and anyone closing the window. Adds a scope entry that registers a scheduled task in the INTERACTIVE session - SYSTEM cannot launch a visible browser, which is why the dispatcher writes a shortcut rather than calling Start-Process. The task relaunches from that same shortcut, so the target URL keeps one source of truth: retarget a subtype in DISPLAY_TYPE_TARGETS and the watchdog follows unchanged. Two details that matter. It matches on the COMMAND LINE, not the image name: Edge runs a crowd of msedge.exe children and only the parent carries --kiosk, so testing "is msedge running" would let a stray renderer mask a dead kiosk forever - verified against a real kiosk PC showing 7 processes and 1 match. And it avoids -RepetitionDuration [TimeSpan]::MaxValue, which serialises out of range and is rejected, exactly as the kiosk installer documents. A launch debounce stops a display that fails to start from spawning a browser every cycle, the log is size-bounded because this runs forever on a PC nobody watches, and it does nothing at all when no kiosk shortcut is present so it cannot put Edge on a PC that never asked for one. Verified on Windows: registers with the right principal and triggers, relaunches when the kiosk is gone, debounces an immediate re-run, and is idempotent across cycles (the staged script compare is trimmed - Set-Content adds a trailing newline the here-string lacks, so an untrimmed compare rewrote it every cycle). |
||
|
|
442a631557 |
Let a site choose the order its board rows run in
Rows ran in display-style order and then alphabetically, so what led the screen was an accident of styling and the alphabet - a new type called Awareness landed above Recertification for no better reason than the letter A. Each type now carries a board position, lowest first, set on the Notification Types page. The migration seeds Recognition at 10 and Recertification at 20 and leaves everything else at 100, so an existing board keeps the order sites already expect. Steps of ten leave room to slot a row in without renumbering the rest. A row shared by several types sits wherever its earliest-ordered type puts it, so a category moves as a unit. |
||
|
|
adb8542018 |
backups: show timestamps in the site timezone
Backup timestamps read wrong because of two faults stacked, which is why it
looked like a single offset.
The API serialised naive ISO ("2026-08-07T12:00:00"), with nothing saying the
value was UTC. JavaScript's new Date() parses that as BROWSER-LOCAL, so every
timestamp shifted by the viewer's offset before any timezone formatting ran.
Every datetime this plugin stores is naive UTC, so the wire format now carries
a trailing Z.
The history view then formatted with toLocaleString(), i.e. the viewer's zone,
ignoring the site_timezone setting entirely. It now loads that setting and
formats through the shared formatInZone helper, matching NotificationsList.
The panel list label is built server-side with strftime, so a client cannot
correct it afterwards. It now converts to the site zone using the same Setting
lookup the notifications plugin uses - without that it showed UTC, four hours
out at West Jefferson.
Tests cover the wire format and that 16:30Z renders as 12:30 in
America/New_York.
|
||
|
|
c93ec7a949 |
backups: one tabbed DNC card, machine-numbered downloads, themed history
DNC Info becomes a single tabbed card - General, eFocas, Serial, NTSHR and MARK - instead of a flat wall of every value. On 3204 that is 11 rows visible rather than 22, and on 0600 eleven rather than 27, which also stops the card unbalancing the detail page's two-column layout. Everything DNC now lives on that one card, so the Part Marker panel is gone: its settings are the MARK tab. The partmarker KIND is untouched and still stores, dedupes and serves revisions - they are listed on the backup history page - it simply contributes no card of its own, which on 145 of 147 machines would have been an empty box. Downloads are named for the machine: 3204.reg, and 3204-wow6432node.reg for the dialect that imports outside NTLARS. The view had been rebuilding the name from sourcefilename and producing 3204.reg-wow6432node.reg, so the revision now carries assetnumber and both ends agree. That needed a viewonly relationship to Asset - no backref, so the core asset side gains no dependency on this plugin. The history page was hardcoded to light colours (#e0e0e0, #f4f9ff, #666) and rendered as a white table on a dark page. It now uses the palette variables throughout, per frontend/CLAUDE.md. The current-revision tint is a color-mix against --primary so it reads in both themes rather than a baked light blue that disappears on dark, and the diff columns are headed as well as red/green, since colour alone does not survive a colourblind reader. |
||
|
|
efe2f7e6ca |
backups: show a kind's panels only when that asset has data
Every machine was getting a Part Marker Configuration panel, and only two of the 147 known machines are part markers. A kind applies to an asset TYPE, but whether a given asset ever carries that kind of backup is a property of the individual machine, so type alone cannot decide what to show. The generic renderer already handled this: a list panel is visible when it has rows OR declares empty text. Declaring emptytext on both kinds defeated it and forced them to render everywhere. emptytext now defaults to None on the base class, neither bundled kind sets one, and the panel builder OMITS the key rather than emitting null - a present-but-null 'empty' would still have kept the panel on screen. The DNC Info card carried empty text that could never be displayed, since keyvalue visibility is decided purely on field count. Removed rather than left to mislead. A lathe now shows DNC Info and NTLARS history; 0600 and 0614 additionally show Part Marker once something collects for them; a machine with no NTLARS data shows no backup panels at all. The emptytext mechanism stays available for a kind that genuinely wants to say "expected here, nothing yet". |
||
|
|
565611e3d0 |
add backups to the universal site profile, and guard the list
site-profile-universal.json is what the released Windows installer is built from, so a bundled plugin missing from it is invisible to the install wizard. Worse, `flask plugin prune-schema` drops the tables owned by plugins the site did not install (ADR-014), so backuprevisions would have been dropped at provisioning on every new site - a table that shipped in the build, removed because the profile never named it. The same omission explains why `flask plugin upgrade-all` skipped backups: upgrade_all_plugins iterates the REGISTRY, not the plugins directory, on purpose - a plugin folder merely sitting on disk unadopted must not have its DDL run as a side effect of a deploy. instance/ is gitignored, so any machine that never ran `flask plugin install backups` has it on disk but unadopted. Nothing in the suite caught the stale profile, so this adds two guards: every bundled plugin carrying a manifest must appear in the universal profile, and the profile must not name a plugin that does not exist. Verified the first one fails with the profile as it was. |
||
|
|
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. |
||
|
|
ed9c91c47d |
Give a network device somewhere to put its IP
The network device form had no IP field, so the one thing people look up a switch for could not be entered. The API had accepted an ipaddress since the legacy import work - create, update and every read already carried it - but nothing in the UI ever sent one, which also left the device off the "Devices on this network" list, since that matches on the IP a device does not have. The field sits next to Hostname and round-trips through the Communication row the platform keeps IPs in, the same way the PC and printer forms do. Clearing it now clears the stored address. The upsert helper returned early on a blank value despite a docstring promising it cleared - harmless while no form could submit one, wrong the moment this field existed. |
||
|
|
76c184fe91 |
Give every notification type its own row on the board
The shopfloor board grouped cards by display style alone, so every type set to grid landed inside the Recertification row and every carousel type inside Recognition's - under a heading naming somebody else's type. Setting Awareness to grid put awareness messages under "Recertification Required". Each type now gets a row of its own, titled by its own name, and rotation state is per row: two carousel rows advance on their own indexes instead of sharing one counter, and two grid rows page independently. For the other direction there is notificationtypes.boardcategory. Types sharing a category share one row under the category name, so Change, Awareness and Incident can sit together while Recognition and Recertification keep their own. Blank - the default - means a row of its own. The category is part of the grouping key along with the display style, since a category cannot merge a banner with a row of tiles. A card that names no employee now renders its message as the tile or card, rather than a placeholder face above a blank name, which is what a grid type like Awareness looked like before. The layout fingerprint that makes open kiosks reload now covers the category and the grace window, so a re-grouped board reaches screens that are already up. |
||
|
|
fef5e28952 |
Let an end time mean the card leaves the board
The shopfloor feed kept every ended notification up for a hardcoded 30 minutes, flagged resolved. A card with an 8:00 end time was still on the board at 8:29, which reads as an expiry that did not work - and in the carousel, grid and banner sections it read that way with no visual sign at all, since only the standard cards render the resolved state. The tail is now notificationtypes.gracewindowminutes, set per type on the Notification Types page and defaulting to 0, so an end time means what it says. A type whose cards are worth acknowledging after they clear - an incident, say - opts into a tail, and only that type's cards get one. The feed widens its query to the largest configured tail and then holds each row to its own type's window. That keeps one portable query rather than a per-type interval expression in SQL, and with every type at 0 it collapses to "still showing". Also fixes resolved serializing as null rather than false for a card with no end time, which the and-chain produced. |
||
|
|
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. |
||
|
|
f8c4246483 |
Fix model photo upload, and give network devices the model link the page assumed
Three faults around vendor-model photos, found while looking at why an uploaded image did not appear. Saving a model was blocked after uploading a photo. The Image URL field was type="url", and an upload sets it to an application path such as /api/models/image/model-120.png. Native url validation demands an absolute URL with a scheme, so the browser refused to submit the form with "Please enter a URL" for a value the page had just written itself. The field is now type="text", which is what it always needed to be: it holds either a full web address or a path on this server. documentationurl stays type="url". The upload button did not appear when adding a model, only when editing one. That was deliberate - the photo is stored as model-<id>.<ext>, so it cannot be sent before the record has an id - but it reads as a missing feature, and the hint explaining it was easy to miss. A photo chosen while creating is now held and uploaded as soon as the model is saved, and it is dropped if the dialog is cancelled, so it cannot land on the next model created in the same session. Network devices could never show a photo. NetworkDeviceDetail.vue binds its hero image to networkdevice.imageurl, but networkdevices carried only vendorid, with no link to a catalog model, so nothing could populate it - a feature that looked present and could not work. Machines, PCs and printers have carried modelnumberid since July. This adds the same column and relationship, the to_dict branch that exposes modelname and imageurl, the field on the API, and a Model selector on the form so the link can actually be set. The migration is guarded the same way employees0002photo is: on a fresh database the tables come from the SQLAlchemy models, which already declare the column, so an unconditional add fails with "duplicate column name". The foreign key is created only on databases that can add one by ALTER; routing it through batch_alter_table made Alembic's column sort raise "Circular dependency detected" on the fresh-database test. Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the server, not just a file copy. |
||
|
|
fb53161578 |
Answer "is this a re-run of my install?" from a record, not from the machine
The installer inferred that question from whatever the server happened to look like: a MySQL service exists, the database has tables, the site exists, the venv exists. None of those record who created them. A retry after a failed first install was therefore taken for an upgrade of somebody else's working system, which produced two dead ends on exactly the retry the wizard invites: stage 3 demanded a mandatory backup of a database its own failed attempt had written, and then refused to prune tables it had created minutes earlier, because core migration 7d05 seeds access protocols owned by the computers plugin and any profile without that plugin hit the refusal every single time. An install record at ProgramData\ShopDB-Flask\install-state.json answers it instead. It is written when provisioning STARTS rather than when it finishes, because the run that dies halfway is precisely the run whose retry needs it, and it records what this installer created as it goes, so a crashed run no longer leaves the next one guessing from the machine. During unfinished first provisioning the pre-migration backup becomes advisory and prune may force, since every row present was written by an earlier attempt of the same install. On an established install both stay exactly as they were. The classification is deliberately asymmetric: an install predating this record carries a version stamp and probably real data, so it is treated as established and keeps the mandatory backup. Guessing "first run" there would arm prune --force against live tables. Get-CreatedItems comma-protects its return. A zero-length array returned from a PowerShell function unrolls to $null, and $null.Count is fatal under StrictMode 2.0 - the same fault that made bundle verification fail on every install earlier. The harness caught it before it shipped. Tests: deploy/windows/installer/tests/test-install-state.ps1 exercises new servers, retries, completed installs, unrecorded-but-stamped installs, records naming another directory, corrupt records, and persistence across a crash. tests/test_installer_state.py runs it wherever pwsh exists and asserts the invariants as text everywhere else. Both were confirmed to fail when the prune gate or the comma protection is removed. pytest.ini stops collection walking into deploy/windows/installer/bundle, which is build output holding a complete second copy of the application. Importing every plugin twice made SQLAlchemy refuse a redefined table and the whole suite fail to collect, on a tree with nothing wrong in it, purely because an installer had been built first. It surfaced only when the bundle grew from four plugins to thirteen. |
||
|
|
a352a21a10 |
Declare packaging as a runtime dependency
shopdb/plugins/loader.py imports packaging.specifiers and packaging.version at module scope, but packaging was never listed in requirements.in. It was present in every development and CI environment as a transitive dependency of pytest, so the full suite passed while a venv built from requirements.txt alone could not import shopdb at all. The Windows installer builds exactly such a venv, so stage 3 failed on a customer server with ModuleNotFoundError: No module named 'packaging', after the runtime and all wheels had installed successfully. Add packaging to requirements.in, recompile the hashed lockfile, and add the wheel to the offline wheelhouse with the matching bundle-lock entry. The recompile also picked up newer uv formatting: inline environment markers on cffi and greenlet and shorter "via" comments. The pinned distribution set and every existing hash are unchanged. tests/test_runtime_dependencies.py guards the general case by scanning shopdb/, plugins/ and scripts/ for unconditional third-party imports and asserting each maps to a distribution pinned in requirements.txt. Test dependencies are the blind spot for this class of failure, since they are present wherever the suite runs and absent wherever it does not. |
||
|
|
8d0afc40d3 |
fix(installer): bundled MySQL install failed on a malformed msiexec command line
Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing every msiexec /Option appeared, then the wizard reported that the bundled MySQL database could not be installed. That dialog is msiexec's usage help - it prints it when the command line does not parse - so the install never started. Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which contains spaces. Invoke-Native wraps any argument containing whitespace in quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public properties as PROPERTY=value and expects the VALUE quoted - INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and exited non-zero. This file already carried the rule, next to the Python target: "Never put a space in a path this installer controls." I broke it setting the 8.4 path. Two fixes, because one of them alone leaves the trap in place: - $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client search paths in the installer, the preflight and the operator console all look there first, keeping backups working against the bundled server. - Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path explicitly no longer produces an unparseable command line. tests/test_installer_defaults.py fails if an installer-controlled path default ever contains a space again. |
||
|
|
5321649e02 |
fix(installer): stop blocking the wizard on things the installer itself installs
The preflight page began refusing to continue while any check was failing, which is right for something the operator must go and fix. HttpPlatformHandler was marked FAIL when absent - so on a server without it the wizard stopped dead, telling the operator the server was not ready, over a module the bundle carries and stage 4 installs a few pages later. The only way forward was to go and install by hand the exact thing the installer was about to install. It is now INFO: reported, not blocking, matching how URL Rewrite is already handled. Nothing the installer SUPPLIES may block the wizard, and tests/test_installer_defaults.py now fails if that rule is broken again. The site-port conflict check is downgraded from FAIL to WARN for the same class of reason: it runs before the operator reaches the Address page, so it tests the DEFAULT port rather than the one they intend to use, and blocking refuses an install over a conflict the very next page lets them resolve. Genuine blockers are unchanged - no IIS, no WebAdministration, wrong Windows edition or architecture, no disk, and the MySQL 5.6 index flags. Those the operator really does have to fix first. |
||
|
|
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.
|