Commit Graph

52 Commits

Author SHA1 Message Date
cproudlock
035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00
cproudlock
d830dd49a9 collector: adopt an instrument before minting one, and stop the 500 loops
Minting derived a measuring tool's asset number from the HOSTNAME, so a
permanent instrument inherited the identity of whichever PC drove it that week:
replace the PC and either the number lies or a second tool appears for the same
physical unit. And because idempotency was keyed on the collector's own label,
it could not see a tool it had not created - on prod that left 43 legacy MT-####
tools shadowed by minted <HOST>-CMM twins, three records deep in places.

Resolution order is now most-stable-identity-first: the instrument named by
measuringtool-id.txt, then a prior collector link, then a tool this PC already
controls that somebody else created, then the reported machine number, and only
then mint. What minting produces should be read as a placeholder until a real
identifier is recorded.

Three separate 500 loops came out of the same mistake, looking a relationship up
by LABEL when assetrelationships is unique on (source, target, type):

- On a CMM the instrument IS the reported bay, so the machine sync has already
  made a row for that exact triple - and it finds its own rows by that label.
  Relabelling hid the link, so the next cycle built a second row for the same
  triple and MySQL rejected it: 200 once, then 500 forever. The machine link is
  now recognised and left alone; adoption only needs the identity.
- A part-marker PC hit it twice over, once on its partof row and once because
  the marker's asset number is derived from the PC and could already be taken.
  Both are get-or-create on the triple now, and an existing asset of that number
  is adopted rather than duplicated.

A named instrument also supersedes a minted twin properly: the stale link is
archived by TARGET, not by object identity, which is what left a PC reading as
the controller of two instruments.

Reported identifiers are matched exactly rather than with ilike. They arrive
from a text file on a shopfloor PC, and ilike reads _ and % as wildcards, so
MT-600_ adopted MT-6001 and a bare % adopted whatever active asset came first.
A named id that is not a measuring tool is refused with a warning rather than
linked as one.
2026-08-14 13:46:40 -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
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
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
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
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
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
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
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.
2026-08-10 08:34:19 -04:00
cproudlock
245f94d344 Stop two dialogs going see-through in dark mode
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
--bg-card is deliberately translucent in dark mode (rgba(0,0,61,0.4)) so cards
glass over the page; --bg-card-solid exists for the things that must not. Two
hand-rolled modal panels used the former, leaving the notification-type and
access-protocol editors transparent over the overlay with the table legible
through them. The shared .modal in style.css already got this right.

Also writes down the page-vs-modal rule the codebase already follows, since
nothing stated it: a record with a detail page gets a routed form page, a lookup
row that only exists inside its list gets a modal over that list. Plus the modal
rules from the overlay-close fix - data entry never closes on a stray click,
confirmations may, and panels are painted solid.
2026-08-07 10:30:45 -04:00
cproudlock
d8fe0a48b2 Stop a stray click outside a modal discarding what was typed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Operators reported losing a part-filled form by clicking slightly outside it.
Every data-entry modal closed on a backdrop click with no warning and no way
back - the worst possible response to a misplaced click, and it happens most to
someone adding their first records at a new site.

Close-on-overlay is removed from 35 modals across 30 files: anything containing
an input, textarea, select or v-model. They still close by Cancel or the X.

Confirmation dialogs keep it, because a delete prompt holds nothing to lose and
dismissing one by clicking away is the behaviour people expect. VendorsList
shows the distinction - its edit form no longer closes that way, its delete
confirmation still does.

The shared Modal component now defaults closeOnOverlay to FALSE. Every current
caller holds a form, a checkout, a stock adjustment or a map position being
picked, and not one passed the prop, so all of them had the same fault. A modal
that genuinely wants dismissing that way opts in explicitly.

Also regroups the operator console menu, which had grown to numbers 1-9 plus
three letters bolted on with no order to them. Actions are now grouped by what
they touch, keyed by their first letter, and the old numbers still work so
nobody who has used it for months is stopped by a rearrangement.

The menu also warns when the server is not fully provisioned and names the key
that fixes it, instead of reporting it as ordinary status lines that read as
normal unless you already knew what to look for. That check is cached for the
session because it shells out to flask twice and the answer does not change
while somebody reads the screen.
2026-08-05 13:42:20 -04:00
cproudlock
cb18d170cf Say whose type it is
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Two fields on the same page were both labelled "Type": the asset's own, and the
catalog model's. Only one of them was vague. "Model type" already says exactly
what it is; the bare "Type" did not say whose.

So the unqualified one is the one that changes. No new vocabulary, and "Model
type" reads correctly against it:

  Type  ->  Machine Type      (machines)
  Type  ->  PC Type           (computers)
  Type  ->  Printer Type      (printers)
  Type  ->  Device Type       (network devices)

Left alone everywhere the word is not ambiguous - measuring tools, subnets,
VLANs, notifications, supply types and the manifest editor have no model type on
screen to be confused with.

