Commit Graph

519 Commits

Author SHA1 Message Date
cproudlock
9336577abe Commit the migration run instead of trusting MySQL to
SQLAlchemy 2.0 connections do not autocommit, and on MySQL alembic reports
"non-transactional DDL", so context.begin_transaction() is a no-op. Nothing in
a migration run committed.

It looked like it worked because MySQL implicitly commits on DDL: every
ALTER/CREATE flushed whatever was queued before it, including the PREVIOUS
migration's version stamp. The LAST migration of a run has no DDL after it, so
its stamp was rolled back when the connection closed. `flask db upgrade` then
exited 0 with the schema change really applied and alembic_version one revision
behind, and re-ran that same migration on the next deploy. A migration that is
not idempotent applies twice.

Found while adding a core migration: the column default really changed and
alembic_version still named its parent, with UPDATE alembic_version followed
immediately by ROLLBACK in the log.

Existing databases upgraded before this are one revision behind their real
schema. Compare `flask db current` against `flask db heads`; re-running the
upgrade re-applies the final migration once and lands the stamp.
2026-08-13 09:28:11 -04:00
cproudlock
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.
2026-08-12 17:21:29 -04:00
cproudlock
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.
2026-08-12 17:07:21 -04:00
cproudlock
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.
2026-08-12 16:56:45 -04:00
cproudlock
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.
2026-08-12 16:49:58 -04:00
cproudlock
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.
2026-08-12 16:41:09 -04:00
cproudlock
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.
2026-08-12 16:31:24 -04:00
cproudlock
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.
2026-08-12 15:47:36 -04:00
cproudlock
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.
2026-08-12 15:23:57 -04:00
cproudlock
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.
2026-08-12 12:26:29 -04:00
cproudlock
457349d258 export: stop an unanchored exclude from eating a plugin
The publication rsync excluded 'tools' to keep the repo-root tools/ dir
off GitHub. rsync patterns without a leading slash match at any depth, so
naming a plugin 'tools' meant plugins/tools/ was dropped too. It was
committed, exported, bundled and deployed, and the first sign of trouble
was `plugin install tools` on the server failing on a manifest.json that
had never been shipped.