This is a labelling change only. It does not address the blank type column on
machines imported from the classic ASP database, which is a data gap the
backfill script fills; renaming a column heading was never going to put values
in it.
2026-08-05 10:33:47 -04:00
cproudlock
24266146d8 Show the model's type only when it differs from the asset's own
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
Adding a "Model type" row next to "Type" put two rows in the Hardware section
that read identically. They come from different tables - modeltypes is the
catalog-wide list spanning every kind of asset, machinetypes is machine-only -
but the names line up in practice: all 262 machines in the development database
match exactly, which is the same fact that makes the type backfill safe.

So the row now appears only when the two disagree, which is the case worth
seeing: a model catalogued as one thing fitted to an asset recorded as another.
When they agree it says nothing and is hidden. Applied to machines, PCs,
printers and network devices, each compared against its own type table.
2026-08-05 10:16:21 -04:00
cproudlock
3f320fcc8b Derive an asset's vendor from its catalog model, and show the model's own type
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and
the detail pages render "(from model)" beside the value, because the record
itself is still empty: the edit form shows an empty vendor box, and a page
implying the vendor is stored would be lying about where it came from.

The model's type is exposed under its own name, modeltypename, and shown as a
separate "Model type" row. It is deliberately NOT used to fill in the asset's
own type. modeltypes is the catalog-wide list covering every kind of asset - it
holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries -
so it is a different taxonomy from machinetypes. Only about two thirds of the
names overlap, and mapping one onto the other would mistype the remainder, with
the failure mode being a machine labelled "Desktop PC".

scripts/backfill_vendor_from_model.py writes the derived vendor down for real,
since the display fallback leaves reports that read vendorid still seeing
nothing. It is a dry run unless given --commit, fills only rows where the
asset's vendor is NULL and the model names one, and never overwrites a vendor
somebody chose. It skips a table lacking either column, so it runs against a
server whose network migration has not been applied yet.

Verified against the development database by nulling one machine's vendor inside
a transaction: it was detected as fillable, restored to exactly its original
value, and the rollback left the row untouched.

FLASK_ENV is not forced by the script. The app already reads it from .env, and
overriding it demanded a SECRET_KEY the environment had no reason to supply.
2026-08-05 09:59:39 -04:00
cproudlock
b16f143467 search: asset lists search the type column they display
Every asset list shows a Type column (and printers a Model, machines and
network a Vendor), but the search filters only looked at the asset number,
name, serial and hostname. Searching a type returned zero rows: 'Part Washer'
on machines, 'Standard' on PCs, 'Thermal' on printers.

Extend the search on machines, computers, printers, network devices,
measuring tools and the unified asset list to cover the type name plus the
vendor/model where the list shows them. Joins are outer joins so an asset
missing a type or vendor still matches on its own fields; the core list uses
a correlated EXISTS instead, since its type-name filter already joins
AssetType.
2026-07-31 09:02:51 -04:00
cproudlock
b63690996a fix: location dropdowns rendered blank (wrong field) + require printer model
Some checks failed
CI / backend (push) Failing after 1m52s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
The location option label read l.location, but the Location.to_dict() field is
locationname, so every option rendered blank - the dropdown looked empty and
"massive" (a long list of blank rows). Fixed across all five affected forms:
printers, computers, network devices, network device form, and the subnets
location filter. Other .location uses (printer-driver URL, search-result label,
report bylocation key) are legitimately different fields, left alone.

Also require a model on the printer form: asterisk + required attr, plus a JS
guard in savePrinter (the native required is skipped while the select is
disabled with no vendor picked) that points the user at the vendor first.
2026-07-29 09:07:43 -04:00
cproudlock
e237cc2c05 dashboarddefaults: pick a kiosk from a dropdown (auto-fill FQDN) + Location wording
Adds GET /api/computers/display-kiosks - the displays that reported in (Kiosk
type), each with its derived FQDN (F<serial>.<domain>). The Dashboard Defaults
form gets a kiosk dropdown that fills the FQDN so admins pick a display instead
of typing an IP; IP stays an optional manual field. Table shows FQDN or IP.
'Business Unit' label -> 'Location' on this page + the settings nav.
2026-07-29 07:29:34 -04:00
cproudlock
31139267d1 Fix model images 404 under a subpath mount (withBase)
Model image URLs are root-relative (/api/models/image/...), so on an /ops
subpath deploy the raw <img src> resolved to the server root and 404'd. Wrap
every model-image src in withBase(): machine/printer/PC/network detail heroes,
the models settings preview, and the machine-badge / asset-label print pages.
withBase leaves external http(s)/data URLs untouched.
2026-07-20 11:14:00 -04:00
cproudlock
592ff49abe ADR-013 Phase 4: extract the 11 plugin routes embedded in core.js
core.js still routed plugin-owned pages directly. Extracted all 11 into the
owning plugin's route file + moved their views into plugins/<name>/frontend/:
- computers: reports/pc-relationships, settings/pctypemapping
- printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply
  monitoring)
- machines: settings/machinetypes
- network: settings/networktypes
- warranty: settings/dellwarranty
- slides: settings/slides (its route file gains a default export; it was
  toplevel-only)
- employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) -
  employees had no route file before; its pages lived only in core.js.