The root-intended excludes are anchored, and the sync now verifies that
every plugins/*/manifest.json in the working tree came out the other side.
A silent omission of a whole plugin should not be something a deploy
discovers for us.
2026-08-12 12:13:23 -04:00
cproudlock
a64796f060 Document the upload, proof and forecast endpoints
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
Ten routes shipped over the last few commits without reaching the inventory,
which is the file everything else is generated from - openapi.json, the /api/docs
browser, and the per-site build. An endpoint absent from it is invisible to
anyone integrating against the API even though it answers requests.

The auth notes matter more than the paths. API-REFERENCE tells a deployment
reviewer that essentially every GET is optional-auth; two of these are not, and
an exception buried in a table of 372 operations is one nobody reads. The
installer download and the warranty proof are called out with why: one is
licensed vendor software, the other carries pricing and a service tag. The
application image is listed with the fully public endpoints, since tiles render
before login.
2026-08-12 12:06:25 -04:00
cproudlock
ea35a134fe Show the fiscal week beside the site name
The classic ASP site prints a week number under the site title, and people
quote it in conversation and on paperwork. Anyone with both sites open needs
the two to agree, so this is a port of the old arithmetic rather than a fresh
interpretation of what a fiscal week is: ISO 8601, week 1 contains 4 January,
and the week's Thursday decides which year it belongs to. That rule is what
makes late December and early January land in the right year, which is exactly
where a naive day-of-year count goes wrong, so it is what the tests cover.

Worth recording: a true GE fiscal calendar need not follow ISO weeks. Nobody
has asked for a different rule, and inventing one here would silently disagree
with the site people compare against.

Computed in local time on purpose. The number people quote is the one on the
wall where they stand, and a UTC week rolls over hours early in the evening at
a US site. The sidebar re-checks every half hour; the shop-floor board picks it
up with the clock it already ticks.
2026-08-12 12:06:25 -04:00
cproudlock
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".
2026-08-12 11:45:40 -04:00
cproudlock
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.
2026-08-12 11:45:40 -04:00
cproudlock
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/.
2026-08-12 11:45:17 -04:00
cproudlock
2693eb28d6 Cap the dashboard at four cards, and shrink the hover map
The card track was 28rem, which fits four across a full-width page but only
three once the sidebar takes its 250px - and 1920 with the sidebar is the
common case here, so the board showed three. The track is 22rem now, with an
explicit four-column cap above 96rem: left to auto-fit alone a wide screen
reaches five, and a fifth column only makes the cards narrower until the rows
they hold start truncating again.

Measured at 1366, 1600, 1920 and 2560: three, four, four, four.

The floor-plan preview drops from 500x385 to 390x300. At the old size it
covered the row it was launched from, which is the row you are trying to read.
2026-08-12 11:45:17 -04:00
cproudlock
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.
2026-08-12 11:43:57 -04:00
cproudlock
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.
2026-08-12 11:43:48 -04:00
cproudlock
4d807ccb4b toner report: the part to order, and where the printer is
The report exists to answer "what needs replacing and where do I get it",
and it was answering neither. The part numbers were already in the
lowsupplies payload and simply never rendered; a chip per part now shows
them, with capacity tier and page yield on hover, since a model can list
several tiers for one colour.

Asset # and Location columns are gone. Location is replaced by the
floor-plan preview the asset pages already use, hung off the printer name
via its mapx/mapy.

The IP is now the site's FQDN (printer_hostname_template, built from the
IP exactly as PrinterForm does) and links to the printer's own web page in
a new tab - the report is a worklist, and losing your place in it to visit
one printer means finding your row again. The raw IP stays on hover.

Cartridge names were ellipsised inside a fixed 120px column, hiding the
one thing being reordered. The supplies cell is a grid with a max-content
name column, so names show in full and still line up across a printer's
rows.

CSV and emailed exports follow the screen, with one row per part number so
the result is a copy-pasteable order list.
2026-08-12 11:43:25 -04:00
cproudlock
94d8d6c9b6 dashboard: numbers that agree, a map on hover, wider cards
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
"All assets 704" sat beside "all assets in use 737", and both were correct
about different populations. The totals summed five specific asset types and
subtracted dual-bay secondaries; the status counts took every asset row of any
type with no collapse, so USB devices and hidden secondary bays inflated one
side of a comparison the layout invites. Status is now counted over exactly the
same assets the totals describe.

Warranty rows fell back to asset.name when the covered asset had no hostname,
and an asset's name is usually the MACHINE's descriptive name - which is how a
column meant to identify a PC ended up showing a machine. Hostname, else the
asset number, never the name. The machine number loses its label too: the row
is hostname, machine, state, and "machine 3015" spends a word on what position
already conveys.

Printer names now carry the floor-plan preview on hover, the same
LocationMapTooltip the printer's own page uses - a location name tells you the
room, the map tells you where to walk. Declared as map.maphover on the card, so
any card with coordinates gets it; a row without them shows a plain link rather
than being dropped.

Cards are four across rather than five. At five columns a row holding a
hostname, a machine number and a state truncates on exactly the rows that
matter. auto-fit, so two cards fill the width instead of leaving empty tracks.

Not covered by a test: the count fix. I started one and it was interrupted, and
I have not gone back for it - the assertion worth having is that in-use can
never exceed the total.
2026-08-11 16:27:14 -04:00
cproudlock
221bbb226e employees: an endpoint that says WHY a name did not resolve
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 16:10:20 -04:00
cproudlock
42c050a9f4 dashboard: tighten the printer, warranty and notification cards
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
PRINTER CARD is now supplies at or below 5 percent, a new
printers_dashboardpercent setting. The report and the card want different
scopes: the report lists anything the thresholds call low, which is right for
planning an order, while the dashboard is asking what to walk out and change
today - and a cartridge at 18 percent is not that. Lowest first.

PRINTER LOCATION WAS ALWAYS EMPTY. The lookup went through db.session.get on
locationid and produced nothing even where a location is set; the printers list
has always read it through the asset relationship, so the card does too now.

WARRANTY ROWS are identified the way the floor identifies them: the PC's
hostname and the MACHINE it drives, reusing the same lookup behind the warranty
page's machine column so the board and the report cannot disagree about which
bay a PC belongs to. No dates - expired or expiring is the whole decision when
scanning a board, and the exact day belongs on the report you order from.

NOTIFICATIONS are stacked: the type in full on one line, the message beneath,
trimmed to 100 characters with the rest on hover. Inline, the type was
truncated to make room for prose that was then truncated anyway, and neither
read. A tooltip is omitted when the text was not trimmed, because one repeating
what is already on screen is noise.

Two general additions: layout: 'stacked' on a card, and map.detailtooltip.
2026-08-11 15:57:14 -04:00
cproudlock
8623db3ee2 dashboard: printer rows are a name and its cartridges, with the answer on hover
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The printer card was a name followed by a comma-joined string of cartridges and
a location - the widest row on the board, and the one running past the card
edge.

Now: the printer name links to its page and reveals its location on hover, and
each depleted cartridge is its own chip showing "Black 4%", revealing the part
number to order on hover. The percentage says something is wrong; the part
number says what to do about it, which today means opening the printer's page
to find out. Every capacity tier is listed, as the report has always done.

Two additions to the card contract, both general: 'chips' maps a row key to a
list of {text, title, level}, and 'titletooltip' puts context on the row title.
Nothing load-bearing goes in a tooltip - hover is not discoverable and does not
exist on touch - so a chip always states the fact and only explains it on hover.

Chips are bordered rather than filled: a row of solid red pills reads as an
emergency even when a cartridge is merely low.

Also repaired a self-inflicted mess. A string-slice edit used a marker that
appears EARLIER in the file, so the slice was empty and two helpers were
injected at line 1, above the module docstring. Removed; the file parses and
the helpers live beside the route they serve.
2026-08-11 15:46:21 -04:00
cproudlock
c34815b87e dashboard: overflow links somewhere, tiles say what they count, rows stay inside
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 15:28:29 -04:00
cproudlock
294ddbb38e backups: show that a check happened, not just that a change did
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 15:00:25 -04:00
cproudlock
e0e4cce8bd dashboard: fix what a real fleet showed, which tests could not
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 14:45:28 -04:00
cproudlock
5eb84873e8 dashboard: convert the last dead widgets, and delete the one that had nothing
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 14:27:29 -04:00
cproudlock
7151b68bdd dashboard: printer supplies, expiring warranties, mis-numbered bays
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 14:13:26 -04:00
cproudlock
6c975a107c backups: record that a config was checked, not only that it changed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 13:52:07 -04:00
cproudlock
1ca8a9b8e8 dashboard: PCs not reporting, and the card styling standard it broke
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 13:40:41 -04:00
cproudlock
05c150c663 dashboard: render plugin-declared cards, starting with enforcement failures
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The frontend now calls /api/dashboard/widgets. It never had, which is why five
plugins have been declaring widgets into a void for months, pointing at
components nobody ever wrote.

Core owns three generic renderers - exceptions, metric, list - and a plugin
declares data, a shape and a link template. The mapping logic lives in a plain
module beside the component, the same split as pluginAssetPanels.js, so it is
unit tested without mounting anything: 16 tests covering row mapping, empty
handling, ordering and gating.

The behaviours worth naming, because each is a decision rather than an
implementation detail:

Cards fetch INDEPENDENTLY and a failure becomes null. One hung endpoint - a
Zabbix call, a plugin mid-upgrade - cannot blank the board. A card whose fetch
failed HIDES rather than drawing empty, because "nothing wrong" and "I could
not tell" must not look the same.

Empty cards disappear by default. A card reporting nothing every day teaches
people to stop reading the page, which is precisely how a fleet log reached
3,234 lines with 17 that mattered. A card opts into a one-line presence only
when its absence is itself news.

Severity outranks position, so an info card can never sit above a failure.

Permission filtering happens BEFORE fetching: no point firing a request that
would only 403, and the dashboard must not become a way around RBAC.

An unknown render mode is skipped, so a plugin built against a newer core
degrades instead of leaving a hole.

A row whose link substitution is missing keeps the row and drops the link -
a PC shopdb does not know still reports its failure, and that is the bay most
likely to be misconfigured.

Cards sit ABOVE the totals: what needs a person first, context second. The
existing stat cards are untouched for now.
2026-08-11 13:09:46 -04:00
cproudlock
8b50e6fe2a geenforce: first dashboard card, and the widget contract it proves
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 13:01:04 -04:00
cproudlock
02d7619b1a Propose a dashboard that shows the fleet, not the row count
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The dashboard reports totals that are true every day and actionable on none of
them. Meanwhile a bay returned 500 to every collector report for a day and a
half, eleven markers' backups overwrote each other for weeks, and GE-Enforce
records failures, stale manifests and repeated self-heals that surface nowhere.
Every one of those was found by someone going to look.

Two things found while writing this shaped it. The plugin mechanism already
exists - get_dashboard_widgets, an endpoint that aggregates across enabled
plugins and isolates failures, and five plugins declaring widgets. And nothing
consumes it: the frontend never calls the endpoint and not one of the declared
components was ever written. So this finishes a half-built feature rather than
starting one.

It also proposes fixing the contract while it is still unused. Naming a
component per widget does not survive a lean build, where a plugin's component
may not be staged into the bundle at all. ADR-010 already solved this for asset
panels - declare data and a render mode, let a generic core component draw it -
and the same three renderers cover every card listed.

The rest is principles that came out of this week: empty cards shrink, because a
card that says "nothing wrong" daily trains people to stop reading, which is how
a log reached 3,234 lines with 17 that mattered; every row links to the thing;
cards declare a permission so the dashboard cannot become a way around RBAC;
thresholds are settings with neutral defaults.

Wave one is six cards whose data is already reliable. Wave two is the valuable
one: diffing what a manifest says a PC should have against what it reports
having, which nothing answers today and which is what GE-Enforce exists to
guarantee.
2026-08-11 12:50:16 -04:00
cproudlock
105345fb3d Release 0.9.0
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Product version to 0.9.0, frontend in lock-step, Unreleased notes moved into a
dated section per ADR-007. The plugin contract stays at 0.16.0: it moved after
v0.8.1 and is already recorded in this release's notes, and nothing since
touched the contract surface.

A minor rather than a patch: collector behaviour changed in ways an integrator
must know about. A reported machine number no longer becomes the PC's asset
number, it builds a controls link instead; a second PC claiming a machine is
treated as a claim rather than a handover; and a backup revision chain is now
per source PC rather than per asset.
v0.9.0
2026-08-11 12:41:35 -04:00
cproudlock
bf75cdc43b docs: keep real fleet hostnames out of a page that publishes to the wiki
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The check-shared-machines example carried two actual prod hostnames. docs/ is
excluded from the code bundle and goes to the GitHub wiki, so an example is the
wrong place for live fleet identifiers. Replaced with placeholders.
2026-08-11 12:10:04 -04:00
cproudlock
db2b9280e7 docs: write down the composition pattern, not just the one case
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The MECHANISM was already a documented platform contract - ADR-001 defines
partof as composition, makes controls propagate through it, and walks it first
for map-position inheritance. The part-marker work used that rail rather than
inventing one.

What was undocumented is the PATTERN built on it: several devices answering to
one identifier, each becoming its own asset filed under a parent. It existed
only as a collector behaviour for part markers plus a docstring in the device
map, so nothing told anyone how to apply it to another device type, or when not
to.

ASSET-COMPOSITION.md covers when to reach for it and when the shared identifier
is a numbering fault instead, what propagation buys, how to declare a device
type through the map or a per-site setting, what a backup kind must do to
follow the device rather than the parent, how to find the next case with
check-shared-machines, and why the parent is not disposable once devices hang
off it - deactivating it breaks filing, and a hard delete cascades through
backuprevisions.
2026-08-11 12:04:35 -04:00
cproudlock
c90ebcbc7c computers: declare subordinate devices instead of coding each one
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
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.
2026-08-11 11:13:12 -04:00
cproudlock
91143d94fb ADR-016: credential delivery to the fleet
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Controller and share credentials are currently edited as cleartext into a
script on the SFLD share, one value for the whole fleet, re-applied by
GE-Enforce every cycle. Rotating means editing that file and hoping - nothing
reports which bays picked the new value up - and sites need per-bay variation,
which a manifest scope cannot express because scopes are per PC TYPE. A scope
per bay is a spreadsheet, not a model.

ShopDB therefore owns the definitions and the targeting, and a PC receives a
decided answer rather than rules to evaluate. Targeting uses the axes
GE-Enforce already filters on plus those the asset model knows: hostname
(including -like globs), machine, model, controller OS, PC type with alias
expansion, GE-Enforce profile, and a selector over the stored DNC projection -
"every machine whose DataHost is that share" is answerable from data already
held, and it also answers which machines need a credential at all.

Overlapping matches are guaranteed rather than exceptional, so precedence is
explicit: an integer priority, then scope specificity, then rule id for
stability. The resolver must be able to explain which rule won and why, or
nobody will trust it. It reuses plugins/geenforce/filters.py; there are already
two implementations of this matching logic kept honest by a parity harness, and
a third would drift silently.

Targeting stays separate from detection, mirroring PCTypes versus
DetectionMethod: who gets a credential is a server question, whether it is
already applied is a local one, and conflating them rewrites the secret every
cycle instead of healing drift.

Stored Fernet-encrypted with the key OUTSIDE the database, write-only through
the API and masked on read, fetched with a dedicated credentials.fetch scope so
a leaked collector key does not yield controller passwords, and every fetch
audited.

PROPOSED, not accepted: it makes ShopDB a credential store, and the key becomes
a single point of loss - restore the database without it and every credential
is unrecoverable, which docs/BACKUP-RESTORE.md must state in the same change
that implements this. Registry-only provisioning is recommended as a first step
regardless, since it removes the cleartext from the share immediately and the
client helper is identical either way.
2026-08-11 10:54:42 -04:00
cproudlock
6516e76bf4 backups: the collection interval was never actually readable
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-11 09:53:43 -04:00
cproudlock
13e6e039fe ADR-015: where a site's own data is allowed to live
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
ShopDB is a product several sites adopt, developed at one site against that
site's live fleet, so every feature arrives carrying West Jefferson's
hostnames, networks and share paths. A scan of plugins/ and shopdb/ returns 19
site literals, of which about 8 are a shipped default or a hardcode rather than
a comment: a kiosk URL baked into a PowerShell payload a sister site cannot
override, a WJ UNC path as the backup share default, and device.geaerospace.net
shipped as a SETTING DEFAULT - inherited silently by anyone who adopts.

The mechanisms already existed; the rule did not, so each value landed wherever
was convenient. The rule: a setting with a NEUTRAL default first, a
site-namespaced directory second, seed data third. Blank must be a working
state. Fleet-wide vocabulary such as the gea-shopfloor-* imaging types is not
site data and is out of scope - it already has a per-site override.

Enforced by an addition to check-naming-and-style.sh, which CI already runs, in
REPORT-ONLY mode. Making a 19-item backlog a hard gate the day it lands blocks
every unrelated commit until someone clears it; SITE_LITERALS_ENFORCE=1 turns
it into a gate once the listed hardcodes are done. The ADR carries that backlog,
including that the display FQDN domain is defined three times in three files.
2026-08-11 09:21:23 -04:00
cproudlock
78c1c4709f docs: the collector contract described the machine-number defect as the rule
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
COLLECTOR-INTEGRATION.md told integrators that machinenumber maps to
Asset.assetnumber and that a real value updates it on an existing PC. That is
precisely the behaviour that returned 500 to every bay reporting a machine
ShopDB already knew, and it is the document another site reads to build its
reporter.

Rewritten to say what the field is for: it identifies the machine, the PC keeps
its hostname, and the number builds a controls link. Adds a section covering
the claim-versus-handover rule and its quiet window, the alert gate defaulting
off, and how a part-marker PC gets its own marker asset under the operation.
2026-08-11 09:13:00 -04:00
cproudlock
300f9123d2 backups: re-file historical revisions onto the part markers they came from
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Backups collected before markers became assets were filed against the operation
number the PC reported. The collector now files a marker PC's backup against
its marker, but only for backups collected since; everything already stored
stayed where it was, so the new marker assets read as empty while the operation
holds a mixed history.

The attribution is exact rather than a guess. A revision records the PC it was
read from, and the collector records which marker each PC drives, so the move
is that mapping applied. Revisions with no source hostname stay put, since
nothing says which marker they came from.

Moving a revision can duplicate one the collector has already written against
the marker, so the script collapses those as part of the move, keeping the
earliest so the date a config was first seen survives. Dry run by default.
2026-08-11 08:13:21 -04:00
cproudlock
738f30dca3 computers: never file a part marker under its own PC
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-10 17:01:23 -04:00
cproudlock
a61739d1ab computers: a part marker is its own asset, under the operation it serves
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-10 15:54:53 -04:00
cproudlock
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.
2026-08-10 15:02:38 -04:00
cproudlock
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.
2026-08-10 14:48:51 -04:00
cproudlock
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.
2026-08-10 14:24:55 -04:00
cproudlock
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.
2026-08-10 14:24:41 -04:00
cproudlock
2785c0463e warranty: use the shared LocationMapTooltip for the machine preview
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The machine map preview was a bespoke popover. LocationMapTooltip already
existed and is what the machine detail page uses, so the warranty tables now
show the same thing.

That is what was asked for - the preview zooms, as it does on the machine page -
and it comes with behaviour the bespoke one did not have: scroll-wheel zoom, and
staying open while the pointer is on the tooltip itself so it can actually be
read and panned.

It also solves the clipping properly. The previous commit reached for
position: fixed with a hand-rolled flip because .table-container's overflow-x
clipped an absolute child; LocationMapTooltip teleports to body, which avoids
the clipping context altogether rather than escaping it. All of that
positioning code, and the blueprint rendering, is deleted - 64 lines from 140.

An unplaced machine keeps its chip and now says why in the native tooltip
rather than showing an empty panel: 17 of the 142 linked machines have no map
position, and those rows still need their number.
2026-08-10 10:05:15 -04:00