core.js now holds only core routes; all 14 bundled plugins are self-contained
under plugins/<name>/frontend/. Verified live: the extracted Machine Types
settings page renders in the settings rail from the machines plugin frontend.
Build + 58 vitest + naming green.
2026-07-19 00:02:32 -04:00
cproudlock
ebca0b00b0 ADR-013 Phase 4: relocate the remaining 9 plugin frontends (all 13 done)
Relocate warranty, measuringtools, network, printers, usb, notifications,
computers, and slides into plugins/<name>/frontend/. Each plugin's views are
pulled from wherever they lived (own dir, plus the shared views/settings/,
views/reports/, views/print/ dirs, and top-level views) into the plugin's
frontend/views/, and its route file becomes the self-contained routes.js.

Handled the messy cases:
- computers: name mismatch (its views live in views/pcs/) - moved by following
  the route file's own imports, so the dir name did not matter. Its OS/access-
  protocol/PC-type settings views move with it (only computers.js routed them).
- network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse,
  not directly routed) moved too so its `./` imports resolve.
- printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in
  views/print/ and PrinterQR imports it via @/views/print/qrLogo.
- slides: route file is toplevel-only (TVDashboard); SlideManager stays core
  (core.js routes /settings/slides).

frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds
only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/.
Verified live: Network (hub + moved sub-views), Computers (name mismatch),
GE-Enforce (helper), printedparts all render from their staged frontends. Build +
58 vitest + naming green.
2026-07-18 23:56:07 -04:00
cproudlock
1c6c7ba14b DB review fixes: drop redundant indexes + dead column, add CI MySQL-upgrade job
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
From the database review (verdict: sound-with-minor-issues). Applies the
actionable findings.

Redundant indexes: five non-unique secondary indexes duplicated a named idx_*
or a unique index on the same column - ix_communications_assetid,
ix_computers_hostname, ix_networkdevices_hostname, ix_printers_hostname (each
shadowing an idx_*), and idx_usb_serial (shadowing the serialnumber unique
index). Removed the redundant index source from the models (column index=True /
the extra db.Index) and added core migration 7d25 dropping the live duplicates.
The unique ix_*_assetid indexes are kept (they enforce assetid uniqueness).

Dead column: usbcheckouts.machineid was a NOT NULL soft-ref to the retired
machines table storing sentinel 0 (ADR-001). Dropped from the model + the
machineid=0 literal in selfhosted checkout; usb plugin migration 0002 drops it
live (downgrade restores it default 0).

Index: notifications.businessunitid (filtered by the shopfloor feed) was
unindexed; added index=True + notifications migration 0002.

CI: new migrations-mysql job proves the real multi-site deploy path - fresh
`flask db upgrade` + per-plugin install on utf8mb4 MySQL from empty, asserting
table count + charset and a clean second-run no-op. The pytest suite only
exercises SQLite create_all(), so a regression in the Alembic chain on MySQL
would otherwise ship undetected.

Verified: fresh core upgrade on a scratch utf8mb4 MySQL builds clean + no-op on
rerun (redundant indexes absent, unique assetid kept); plugin migrations applied
+ verified on the dev DB (machineid gone, bu index present). 953 backend tests
pass; naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:29:45 -04:00
cproudlock
9c8b2c9c9e DB review safe-fix: naive-UTC timestamp defaults (drop db.func.now)
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
DB review found four DateTime columns defaulting to db.func.now() (MySQL
session-timezone wall clock) while the rest of the schema stores naive UTC, so
one schema mixed two clocks and to_dict() labelled the local values UTC with a
'Z' suffix. Switch application.dateadded, computers.installeddate,
knowledgebase.lastupdated (default + onupdate), and slides.uploadeddate to the
module-level naive-UTC _utcnow callable already used elsewhere (apitoken.py).
ORM-side default only - no column-type change, no data migration; affects
new/updated rows.

Targeted tests pass (154); naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:09:29 -04:00
cproudlock
cd353b6432 Review safe-polish: docs accuracy, dead imports, no-emoji, geenforce robustness
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
From the full multi-agent review (0 high, 7 medium, 17 low findings). Applies
the mechanical, low-risk items; design/policy findings left for a decision.

Docs accuracy: CLAUDE.md contract 0.10.0 -> 0.11.0 and both stale Alembic head
citations -> 7d24_customfield_searchable / 31 migrations; Dockerfile bundled-
plugin comment fixed (drop nonexistent "equipment", add machines +
measuringtools, count eleven).

Style/naming (LOCKED rules): remove a CSS-escaped pushpin emoji before location
search results (no-emoji policy); rename ManifestEditor shareRoot -> shareroot
(variable mirrors the API field verbatim).

Dead code: remove confirmed-unused imports across ~20 modules (require_role/
require_permission scaffold residue, stray db/Vendor/Model/current_user/Optional/
error_response); drop unused build_scope import + a stale GEENFORCE_API_KEY
docstring clause in geenforce. Migration files left untouched.

Correctness: geenforce ingest robustness - record_enforcement_report now 400s
on a non-dict counts / non-list results instead of 500; _apply_app_link ignores
a non-numeric appid per its docstring instead of 500. Regression tests added.

Backend query.get sweep finished: auth.py refresh -> db.session.get (last one).

910 backend tests pass; pyflakes clean; naming green; frontend build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:02:43 -04:00
cproudlock
456a44104b GE-Enforce polish: DDL-parity guard test, retire Collector PC Types page
All checks were successful
CI / backend (push) Successful in 1m40s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Add test_geenforce_ddl_parity to lock the manifest models against their
Alembic baseline (catches model/migration drift for a chain that is still
amendable pre-deploy).

Retire the "Collector PC Types" settings card: GE-Enforce scope
computertypeid supersedes the pctypemap editor UI (ADR-012). The collector
still reads pctype_mapping(), so the backend map stays; only the editor
surface is removed, with a deprecation note in pctypemap.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:25:01 -04:00
cproudlock
1672e349e5 Collector auto-links measuring tools for metrology PCs; settings rail cleanup
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Metrology PCs (CMM, Keyence, Genspect, wax-and-trace imaging pc-types) drive
an attached measuring instrument. The PC itself stays a shopfloor PC, but the
collector now models the instrument:

- New METROLOGY_TOOL_MAP (pctypemap.py) maps those pc-types to a
  MeasuringToolType (CMM, Vision System, Genspect, Form Tracer).
- ComputersPlugin._sync_measuringtool_link creates the MeasuringTool asset
  once and a directional PC->tool "controls" relationship, tagged
  collector:measuringtool. Idempotent (re-push reuses, no duplicate asset) and
  self-archiving (a PC re-imaged to a non-metrology type deactivates the link
  but keeps the asset and any calibration history). Mirrors the printer-link
  pattern. The MeasuringToolType is created on demand if not seeded.
- 4 tests: create+link, idempotent re-push, non-metrology skip, repurpose
  archives. Non-metrology PCs never warn about a missing controls type.

Settings rail cleanup:
- Collapsible groups so the 13-group rail fits without scrolling (1511px ->
  488px). The group containing the current page expands; the rest collapse.
  CSS-drawn caret (ASCII source, no Unicode). Empty groups never render, in
  both the rail and the landing page.
- Measuring Tools group placed with the other asset groups (right after
  Machines) instead of appended last; empty placeholder positions the
  plugin-contributed cards.
- Operating Systems moved from PCs to General Reference: OS is cross-asset
  (PCs, machines, measuring tools, network devices all run one).

Plus docs/proposals/ge-enforce-plugin.md: a planning doc for refactoring
GE-Enforce/DSC into a shopdb plugin (manifest as shopdb data, payloads on
SMB/HTTP/inline), grounded in the real manifest schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:29:08 -04:00
cproudlock
275224822e Add collector PC->printer links and searchable custom fields
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Collector: the computers collector schema gains defaultprinter and
printers; apply_collector_payload resolves each reported identifier to
a printer asset (windowsname/hostname/sharename/assetnumber/IP,
first-hit case-insensitive) and idempotently syncs relationships -
defaultprinter (directional) for the default, connectedto for the
rest. Collector-created rows are tagged so a re-report archives dropped
links while manual relationships are never touched; unresolved
identifiers warn instead of failing. Both PC and printer detail pages
show the links via the shared relationships card (no frontend change).
GE-Enforce Win32_Printer collection snippet documented.

Searchable custom fields: a per-field searchable flag (migration 7d24);
global search matches custom-field values on flagged active fields and
routes each hit to the asset detail page, reusing the existing
(type,id) dedupe and search_<type>_enabled domain filter. Searchable
toggle on the Custom Fields settings page.

822 tests pass; both verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:01:20 -04:00
cproudlock
d187a2c535 Fix PC installed-applications rendering; minor UI cleanups
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
The PC detail Installed Applications section 500d and vanished on any
real PC: ComputerInstalledApp had no to_dict, so the endpoint errored
and the v-if hid the section. Added the serializer (curated version
wins over the raw collected string, app name + description included)
and aligned PCDetail to the flat payload; regression test added.

Also: employee detail skips its USB panels when the usb plugin is
disabled (was firing 404s), and the shopfloor kiosk header is now
light-on-dark for readability.

810 tests pass; PC 259 installed apps verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:37:58 -04:00
cproudlock
7dfbe7bf8a Add the get_permissions plugin hook (contract 0.10.0)
All checks were successful
CI / backend (push) Successful in 1m20s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Plugins declare their own RBAC permissions instead of core accumulating
them: 36 permissions moved out of the core catalog into the 9 owning
plugins (core keeps the 19 its own blueprints enforce). The catalog is
resolved dynamically (core + enabled plugins) and feeds the roles grid,
the token scope picker and ceiling, and flask seed permissions;
installing or enabling a plugin seeds its permissions automatically. A
disabled plugin drops out of the assignable catalog while existing role
links keep working. New plugins - bundled or external - now bring their
permissions with zero core edits.

781 tests pass; live-verified with a machines.edit-scoped token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 09:29:55 -04:00
cproudlock
7bde765f1a Render vendor-model photos on asset detail heroes
All checks were successful
CI / backend (push) Successful in 1m14s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Computer and Printer payloads now surface the linked model imageurl the
way machines already did, and the machine/PC/printer detail heroes
render the photo when present (network devices and measuring tools
have no model link, so nothing to surface). Absent images render
nothing rather than a broken icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:39:11 -04:00
cproudlock
46e50c07ff Add the API import surface for legacy migrations (contract 0.8.0)
All checks were successful
CI / backend (push) Successful in 1m4s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Goal: an LLM or script can migrate an entire legacy database using only
the HTTP API - original history preserved, safely re-runnable.

- X-Import-Mode header (admin only): create/update endpoints across 15
  timestamped entity types accept original createddate/modifieddate;
  helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0).
- Exact-match natural-key lookup filters on 13 list endpoints for the
  lookup-then-upsert recipe.
- Selfhosted USB checkout/checkin accept backdated event times in
  import mode.
- docs/IMPORT-API.md: operator manual grounded in the real legacy
  schema - order of operations, full table-by-table mapping including
  the machines fan-out, idempotent Python importer with dry-run, parity
  checks, and decided dispositions for unmigrated tables (DNC config
  stays live-fed via the collector; supportteams/appowners map to the
  upcoming supportteams model).

635 tests pass; naming green; frontend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:10:46 -04:00
cproudlock
48d3160bc5 Rename the equipment domain to machines; retype the models catalog (ADR-011)
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:17:42 -04:00
cproudlock
22e623c1f6 Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework:
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin
  carries its own chain with a stamp-only anchor at the ownership cutover;
  new plugin schema lands in plugins/<name>/migrations/, never the core
  chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the
  shared alembic template (engine URL resolution) and taught the metadata
  filter to include FK-referenced core tables.
- Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin;
  a disabled plugin's pages redirect to the dashboard via a cached,
  fail-open check against the new public GET /api/plugins/enabled.
- get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute
  report cards; warranty and toner cards moved off the hardcoded list.

Reports:
- Hub grouped by category with search; inline reports render at the top,
  are URL-backed (?report=id, back-button and deep links work), expose
  their server-side filter params as controls, and export CSV. Warranty
  and Toner pages gained CSV export.
- Deleted the dead legacy Warranty Status report (always-zero buckets
  from a retired column).

Theming and fonts:
- Inter (variable) bundled locally via @fontsource, replacing the Google
  Fonts Roboto import - air-gapped installs now render correctly; tables
  use tabular numerals.
- Optional brand_primary_dark_color, brand_accent_color,
  brand_sidebar_color settings applied to CSS vars at bootstrap.

USB frontend repair (views were reading a dead legacy shape):
- List/detail/form and the employee profile USB panels remapped to the
  real API shape (device_id/device_desc/checkinoutlog); employee panels
  now use /usb/checkouts endpoints; external-mode /usb/checkouts/active
  honors the badge filter; dead client methods pruned.

Also: warranties list page no longer requires login (matches app
convention); collector doc rewritten with a GE-Enforce integration guide
and paste-ready PowerShell reporter; ADR index and CHANGELOG updated.

Verified: 323 tests pass, naming/style green, frontend builds, plugin
migration dry-run green on scratch MySQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:01:47 -04:00
cproudlock
b8c22244a1 Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00
cproudlock
78a0ee8d83 Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:37:21 -04:00
cproudlock
10ed83e14c Collector: ingest GE-Enforce/enrollment data with configurable pc-type mapping
Extends the computers collector so it can replace the classic api.asp
updateCompleteAsset path that the shopfloor PC fleet uses to auto-update data.

Collector schema (project naming) now accepts the GE-Enforce/enrollment shape:
machinenumber, pctype, pcsubtype, serialnumber, loggedinuser, lastboottime,
lastcheckin, ipaddress, vendorname, modelnumber, osname, installedsoftware.
- machinenumber -> Asset.assetnumber (skips the 9999 imaging placeholder, falls
  back to hostname), on create and update.
- pctype -> ComputerType via a configurable mapping (see below).
- vendor/model created if missing (free vocab); OS looked up (controlled, warns
  if unknown); pcsubtype accepted but not yet stored (warning).
- Dropped per scope: VNC/WinRM flags, warranty, DNC config, multi-NIC.

Configurable pc-type mapping (the gea-shopfloor-* imaging taxonomy ->
ComputerType): defaults + resolution live in plugins/computers/pctypemap.py
(plugin domain, contract-pure - reads Setting via shopdb.api); overrides stored
as pctypemap_<pxetype> settings, seeded on plugin install, edited in Settings >
System > "Collector PC Type Mapping" (new UI section).

Migration doc: docs/COLLECTOR-INTEGRATION.md maps classic api.asp fields +
GE-Enforce status fields to the collector schema, documents machine-number
sourcing (registry MachineNo first, then C:\Enrollment\machine-number.txt) and
that the transport is interim.

Tests: complete-asset payload maps machinenumber/pctype/vendor/model/os; 9999
placeholder falls back to hostname. 186 tests pass, naming green, app boots,
mapping UI verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:37:06 -04:00
cproudlock
5fa5160420 Apply skill-driven review fixes: security, hook isolation, tests, docs
Addresses findings from a 6-lens review against the project skills
(defining-asset-contract, enforcing-plugin-contract, hardening-flask-config,
integrating-plugin-hooks, pinning-flask-behavior, simplifying-python).

Security (hardening-flask-config):
- Load per-plugin COLLECTOR_API_KEY_<PLUGIN> from env in create_app. from_object
  only copies class attributes, so per-plugin keys (ADR-006) were dead in real
  deploys and silently fell back to the shared key.
- EMPLOYEE_DB_USER/PASSWORD no longer default to root/rootpassword (no safe
  default for a secret; unset fails loud). Documented in .env.example + DEPLOY.md.
- COLLECTOR_API_KEY + per-plugin + EMPLOYEE_DB_* added to .env.example/DEPLOY.md.

Hook isolation (integrating-plugin-hooks):
- collector _collector_plugins and dashboard get_navigation now re-raise in
  dev/test and log+isolate in prod, instead of silently swallowing a broken
  plugin hook.

Plugin loader (enforcing-plugin-contract):
- enable_plugin/install_plugin read dependencies+version from the manifest
  instead of instantiating the plugin class.
- _register_plugin_components rejects a second plugin claiming an already-used
  api_prefix (reset per app in init_app).

Tests (pinning-flask-behavior):
- test_identifiers.py: gauge/maintenance round-trip on computer/printer/network
  create+update; per-type seed yields the 12 identifier keys.
- contract tests for apply_collector_payload presence + schema-declarers-implement.
- security tests for per-plugin key env loading + no employee-db password default.

Docs/contract sync (defining-asset-contract):
- PLUGIN-HOOKS.md documents apply_collector_payload; stale 0.2.0 -> 0.3.0.
- ADR-006 documents apply_collector_payload + single-dispatch rationale.
- ADR-001 enumerates the expanded shopdb.api import surface.

Simplify (simplifying-python):
- De-duplicate the 21-entry settings defaults: shared build_default_settings()
  used by both the /settings/seed route and the CLI (were drifting copies).
- Remove dead AssetStatus import + redundant AssetType local import in computers
  plugin; comment the statusid=1 collector default.

153 tests pass (was 145), naming/style green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:25:52 -04:00
cproudlock
f663cc5bbe Enforce plugin contract purity: single import surface via shopdb.api
Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:45:06 -04:00
cproudlock
37ffb4add5 Make gauge/maintenance identifiers per-asset-type and extend to all types
Optional asset identifiers (gauge lab reference, maintenance reference, FQDN)
were global per-identifier and only surfaced on equipment. Now they are
toggleable per asset type and rendered on every asset type.

- settings: replace 3 global identifier toggles with a per-type matrix. New
  keys identifier_<name>_<assettype>_enabled (3 identifiers x 4 types).
  IDENTIFIER_LABELS / IDENTIFIER_ASSETTYPES constants drive the seed (API seed
  and CLI seed settings).
- composable: identifierSettings now exposes isEnabled(name, assettype),
  per-type flag winning over the legacy global key, defaulting on.
- backend writes: computers, network, printers asset create + update now
  accept gaugelabreference and maintenancereference (equipment already did).
  Reads already flowed through Asset.to_dict.
- frontend: Settings page renders an identifier x asset-type toggle matrix.
  Equipment, PC, printer, network forms and detail pages show gauge/maintenance
  (and FQDN where applicable) gated by isEnabled(name, type).

Legacy global identifier_<name>_enabled keys are still honored as a fallback
for older installs. SystemSettings toggles upsert (create on 404) so a deploy
that has not re-seeded still works on first toggle.

144 tests pass, naming/style check green, frontend builds. Verified live:
matrix renders, PC form shows the fields, PUT persists gauge/maintenance on a
PC and reads back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:34:50 -04:00
cproudlock
b567de14ac Add ADR-006 generic plugin collector contract
Implements the plugin collector contract from ADR-006 so any plugin can
accept idempotent inventory ingest, not just PCs.

- base.py: add apply_collector_payload hook (companion to get_collector_schema),
  raises NotImplementedError by default for plugins that declare a schema but
  do not implement the upsert.
- collector.py: generic POST /api/collector/<plugin> dispatch with per-plugin
  API key (COLLECTOR_API_KEY_<PLUGINNAME> with COLLECTOR_API_KEY fallback),
  schema-driven identity validation, idempotent upsert, ADR-006 response
  contract (status, action, assetid, identityvalue, warnings), audit log.
  JWT-protected GET /api/collector/_schemas lists registered schemas. Legacy
  /pc, /apps, /heartbeat, /bulk kept for back-compat.
- computers plugin: implements get_collector_schema (identityfield hostname)
  and apply_collector_payload (create-or-update Asset+Computer, serialnumber,
  loggedinuser, lastboottime, primary IP communication, installed apps).
- tests: 7 collector-contract tests (auth, 404, validation, create/idempotent
  update, per-plugin key precedence, JWT schema listing).

A single dynamic dispatch route is used instead of per-plugin blueprint
registration, avoiding Flask's register-blueprint-after-first-request error.

144 tests pass, naming/style check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:14:27 -04:00
cproudlock
4f1638bda0 Consolidate bundled plugin schema into the core migration chain
The core chain already owns and reproduces the full bundled schema (deploys run
`flask db upgrade` only). The per-plugin Alembic baselines duplicated those
tables, so `flask plugin upgrade-all` would conflict - a footgun. Remove the 6
bundled plugin migration dirs; the per-plugin Alembic helpers
(alembic_template, PluginMigrationRunner) remain for external/filesystem
plugins. upgrade-all now cleanly no-ops for bundled plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:48:07 -04:00
cproudlock
fc93506af3 Fix list filters + align API params to the concatenated convention
Backends read snake_case filter params (type_id, vendor_id, location_id,
businessunit_id, status_id, os_id) while the frontend sends the concatenated
form (typeid, vendorid, ...), so the network/notifications/printers list
filters silently did nothing. Backends now read the concatenated name with a
snake_case fallback (no breakage), matching the locked naming convention.

Verified: network type filter now narrows results (45 -> 25 for Access Points).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:36:47 -04:00
cproudlock
e1948a4774 Migrate PC form + collector off legacy Machine onto the asset/computer model
- PCForm saves/loads via computersApi (asset core + computer extension +
  primary IP in one call); PC Type now uses the dedicated computertypes table
  instead of MachineType, fixing the cross-table id mismatch.
- Collector (/api/collector/*) writes the Computer model: lookup by hostname
  or asset number, update loggedinuser/lastreporteddate/lastboottime + asset
  serial, installed apps via ComputerInstalledApp.
- Add computers.vendorid + modelnumberid (PCs carry make/model) and
  computerinstalledapps.installedversion; computer GET now includes
  communications. Wire COLLECTOR_API_KEY into config.

Retires the last write paths to the Machine model for PCs (ADR-001).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:16:09 -04:00
cproudlock
42ae985d72 Phase 7B: per-plugin Alembic chains for bundled plugins
Each of the six bundled plugins (computers, equipment, network,
notifications, printers, usb) now has its own Alembic chain with a
baseline migration. Sister sites adopting one of these plugins can
manage its schema via `flask plugin migrate <name>` instead of relying
on db.create_all to bootstrap everything.

Existing single-site deploys that bootstrap via db.create_all continue
to work unchanged. The chains coexist; the bootstrap path stays the
operator's choice.

Framework
- shopdb/plugins/alembic_template.py: shared env.py logic + helpers.
  PLUGIN_TABLE_OWNERS pins which tables belong to which plugin (explicit
  registry, not import-side-effect). _get_plugin_metadata filters
  db.metadata to only the named plugin's tables. create_plugin_tables /
  drop_plugin_tables emit DDL via SQLAlchemy CreateTable so the table
  definitions stay sourced from the models, not duplicated.
- shopdb/plugins/__init__.py: PluginManager.upgrade_all_plugins() runs
  pending migrations across every discovered plugin and returns a status
  dict. Idempotent (Alembic skips applied revisions).

CLI
- `flask plugin upgrade-all` runs pending migrations for every plugin.
  Used on a fresh deploy after the core schema is in place.

Per-plugin scaffolding
- plugins/{computers,equipment,network,notifications,printers,usb}/
  migrations/{alembic.ini, env.py, script.py.mako, versions/0001_baseline.py}
- Each env.py is a 5-line shim that sets PLUGIN_NAME and delegates to
  the shared template. Each 0001_baseline calls create_plugin_tables(name)
  / drop_plugin_tables(name); no duplication of column definitions.

Tests
- tests/test_plugin_migrations.py (18 cases): every bundled plugin has
  an entry in PLUGIN_TABLE_OWNERS, has the on-disk Alembic scaffolding,
  and the filtered MetaData contains every owned table (catches drift
  between the template's table list and what the models declare).
- 129 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:20:07 -04:00
cproudlock
40686ab0d2 Phase 7A: wire ADR-001 asset position contract surface
Lock the position-resolution columns from ADR-001 in code so
resolve_asset_position's relationship walk activates.

Schema
- Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy
- Location.mapx / Location.mapy added (fallback for priority 3 of the
  ADR-001 resolution chain)
- AssetRelationship.label (free-text nuance per ADR-001)
- AssetRelationship.inheritsposition (bool, server_default true, controls
  whether the resolved-position walk follows the edge)
- RelationshipType.propagatesthroughid (self-FK; sibling-propagation rail)

Seeds
- Three canonical ADR-001 relationship types created idempotently:
  partof, controls, connectedto
- controls.propagatesthroughid wired to partof (partof + connectedto stay
  null per ADR-001 table). Both via Alembic migration AND CLI seed command
  so a fresh test fixture and a sister-site deploy both end up correct.
- Legacy connection types (Serial Cable, Direct Ethernet, USB, WiFi,
  Dualpath) retained for backward compat with pre-1.0 relationship rows.

Resolver
- shopdb.api.resolve_asset_position now walks inheritsposition=true edges
  of type partof (then controls), recursively, depth-capped at 3 with
  visited-set cycle protection. Inactive edges + non-inheritable types
  are skipped. Falls through to the existing location fallback when the
  walk yields nothing.

Tests
- 11 new test_api_namespace cases cover: partof walk, controls-after-
  partof ordering, connectedto skipped, inheritsposition=false skipped,
  recursion, cycle break, depth-3 cap, self-beats-related, related-beats-
  location, inactive-edge skip.
- 111 tests pass. Naming/style check green.

Migration
- migrations/versions/7a01_adr001_position_contract.py:
  - alter_column renames on assets (no data loss)
  - add_column on locations + relationshiptypes + assetrelationships
  - idempotent seed of three ADR types + propagation FK wire-up
  - downgrade reverses + best-effort deletion of seeded types that have
    no FK refs

Backend rename (mapleft/maptop -> mapx/mapy)
- shopdb/core/api/assets.py
- plugins/{computers,equipment,network,printers}/api/...
- scripts/migration/migrate_assets.py
- Legacy Machine model + machines API + import_from_mysql.py UNCHANGED
  (per ADR-001 Machine retires; not part of the asset contract)

Frontend rename
- frontend/src/components/ShopFloorMap.vue
- frontend/src/views/{MapEditor.vue, pcs/{PCDetail,PCForm}.vue,
  printers/{PrinterDetail,PrinterForm}.vue,
  machines/{MachineDetail,MachineForm}.vue,
  network/NetworkDeviceForm.vue}
- Form field labels + v-model bindings + computed flags switched in
  lockstep with the backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:14:22 -04:00
cproudlock
9d3482fa21 Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register
Hardens the plugin framework so sister-site adoption is safe.

Loader rewrite (shopdb/plugins/loader.py):
- Reads manifest.json directly. Dependency sort and version checks
  no longer instantiate plugin classes (avoids __init__ side effects).
- Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin
  errors re-raise. In production, errors log with full context and
  the plugin is excluded from registration. Framework keeps booting.
- Contract-version range check via packaging.SpecifierSet. Plugin's
  manifest.core_version must include the framework's
  __contract_version__ or load fails per the policy above.
- Manifest validation: required fields (name, version, description),
  name matches directory, JSON parseable.

Exceptions (shopdb/exceptions.py):
- PluginNotFoundError, PluginContractError, PluginVersionError,
  PluginDependencyError. Specific types replace generic Exception
  swallowing.

Auto-register core blueprints (shopdb/__init__.py):
- CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core
  resource is one entry, not three lines (import + register call).
- Replaces 27 hand-coded register_blueprint calls.
- Asserts each blueprint is exported by shopdb.core.api at boot.

Public API namespace (shopdb/api/__init__.py):
- audit_log: thin wrapper over AuditLog.log() with stable signature.
- resolve_asset_position: implements ADR-001 position resolution
  (asset > related > location). Asset.mapx/mapy and
  AssetRelationship.inheritsposition columns are part of the locked
  contract surface but not yet in models; helper degrades gracefully
  to location-only fallback until the migration lands.

BasePlugin helpers (shopdb/plugins/base.py):
- get_setting(key, default), set_setting(key, value, ...). Settings
  namespaced as plugin.<pluginname>.<key> so two plugins can use the
  same key without colliding.

Manifest version compatibility (plugins/*/manifest.json):
- Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all
  bundled plugins satisfy the new range check.

Contract version bump (shopdb/__init__.py):
- 0.1.0 -> 0.2.0. Additive surface change (Setting helpers,
  shopdb.api namespace) per ADR-002 minor-bump rules.

Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py):
- 13 loader tests: manifest validation failures, version range
  checks, plugin.py import errors, strict-vs-isolate behavior under
  TESTING vs production-like config, manifest-first dependency sort.
- 8 api-namespace tests: audit_log roundtrip, resolve position
  fallback chain, plugin.get_setting/set_setting roundtrip with
  per-plugin namespacing.

Test count: 66 -> 87 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:15:28 -04:00
cproudlock
2c36c0c873 Add system settings, audit logging, user management, and dark mode fixes
System Settings:
- Add SystemSettings.vue with Zabbix integration, SMTP/email config, SAML SSO settings
- Add Setting model with key-value storage and typed values
- Add settings API with caching

Audit Logging:
- Add AuditLog model tracking user, IP, action, entity changes
- Add comprehensive audit logging to all CRUD operations:
  - Machines, Computers, Equipment, Network devices, VLANs, Subnets
  - Printers, USB devices (including checkout/checkin)
  - Applications, Settings, Users/Roles
- Track old/new values for all field changes
- Mask sensitive values (passwords, tokens) in logs

User Management:
- Add UsersList.vue with full user CRUD
- Add Role management with granular permissions
- Add 41 predefined permissions across 10 categories
- Add users API with roles and permissions endpoints

Reports:
- Add TonerReport.vue for printer supply monitoring

Dark Mode Fixes:
- Fix map position section in PCForm, PrinterForm
- Fix alert-warning in KnowledgeBaseDetail
- All components now use CSS variables for theming

CLI Commands:
- Add flask seed permissions
- Add flask seed settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:16:56 -05:00
cproudlock
83caaa7b7c Add print badges, pagination, route splitting, JWT auth fixes, and list page alignment
- Fix equipment badge barcode not rendering (loading race condition)
- Fix printer QR code not rendering on initial load (same race condition)
- Add model image to equipment badge via imageurl from Model table
- Fix white-on-white machine number text on badge, tighten barcode spacing
- Add PaginationBar component used across all list pages
- Split monolithic router into per-plugin route modules
- Fix 25 GET API endpoints returning 401 (jwt_required -> optional=True)
- Align list page columns across Equipment, PCs, and Network pages
- Add print views: EquipmentBadge, PrinterQRSingle, PrinterQRBatch, USBLabelBatch
- Add PC Relationships report, migration docs, and CLAUDE.md project guide
- Various plugin model, API, and frontend refinements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 07:32:44 -05:00