91 Commits

Author SHA1 Message Date
cproudlock
72b3904f71 Release 0.11.3
Some checks failed
CI / backend (push) Failing after 7m14s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m9s
CI / migrations-mysql (push) Failing after 7m18s
The last of the buildings-and-levels bugs, and the shop-floor clients brought
into the product.

Anyone on 0.11.0 through 0.11.2 should take this. Every hover mini-map in the
product reported "this asset has a position but no level" - the levelid prop
added in 0.11.0 was passed by none of its seven call sites - and the map PDF
printed markers from every floor onto one sheet, which nobody can correct once it
is carried onto the floor. The legacy import loader, still to run against
production, created markers with no level at all.

The gate that should have caught all three asked whether a FILE mentions levelid
rather than whether each position does. It now checks per occurrence.

Also: printers can be assigned to a MACHINE and reach whichever PC controls it,
so a reimaged bay reinstalls its own printers with nothing saved off the old PC;
printerdrivers can name a vendor, so two rows cover 41 of 44 printers instead of
twenty-one near-duplicates; and the collector reporter and EventSaver now live in
the repository with no site baked into either, the reporter generated per site on
request.

The client scripts were validated on Windows 11 against a live ShopDB, not only
by the suite: a bay with no rows of its own created both queues from its
machine's assignment, bound them to the right universal drivers, and set the
per-user default.

The version and the changelog are the release; the detail is in the entry.
2026-08-19 10:56:52 -04:00
cproudlock
8cedf674fb Resolve a driver by vendor, and converge a bay's printers from ShopDB
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Two rows now cover 41 of 44 printers. printerdrivers could only bind a driver to
ONE modelnumberid, so the HP and Xerox universal drivers - which between them
cover almost the whole floor - would have needed 21 near-duplicate rows pointing
at the same package. That is a table nobody keeps true, and it is why 42 of 44
printers resolved no driver at all.

printerdrivers gains vendorid, and resolution runs most-specific-first: the
printer's model, then its vendor, then the pre-vendorid convention of matching
the vendor word in the driver's name so a site that populated the table before
the column existed does not silently lose every driver on upgrade. A row that
names a vendor is never matched by its text, because a mis-set vendor resolving
to the wrong package is worse than resolving to none.

Six rows now resolve 44 of 44 printers at the reference site, and the DesignJet
correctly takes its own driver over the HP universal one.

Set-ShopdbPrinters.ps1 is the client half: ask for-host, create the queues that
are missing, record the desired default. It NEVER removes a queue - a bad minute
from the API must not take printers away from a working bay - and it never
fetches a driver, because downloading 48 MB while somebody waits to print is the
wrong moment. The common scope stages those.

Apply-ShopdbDefaultPrinter.ps1 applies the default in the USER's context, which
is the only context that can: SYSTEM cannot set a per-user default for somebody
else. It also turns off "Let Windows manage my default printer", without which
Windows silently overwrites the choice the next time anyone prints elsewhere -
a fix that undoes itself within a day.

VALIDATED ON WINDOWS 11 AGAINST A LIVE SHOPDB, not only by tests. Printers were
assigned to a MACHINE; a PC controlling it, holding no rows of its own, created
both queues bound to the right universal drivers, recorded the default and set
it, and a second run changed nothing. The first attempt failed with
"Relationship types are not seeded - run: flask seed reference-data", which is
the deployment trap the plan predicted, caught by an explicit error rather than
silently resolving nothing.
2026-08-19 10:24:53 -04:00
cproudlock
0dc0ac13c8 Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.

New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.

Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.

GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".

PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.

One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.

printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
2026-08-19 09:33:22 -04:00
cproudlock
03d0754fdc Stage printer drivers as a deployable set, for the common scope
Assigning a printer to a bay is useless if the bay cannot install it, and the
fleet data says why that mattered: 42 of 44 printers could not resolve a driver.
This is the delivery half - the drivers themselves, staged once per bay, so that
creating a queue never waits on a download.

Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then
pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1
does a site's whole set from drivers.json, and answers a compliance question with
-TestOnly, which is what makes it a clean DSC Script resource rather than a
fire-and-forget install.

Deliberately SEPARATE from assignment. Drivers are large, near-identical across a
fleet and change rarely; assignments are small, per-bay and change often. Staging
the set in the GE-Enforce common scope means the assignment client only ever
creates a queue - it never fetches a 48 MB package while somebody is waiting to
print, or discovers the share is unmounted at the worst moment.

THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it.
certutil -addstore on the .cat file satisfied the Xerox package and failed every
HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been
established as trusted" - a coin toss, not a mechanism. The certificate is now
extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for
every catalog under the package rather than the first INF's neighbours. On a
locked bay there is no prompt to answer, so the old failure was silent.

Verified on Windows against real packages, not by reading: all six drivers this
site needs install through the script, a second run is a no-op, a wrong driver
name fails with the names the package actually offers, and the DSC cycle behaves
- TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0.

The packages themselves stay out of git: they are licensed vendor binaries, and
they belong on the share beside the other imaging payloads.

DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the
Intune shape, plus the constraint that has cost a session before: the SFLD share
is mounted only during the enforcement cycle, so this runs as a manifest entry
and never as its own scheduled task.
2026-08-19 09:33:05 -04:00
cproudlock
2083029ff2 Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
2026-08-18 15:51:14 -04:00
cproudlock
96f127f8c8 Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the
adoption guide, "provided on request" - which is not a distribution mechanism for
a product meant to be adopted. It now lives in plugins/computers/client/, beside
the collector contract it implements (ADR-006), so the two version together.

Three things named West Jefferson and no longer do (ADR-015):

- The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value
  Install-GEEnforce.ps1 already writes and the enforcement client cannot run
  without - or from -ApiUrl. With neither it logs and exits 0 rather than
  posting somewhere wrong. Any bay running this script runs the enforcement
  client, so the value is present wherever it is deployed.
- The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was
  reported, with a comment reading "update if site re-VLANs". A site may now
  name its ranges (-AllowedRanges, or a CollectorRanges registry value); with
  none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
  "the routable NIC, not the controller NIC" without knowing any site's
  addressing.

VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The
local array was called $allowedRanges, which is the SAME VARIABLE as the [string]
parameter $AllowedRanges because PowerShell names are case-insensitive; the array
was silently coerced to an empty string, and .Count on a scalar string is 1. The
script therefore believed a range was configured, skipped the default-route
fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to
$rangeList, and the four paths were then confirmed on Windows: no config skips
cleanly, BaseUrl resolves from the registry, an unconfigured site picks the
default-route NIC, a configured range selects or excludes as asked.
2026-08-18 09:59:51 -04:00
cproudlock
f34b9ca710 Carry the level everywhere a position is drawn, and gate it per occurrence
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00
cproudlock
8bde89c47e Release 0.11.2
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 6s
Bug fixes for the buildings-and-levels work in 0.11.0, every one of them found by
using the feature rather than by the suite.

Anyone running 0.11.0 or 0.11.1 with more than one level should take this
release: the map drew markers from every level on whichever floor plan happened
to be showing, which is the failure ADR-017 exists to prevent, and it was in the
one component that draws the map. The viewer also had no way to choose a level,
and the editor never accepted a click at all - its handler was bound only if the
map was already a picker at mount, and the editor opens with nothing selected.

Also: a level's native size could not be set (the settings page had no height
field), the same search could return different rows because fourteen searchers
truncated without an ORDER BY, and both map pages carried a scrollbar from
subtracting the wrong page chrome from the viewport height.

No schema change, and the plugin contract stays at 0.20.0.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 15:46:38 -04:00
cproudlock
afd3dce493 Give the map editor's search box room for its own placeholder
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 previous commit stopped the panel header from colliding with the "Assets"
heading, but left the search box sharing a row with the type dropdown: 139px of
input for a placeholder needing 216px, so it still read "Search name o". A
control whose own label does not fit is not a narrower control, it is an
unlabelled one.

One control per row in a 318px panel. Measured in the browser: the input is now
286px against 216px of text, so the placeholder reads in full.
2026-08-17 15:24:37 -04:00
cproudlock
89248407e7 Make the map editor accept a click, and unclutter its panel
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
Clicking the map in the editor did nothing, ever. The click handler was bound
inside `if (props.pickerMode)` at mount, and the editor mounts with no asset
selected, so the handler was never attached; selecting an asset flipped the prop
but nothing rebound it. The asset forms were unaffected because their picker
mounts inside a modal that is already in picker mode, which is why this looked
like an editor-only fault. The handler is now bound unconditionally and
handleMapClick keeps its own picker-mode guard.

Verified in a browser against the dev instance, both ways: with the old binding
a click on a selected asset produced no position at all; with the fix the same
click reports 1652, 1138.

Markers are now drawn in picker mode too. Placing one relative to the machines
already on the floor is the entire task, and the old code skipped rendering them
whenever the map was a picker.

Two things that looked like stray widgets:
- The editor's panel header put a heading and three controls on one row inside a
  320px panel, squeezing the search box until its placeholder read "Search na".
  The heading takes its own row and the controls share the next.
- The legend drew its bar and border even with nothing to put in it, which read
  as an empty input box under the toolbar. It renders only when it has entries.
2026-08-17 15:20:44 -04:00
cproudlock
aa6db94179 Fix the levels viewer: markers were drawn on whichever plan was showing
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Five defects, four of them mine from 0.11.0, found by using the feature.

THE SERIOUS ONE: ShopFloorMap never checked a marker's level. It skipped null
coordinates and drew everything else on whatever blueprint was displayed, so
level 1 markers appeared on level 2 - the exact failure ADR-017 exists to
prevent, in the one component that draws the map. The build gate did not catch it
because that rule checks files EMITTING mapx, not the component consuming it.
Markers are now filtered to the drawn level, and a position with no level is
omitted rather than approximated.

The map page had no way to choose a level at all. It read currentlevelid only to
title the PDF, so a second floor was unreachable from the viewer that most people
use. Adds a level selector (hidden when a site has one level), passes it to the
map, and switches the drawing, the bounds, the coordinate space and the markers
together - swapping the image without the bounds would place every marker against
the wrong scale.

Searching the map now follows results across levels: a search whose matches are
all on another floor showed an empty map while the filter counted them.

Floor map settings had NO height input - only width - so a level's native size
could not be set even while empty, which is the one time it is editable. Both
fields are there now, and size is editable on a level that has markers, because
refusing it blocked the case the feature was built for: a new blueprint of new
dimensions on a floor already full of markers. It confirms first and points at
landmark recalibration.

Search results differed between the sidebar box and the results-page box:
- 14 of 16 searchers truncated with .limit() and no ORDER BY, so the database
  could return a DIFFERENT subset of matching rows for the same query. Every
  searcher now ends in a total order (display key plus primary key).
- Searching a term already in the URL was a duplicate navigation the router
  aborts, so the route watcher never fired and the button did nothing. The
  sidebar never hit this, because it always navigates from another page - which
  is why the two boxes appeared to disagree.

Also removes a scrollbar from both map pages. They subtracted 2rem and 40px from
100vh for the page chrome, which is really 90px of padding on .main-content, so
each overflowed by the difference. The padding is now a CSS variable both the
layout and the pages read. Measured in the browser before and after: 1058 vs a
1000px viewport, now 1000.
2026-08-17 14:59:44 -04:00
cproudlock
dd503be4ba Release 0.11.1
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
A patch release. The pagination-cap fixes, and the documentation that missed the
0.11.0 installer by a few minutes.

docs/UPGRADE.md is one of the documents the Windows installer puts on the server,
and its buildings-and-levels section was committed after 0.11.0 was tagged. So
0.11.0 shipped the page an operator reads WHILE upgrading without the guidance
about that upgrade - which matters most on an air-gapped box, where there is no
other way to reach it. The installer now also ships FLOOR-MAP.md for the same
reason the Windows runbooks ship.

No application code changed on the server side and the plugin contract stays at
0.20.0.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 14:38:59 -04:00
cproudlock
5f5ed7b1d4 Record the pagination-cap fixes in the changelog
Some checks failed
CI / backend (push) Failing after 6s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
2026-08-17 14:17:21 -04:00
cproudlock
62f4a42210 fix: page past the 100-row cap in batch label sheets and asset pickers
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 6s
Follow-up to the application-picker fix. Three of these were already wrong on
data that exists today, not merely latent.

Batch label printing is the worst of them: AssetLabelBatch asked for 500
machines or PCs, got 100, and printed a sheet that looked complete. With 262
machines and 290 PCs in the catalogue that is a physically short run with no
error anywhere - the operator finds out at the label printer, or later at the
bay with no label on it. PrinterQRBatch, USBLabelBatch and PrintedPartsLabels
had the same shape and are fixed alongside it, before their tables cross 100
too.

MachineForm's "controls" PC dropdown offered the first 100 of 290, so a
machine could not be linked to a PC sorting late in the list. NetworkDeviceForm
had it for models, which are already past 100 - and the same file already
called modelsApi.listAll() correctly two lines away.

Adds listAll() to the machines, computers, printers, network, measuring-tools,
USB and printed-parts APIs, all delegating to fetchAllPages().

Still outstanding: callers of vendors, locations, business units and the type
catalogues that ask for more than 100. Those tables are all well under the cap
today, so they are correct for now and wrong the day they are not.
2026-08-17 14:10:08 -04:00
cproudlock
9c1c6c5729 fix: page past the 100-row cap in application pickers
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 8s
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports
nothing about having done so, so a caller asking for perpage: 1000 gets the
first 100 rows and a success response. Every picker built that way looked
complete and was not.

Found on a live site with 126 active applications: the 26 sorting last were
absent from the knowledge-base topic dropdown, so an article could not be
filed against them. Nothing was wrong with those application records, and
editing them could never have helped.

Adds fetchAllPages() to the api module, generalizing the one call site that
already handled this correctly (modelsApi.listAll), and points the four
application pickers at a new applicationsApi.listAll(): the KB article form,
the KB list's topic filter, the notification form, and the report filter
builder.

Lists that render a page at a time are untouched - they page for a reason.
Other callers still asking for more than 100 rows of vendors, locations,
models, subnets and the rest are latent: correct only while those tables stay
under 100, and silent on the day they do not.
2026-08-17 14:06:35 -04:00
cproudlock
741dda5be7 Document buildings and levels where the old single-map model was still taught
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 0.11.0 release changed what a map position means and six documents still
described the model it replaced. Each of these could have caused a real mistake
rather than being merely out of date:

- IMPORT-API mapped legacy mapleft/maptop to mapx/mapy with no mention of the
  level, so a scripted import - including the classic-ASP one still to run
  against production - would have produced markers the map shows as "level
  unknown". It now maps levelid too and says how to resolve the default level.
- API-REFERENCE enumerates the unauthenticated surface in full, because that is
  what a deploy reviewer reads, and the three public /api/maplevels reads were
  missing from it. Also records why the write split is asymmetric: repositioning
  needs assets.edit, creating a level needs admin, since a level's dimensions are
  the coordinate space every marker on it is expressed in.
- CONFIG still presented the four map_* settings as live, telling the reader to
  re-upload a blueprint in a settings page that no longer drives the map. They
  are marked superseded and kept for downgrade.
- UPGRADE gained a 0.11.0 section: nothing moves on screen, and replacing a
  blueprint with one of different dimensions moves every marker on that level, so
  recalibrate from landmarks rather than editing width and height.
- PLUGIN-HOOKS now states that a map overlay keys on assetid and must not return
  coordinates or a level - a second copy of a position is one that can disagree.

Adds FLOOR-MAP.md, the operator's page: loading a plan, placing markers, and
what to do when the plan changes, with the reasoning left in ADR-017. START-HERE
routes to it from the new-site path, and specifically as the page to read BEFORE
a floor plan changes.
2026-08-17 13:35:57 -04:00
cproudlock
24647aa8e8 Release 0.11.0
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
The floor map became a set of drawings instead of one picture, and search now
reaches what the forms have been collecting all along.

A site can hold more than one building and a building more than one level, and
every map position records which drawing its coordinates belong to. An existing
single-map site upgrades into one building and one default level carrying the
blueprint it already had, and nothing moves on screen. Landmark recalibration
moves a level's markers onto a redrawn plan, solved per axis from the landmarks
and never from the image dimensions, dry run by default, snapshotted before it
applies.

Two identifiers the UI collects were findable in almost no way and two plugins'
records in no way at all: gaugelabreference matched only measuring tools,
maintenancereference matched nothing anywhere, and USB devices and printed items
were absent from search entirely because neither is an asset. All four are
searchable now, from the global bar and from every list's own search box.

Contract 0.20.0 is additive: a plugin that writes a map position must write its
level with it.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 13:23:49 -04:00
cproudlock
aede5ac621 Match the optional identifiers in every list's search box
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
gaugelabreference and maintenancereference are collected on the form and shown
on the detail page for all five asset types, and no list's ?search= matched
either. Someone holding a physical tag could read it off the machine, type it
into the list they were already looking at, and get nothing back - while global
search, fixed in the previous commit, found it.

The clause is copy-pasted once per plugin, which is how all five came to omit
fields their own forms collect, so the test is one parametrized pass over all
five lists rather than five per-plugin tests that would drift the same way. It
also pins that widening the clause did not turn the box into a pass-through.

The api-inventory entries enumerate the fields each search matches, so all five
were stale the moment the queries changed; updated with the OpenAPI spec.

Not touched: the collector does not send either identifier, correctly - a
lab-assigned tag is not something an agent on a PC can discover. The legacy
import loader does not map them either, and the classic schema has no
gauge-named column to map from.
2026-08-17 13:16:22 -04:00
cproudlock
3324dbd91e Buildings and levels for the floor map, and make every identifier searchable
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
The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
2026-08-17 12:55:51 -04:00
cproudlock
7d9a54ca0f Release 0.10.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
A privilege-escalation fix in the Windows installer, the multi-site portability
work, and a documentation and API surface that a second site can rely on.

The installer created C:\shopdb-flask, C:\Python314 and C:\MySQL84 without
breaking inheritance from the drive root, whose default DACL grants
Authenticated Users an inherit-only Modify. Two files inside those roots are
executed by someone else - shopdb-admin.ps1 self-elevates, and instance
config.py is loaded unconditionally by the app - so any logged-on user could
replace either and wait. Verified fixed as SYSTEM in the Windows 11 VM against
the shipped functions.

Contract 0.19.0 is breaking: get_dashboard_widgets returns data and shape, not a
component name. The change itself shipped earlier without a bump, with the
contract docs still teaching the dead shape; both are corrected.

The version and the changelog are the release; the detail is in the entry.
2026-08-17 08:01:52 -04:00
cproudlock
a895bde6fe README: count the plugins the tree has, and point somewhere useful
It claimed thirteen bundled plugins and listed thirteen; there are fifteen, and
the two missing ones - backups and tools - were undiscoverable from the first
page anyone reads. The count is gone rather than corrected, because a number
typed here goes stale exactly the way it just did; the generated map carries it.

The version and count guard now covers README.md and CONTRIBUTING.md, not just
docs/. A rule that leaves the most-read file in the repository outside it is
most of a rule.

Also names the reference site as the reference site rather than by plant, adds a
pointer to START-HERE, and corrects the distribution paragraph: it told a reader
of the public mirror that the code is only available on an internal server,
which is both wrong and unhelpful now that the installer ships as a release
asset.
2026-08-14 16:34:46 -04:00
cproudlock
e7b8933588 Stop the publication scrub failing on the rules that enforce it
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
The export gate now carries the site patterns, and three kinds of file tripped
it - two legitimately, one by construction.

Two test files held real internal subnets as fixtures. They are documentation
ranges now, which test the same logic and disclose nothing.

The project-map generator and a changelog entry named a file that is excluded
from publication, so a public reader was pointed at something they cannot see.
Both now describe what happened without naming it.

And the naming script has to CONTAIN the site patterns in order to grep for
them, so written literally the rule's own definition fails the gate that
enforces it. The patterns are assembled from fragments, the same trick the docs
publishability test already uses for the same reason. Verified the hard way: a
planted literal is still caught, so the fragmentation did not quietly turn the
rule into one that matches nothing - which is the obvious way for this fix to
have gone wrong.
2026-08-14 16:27:58 -04:00
cproudlock
1d8c983d04 Record the documentation and API-surface sweep in the changelog 2026-08-14 16:21:16 -04:00
cproudlock
96df19702e docs: one manual runbook, and ADR statuses that mean something
DEPLOY-WINDOWS-IIS was a second copy of the manual IIS procedure that had
diverged from the first: a different MySQL version (8.0, which reached end of
life in April), a different port, a different plugin list, and a profile file
that does not exist. Two runbooks for one procedure means a reader follows
whichever they found, and one of them was wrong. INSTALL-WINDOWS-IIS covers
everything it did plus a preflight step and the subpath method, so the one
section it uniquely had - redeploying a hand-built server - is folded in there,
with the plugin-chain step it was missing and a note to back up first, and the
duplicate is gone. Everything that pointed at it now points at the survivor.

Three ADR statuses said something untrue.

ADR-013 said PROPOSED while half of it had shipped and ADR-014 had been accepted
on top of it. A decision that has been implemented and depended upon is not
proposed, and leaving one that way devalues every other status in the index. The
catalog half is still unbuilt, which is the ordinary state of an accepted
decision: accepted means settled, not delivered.

ADR-016 said ACCEPTED for a design where nothing is built - the endpoint and
permissions it describes do not exist, so a reader goes looking for them. The
status stands, because the decision does; the header now says so plainly and
points at where today's credentials actually live.

ADR-003 and ADR-004 were ACCEPTED with their own Decision lines still opening
"**PROPOSED:**", which reads as though the decision was never taken.

And the dashboard proposal carried Status: ACCEPTED, which belongs to a decision
record. A proposal is a proposal; the contract it produced is the ADR.
2026-08-14 16:20:59 -04:00
cproudlock
928a50c16e docs: what a site needs that no page answered
Four gaps a second site hits and cannot resolve by reading.

**Restoring on Windows** was one sentence - "the standard mysql < dump.sql" -
with no ordering. Restoring a database under running code that expects a
different schema turns a restore into a second incident, so the steps are now
ordered and each says why. It also says what `.env` costs if it is lost, which
is the part nobody discovers until they are already rebuilding: the dump does
not contain it, and without the JWT secrets every issued token dies, so every
collector and every GE-Enforce client on the fleet needs a new key.

**Rolling back** had a paragraph saying downgrades are refused and a backup is
the way back, but not the procedure. Rollback is restoring a matched pair, code
and the schema it expects, in that order - and the doc now separates it from the
case it gets confused with: a migration that failed mid-update has already been
rolled back by the installer, and fixing forward is the only move.

**Sizing, acquisition and support** were absent from the install guide entirely.
A reader could not learn how big a server to ask for, where the .exe comes from,
or where to raise a problem. The sizing is small and the reasons are stated, so
a site does not over-provision a VM for a load that is a few dozen users.

**Credentials** were described in three documents from three ends, so three
answers existed for where a key lives. One table, both ends - server and PC -
plus the two rules behind it: what a shop-floor PC holds is scoped to exactly
what it does, and a credential is delivered rather than typed, because a value
entered per machine is a value that is wrong on some machine.
2026-08-14 16:16:01 -04:00
cproudlock
62c5454f00 docs: a front door, and one page on how the pieces relate
The wiki had no entry point worth the name. Its Home page was hardcoded inside
the generator, had drifted into recommending INSTALL-WINDOWS-IIS - the manual
procedure these same docs tell you not to follow for a new site, because it
produces a server the installer then refuses to upgrade - and the page carrying
that warning, llms.txt, was never published at all, because the generator copied
only .md files. The adoption guide had zero inbound links.

START-HERE routes by what a reader came to do: stand up a site, deploy the
shop-floor tools, write a plugin, integrate with the API, understand a decision,
or fix something. It is the shortest correct path per role, not an index - the
sidebar is already the index.

FLEET-ARCHITECTURE is the page nothing else could assume. The server, GE-Enforce,
the asset reporter, the backup collectors and EventSaver were each documented
alone, each assuming a reader who already knew the other four. It draws the
shape, states the rule that explains most of the behaviour - nothing on the
server reaches out to a PC, the fleet asks - and ends with the table that says
which of the five programs to open for a given symptom, since knowing that is
most of the diagnosis.

wikigen renders START-HERE as the landing page rather than a list maintained in
a second place, and publishes llms.txt as LLM-GUIDE.
2026-08-14 16:12:04 -04:00
cproudlock
ad335cfc9e api: document the twenty-two routes that were serving traffic in silence
The inventory is hand-maintained, and should stay that way - its value is the
prose, and what an endpoint is FOR cannot be derived from the code. An audit of
all 372 documented operations found zero phantom routes and zero wrong parameter
names, so the maintenance is in good order.

What hand-maintenance cannot do is notice a route somebody added. Twenty-two
were undocumented: the entire backups plugin surface, every one of the dashboard
card endpoints added with contract 0.19.0, the GE-Enforce publish preflight, the
employee SSO resolver, the protocol update verbs, and the four /api/docs routes -
so the spec did not describe how to fetch the spec.

Coverage is now a test. It walks the live url_map and fails when a served route
has no entry, which means adding an endpoint includes describing it, in the same
commit, while the author still knows what it is for. The reverse direction is
checked too: a documented route that no longer exists sends a reader to a 404.

Writing that test found one more thing. The inventory writes multi-verb routes
as "PUT|PATCH", and neither the parity check nor the generator split on the pipe
- so those operations were absent from the published spec entirely, with nothing
reporting it. The spec now carries all 394 operations the code serves, which is
the first time the two numbers have matched.

The generator's own docstring claimed the inventory could be regenerated. It
cannot; nothing generates it. That sentence is why nobody noticed it was falling
behind.
2026-08-14 16:09:47 -04:00
cproudlock
36b0265668 openapi: emit a spec a machine can actually use
The generated spec carried no `parameters` and no `requestBody` on any of its
372 operations. That is invalid OpenAPI 3.1, and the practical cost was worse
than the formal one: the MCP server builds its tools from this file, so every
tool had an empty input schema and silently dropped whatever the caller passed.
A request for one asset returned the list, and nothing anywhere reported an
error. All 118 templated paths now declare their path parameters, typed from the
Flask converter that named them, and write verbs declare a JSON body.

The body is an open object carrying the prose description rather than an
invented schema. The inventory describes bodies in sentences, and a field list
this generator guessed at would be worse than none - but "an object, described
here" is the difference between a client that can send a body and one that
cannot send anything.

Security was wrong on 123 operations. `jwt-optional` means "works logged out,
returns more logged in", which OpenAPI expresses as the empty requirement
alongside the scheme; publishing them as bearer-required told every reader that
a public endpoint needs a token.

Responses were one hardcoded 200, so a generated client had no idea a call could
fail. Every operation now documents the error envelope - and the envelope itself
is a defined schema, because its error nests under `data.error` rather than at
the top level, which is the single thing people get wrong when writing against
this API.

95 summaries were cut at 120 characters mid-word, which is what a tool picker
shows a user as the whole description of a call. They now end on a word.

Tests pin the shape rather than the prose. One of them contradicted an older
test that REQUIRED the contract version as a literal in PLUGIN-HOOKS.md - the
same copying that left nine documents stale - so that test now asserts the doc
points at the generated map instead.
2026-08-14 15:56:27 -04:00
cproudlock
05be4c4489 docs: fix the examples that fail if you paste them
Six procedures that could not be followed as written.

Eighty-nine curl examples single-quoted `Authorization: Bearer $TOKEN`, so the
shell never expanded it and the server answered 422 "Not enough segments". Nine
more did the same with X-API-Key. The other 129 examples in the same file
already used double quotes, so this was drift rather than a convention, and the
spec regenerated from it carried the fault onward.

The GE-Enforce report example put a `//` comment inside a JSON body. The server
parses with silent=True, so it saw `{}` and answered "hostname is required"
about a body that plainly has one - the worst kind of error message, one that
sends the reader to the wrong field entirely.

The IIS install ran `flask db upgrade` and a per-plugin install loop but never
`flask plugin upgrade-all`, leaving every plugin's own chain unapplied. That is
precisely the 1054 "Unknown column" a deploy then hits somewhere else, days
later, on the page that uses the new column.

The pilot runbook looped `flask plugin enable` over plugins that were not yet
installed; enable refuses those, so on a fresh database it exited 1 on every
iteration and enabled nothing. ADR-013 had already recorded that defect.
`apply-profile` installs and enables in dependency order, which is what the step
was reaching for.

DEPLOY-WINDOWS-IIS named a profile file that does not exist; the shipped ones do.

And PLUGIN-EXTERNAL-REPO never mentioned PLUGIN_TABLE_OWNERS, while the
migration engine raises for any plugin missing from it - so the guide's own
step 5 fails for any external plugin that owns a table. That the registry lives
in the framework repo is deliberate, so the guide now says so, and says what it
costs: a table-owning external plugin is a two-repository change, and a plugin
that owns no tables avoids it entirely.
2026-08-14 15:43:27 -04:00
cproudlock
d1ba3a1a02 docs: stop typing versions the code already knows
Nine documents carried a hand-typed contract version and every one was stale.
One was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to pin
">=0.13.0,<0.14.0" while the contract is at 0.19.0, so a plugin built by
following that guide is refused by the loader at startup. The plugin count was
wrong in six more.

They now point at docs/PROJECT-MAP.md, which is generated. A test enforces it:
no document may declare a version literal, a stated current version must match
the code, and a stated plugin count must match the tree. ADRs are exempt from
the current-version rule, because an ADR states the version a decision was taken
AT - that is a record of the past, and rewriting it would falsify the record
ADRs exist to keep.

CONTRACT-STABILITY.md was missing 0.17.0, 0.18.0 and 0.19.0 - including the only
BREAKING change in the series - in the one document a site reads to choose its
pin. All three are recorded, with 0.19.0 called out: it took something away, and
it shipped before it was written down, which is the argument for pinning tight
rather than trusting that a minor bump is safe.
2026-08-14 15:41:27 -04:00
cproudlock
4995456136 docs: take one site's name, hosts and paths off the public wiki
The publishability gate caught internal tooling names and developer paths but
nothing site-specific, so roughly sixty leaks reached the wiki: the site name in
ten documents, real fleet hostnames in the collector and GE-Enforce examples, an
internal database name through the whole import guide, imaging-share paths, and
a maintainer's username as the Deciders line of every ADR and inside a generated
curl example.

None of it is a security matter on an air-gapped fleet. It matters because these
pages are read by engineers at other plants, and a document that names one site
throughout reads as that site's notes rather than a product's documentation -
which is exactly what it then gets treated as.

Examples now use neutral hostnames, the site is "the reference site" where the
distinction carries meaning, and ADRs are decided by "ShopDB maintainers". The
gate carries all of these patterns, so the next one fails a build.

Two documents leave docs/ because they were never written for an outside reader.
PROJECT-REVIEW.md is an internal health memo pinned to a commit from July, whose
headline finding (an untracked playbook) has since been fixed - it is history,
and git holds it. PILOT-DEPLOY.md is one site's own cutover runbook, complete
with a "re-measure before publishing" placeholder; it moves next to the loader
it belongs to, in scripts/site_imports/wjf/.

ADR-015 is AMENDED rather than rewritten. Its enforcement section still said
report-only and its backlog still listed hardcodes that are now cleared, which
left the record contradicting itself. The amendment says what changed and why
the report-only period ended; the original text stays, because what the decision
looked like when it was taken is the part worth keeping.

Also corrects llms.txt's response envelope, which had errors at the top level
and pagination at meta.total. Both are nested one deeper, so anything written
against that description read undefined on every error it tried to handle.
2026-08-14 15:38:27 -04:00
cproudlock
c829d96128 docs: take two leaks off the public wiki, and say where the files come from
An ACCEPTED ADR ended with a stray tool-call fragment - it said nothing to a
reader and everything about how the document was produced, which is not what a
decision record is for. And the cutover playbook carried a real internal IP in a
file otherwise correctly scrubbed to documentation ranges. Both were published.

The publishability gate now catches all three shapes, so the next one fails a
build instead of reaching the wiki.

The adoption guide also told a site to copy two files that ship from nowhere:
the asset reporter and EventSaver are both on the reference site's imaging
share, not in this repository, and the guide never said so - it could not be
followed as written. It now states, per artifact, where each comes from, which
of them are pending a move into this repository, and that EventSaver can be
rebuilt from its one source file rather than taken on trust as a binary.
2026-08-14 15:23:04 -04:00
cproudlock
ce6d44e69f docs: EventSaver, and how GE-Enforce reaches a bay in the first place
EventSaver was undocumented anywhere a reader could find it. What it is, the two
source modes and why HTTP is the one to use, how order.txt and the ShopDB feed
each decide the running order, the three command-line switches Windows calls it
with, where it must NOT run (a screensaver over a live dashboard or a metrology
run is a defect), how to build it with the in-box compiler, and a table of
symptoms with the first thing to check.

The adoption guide also stopped short of the question it invites: the fleet
tools assume GE-Enforce is already on the PC, so how does GE-Enforce get there.
Both real paths are now written down - imaging time, which is how the shop-floor
pipeline does it, and Intune, which is how the display cohort is deployed today
because those PCs are Entra-joined with no share. Machine Configuration is
possible and unshipped, with the caveat that matters: DSC and GE-Enforce are
both convergence loops, so give each a layer to own rather than pointing them at
the same configuration.
2026-08-14 14:19:16 -04:00
cproudlock
c648bdf560 labels: one module knows how to draw a code, seven views stop guessing
Three core pages and four plugin pages each imported qrcode and jsbarcode
directly, and each carried its own answer to the same questions: what margin,
what width, which error correction, how big a module must be before a scanner
can read it. The answers had already drifted - margin 0 in one place and 2 in
another, width 150 against 160 - and on a label that is the difference between
a sticker that scans and one that does not.

frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone
and margin defaults, CODE128 with no printed value, and the printer-resolution
arithmetic that only the Tech Tools generator had. A view passes what is
specific to its own label and nothing else - MachineBadge still asks for CODE39,
because the badge readers predate the shop-floor scanners and decode nothing
else, and that is exactly the kind of thing a call site should say out loud.

views/print/qrLogo.js is folded in rather than left as a second half-shared
helper that only some of the pages reached into.

The check script now fails a build that imports either library outside that
module. Without it this re-forks within a month: the next label page starts by
copying the nearest existing one, which is how it happened the first time.

Tests cover the part no amount of looking at a screen verifies - a QR that
looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch
stock.
2026-08-14 14:03:49 -04:00
cproudlock
b37c08eb5b docs: how another site points these tools at its own ShopDB
The asset reporter and EventSaver are both already built to be repointed - the
server URL, the API key and the targeting are parameters, an ini file and
manifest targeting, not code. Nothing said so, so the question "can another shop
use this" had no answer that did not involve reading PowerShell.

Worked examples for all three deployment paths, because sites have different
management planes and the choice is not ours to make: Intune (a remediation for
the reporter, a Win32 app for the screensaver, plus a Machine Configuration/DSC
form for estates already governed that way), a GE-Enforce manifest entry, and
manual installation for a pilot or a single bay.

The two traps are written down rather than left to be discovered. EventSaver
falls back to a path compiled into the binary when its ini is missing, and that
path belongs to the reference site - a missing ini is not a neutral default. And
a config enforced by hash reverts a hand edit on the next cycle, which is the
feature working correctly and reads exactly like a bug.

Also notes the reporter's -ApiUrl default still points at the reference site, so
every example passes it explicitly until that is fixed.
2026-08-14 13:53:17 -04:00
cproudlock
8f9805d964 Generate the facts that go stale, and record the fortnight in the changelog
CLAUDE.md is read at the start of every session and claimed contract 0.16.0 and
1159 tests while the code was at 0.18.0 and 1567, along with a plugin count and
a chain head that were both wrong. Hand-written facts about a moving codebase
are wrong within a fortnight, and a reader who cannot tell which lines are stale
has to re-derive all of them.

So they are derived. scripts/gen_project_map.py reads the versions, the plugin
inventory, every Alembic chain head, the ADR index with status and the size of
the codebase out of the code, and --check fails a build where the committed map
no longer matches. CLAUDE.md now points at it, leads with the multi-site rule,
and lists the gates to run instead of restating the conventions they enforce.

The changelog's Unreleased section covered 8 of the 46 commits since 0.9.0 and
had no Changed, Fixed or Security sections at all. It now carries the whole
fortnight, including both contract bumps - which had never been recorded even
though ADR-002 makes contract versions their own series.
2026-08-14 13:47:48 -04:00
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
4d6ab741cc backups: retention reads the key the settings page actually writes
get_setting on BasePlugin namespaces what it reads to plugin.backups.<key>,
while get_settings_defaults declares - and the settings page writes - the bare
key. So the retention read never found the operator's value and always fell back
to 0, and 0 means keep everything. Retention was configurable in the UI and did
nothing. It is the only place in the codebase using the namespaced helper.

The share root also stops shipping one site's file server as its default. That
put a site's internal topology in a bundled plugin and in the public mirror, and
pointed a second site at a server it cannot reach. Blank now, per ADR-015, and a
share kind with no configured root returns nothing rather than composing a path
from somebody else's hostname - a path built on an empty root is not a lesser
answer, it is a wrong one.
2026-08-14 13:47:19 -04:00
cproudlock
838932a72d geenforce: gate publishing on the library version, not on the manifest's own
The publish gate exists because a minor version bump that NARROWS behaviour is
not backward compatible: _CmmVersion arrived in lib 2.6, and an older lib does
not know the field, so every gated entry looks unfiltered and it installs every
PC-DMIS version it cannot detect, on every CMM, in one cycle.

It was comparing the fleet's reported library versions against manifestversion.
That is the manifest's own 'Version' field. For a share-imported manifest the
two numbering schemes happen to coincide; for a scope authored in code they do
not, and seed_display_scope writes '2.0' - which every kiosk exceeds. So the
gate passed on the scope that most needed it.

A scope now declares minlibversion. Unset, the requirement is DERIVED from what
the manifest actually uses, so a scope written before this column existed is
still judged on its contents rather than on a number that says nothing about the
library. Only features that narrow behaviour belong in that table; an addition
an old lib ignores harmlessly needs no floor. manifestversion remains the last
fallback, which preserves what share-imported manifests already relied on.
2026-08-14 13:47:11 -04:00
cproudlock
2df5028883 relationships: the cleanup tools stop acting on links that were deleted
Deleting a relationship is soft, so the row survives with isactive False, and
three things read them without knowing that.

Re-adding a deleted link answered 409 "this relationship already exists" about a
link the page no longer shows, and there was no way forward from the UI at all -
the row cannot simply be inserted again, since the triple is unique.
Reactivating IS the create for an inactive row.

The inverse guard blocked on a deleted inverse, which made "remove the existing
one first" - the instruction in its own message - fail to unblock anything.

fix-controls-direction retired the reversed row whenever a correctly-directed
one existed, without checking whether that one was itself deleted. So it removed
the only live link and reported a successful clean-up. It now reactivates the
row pointing the right way before retiring the one pointing the wrong way.

These are the commands the docs tell an operator to run against production.
2026-08-14 13:47:01 -04:00
cproudlock
c7dffce81e Serve an uploaded file as data, not as a document that can run
An SVG is an XML document that may carry a script, and it is an accepted image
type because floor-plan maps and branding genuinely want vector. Loaded through
an img tag that script never runs, so the tiles and maps were never the risk.
Opening the file's own URL is - and the application image route is public, so
that URL needs no session.

Every route that serves an upload now goes through one helper that sends
Content-Security-Policy: default-src 'none'; sandbox, and nosniff. Seven routes
across core and five plugins, so a new one added later starts from the same
place rather than repeating the reasoning. Banning the format instead would
have cost the maps their only sensible one.

The app also sent no security headers at all. It now sets nosniff,
frame-ancestors self (as X-Frame-Options too, for the display bays' browsers)
and a referrer policy. Deliberately NOT a page-wide CSP: this serves an SPA with
inline styles, so a real script-src policy is a change worth making with the
frontend in front of you, and a permissive header claiming one would be worse
than having none.

Contract 0.19.0. send_upload is on the shopdb.api surface, because a plugin
serving user-supplied bytes should not have to remember these headers. The same
bump records that get_dashboard_widgets has taken data and shape rather than a
component name since the dashboard was rebuilt - that shipped without a bump,
while BasePlugin and PLUGIN-HOOKS.md both still documented the shape nothing
renders, which is how five plugins came to declare widgets pointing at
components nobody had written.
2026-08-14 13:46:53 -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
38deefe619 migrations: commit the plugin chains too, and bound what re-ran
Core's Alembic env got connection.commit() when the stamp bug was found; the
per-plugin template did not. MySQL commits DDL implicitly, which flushes
everything queued before it including the previous migration's version stamp,
and the LAST migration of a run has no DDL after it - so its stamp rolled back
at close while its schema change survived. flask plugin upgrade-all then exited
0 having silently re-run that migration, and re-ran it again on every deploy
after. Invisible for exactly as long as every plugin head happened to be
idempotent.

Two were not.

backups 0003 cleared lastseenat for EVERY row, which is correct once and
destroys evidence on each repeat. It is now scoped to the backfill's actual
signature, COALESCE(collectedat, createdat) - the expression 0002 wrote - plus a
date bound. Both conditions are needed. Matching on collectedat alone misses
every row whose collectedat is NULL, so precisely the rows carrying the most
invented value would have kept it forever; and value equality is not a signature
on MySQL, where db.DateTime is second-precision and the collector writes both
stamps in one statement, so a genuinely fresh revision would read as a backfill
and be wiped. SQLite keeps microseconds, which is why no test could show it.

geenforce 0003 added a column unconditionally, so it failed on a fresh database
built from the models and on any re-run. Guarded like network0003prefix.
2026-08-14 13:46:23 -04:00
cproudlock
0c574e0f49 installer: break ACL inheritance on the roots it creates
A directory created directly under C:\ inherits the drive root's DACL, and the
default carries an inherit-only Modify grant for Authenticated Users. So
C:\shopdb-flask, C:\Python314 and C:\MySQL84 were writable by every logged-on
user on the server.

That is a local privilege escalation here specifically, because two things this
installer puts inside those roots are executed by someone else: shopdb-admin.ps1
self-elevates with -Verb RunAs, and instance\config.py is loaded unconditionally
by the app through from_pyfile. Replace either, wait for an administrator or the
app pool to run it, and the code runs as them.

Hardening is applied at creation and RE-APPLIED on every run, so an upgrade over
an installation that predates this repairs the ACL rather than leaving the hole
in place. The MySQL data directory is locked down too: it holds the users table
and its password hashes, and it inherits ProgramData's read-for-Users.

The app pool is granted RX on the Python root alongside the app root. A venv's
python.exe is a copy but the DLL and the standard library are still read from
the base install, so without that grant the site 500s on every request once the
roots stop inheriting.

A Python installation this installer did not create is reported, not seized:
something else may depend on the ACL it has.

Verified as SYSTEM in the Windows 11 VM against the functions as shipped
(extracted by AST, not retyped): the inherited ACL really does grant non-admins
write; after hardening only Administrators and SYSTEM remain; a service identity
granted afterwards keeps access and later-created files inherit it; re-running
is a no-op; a missing path is silently skipped. 14 checks, all passing.
2026-08-14 13:46:03 -04:00
cproudlock
ab301df9ac network: generate a device's asset number instead of asking twice
Every network device on this fleet already follows one convention, applied by
hand: AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. 45 records, no exceptions.
The create form demanded the asset number anyway, so the same value was typed
twice and the convention held only as long as everyone remembered it.

The prefix now lives on the device type, and a blank asset number is generated
as <PREFIX>-<name>. Left explicit, an asset number always wins: a device
carrying a real identifier of its own - a vendor tag, a controller name, a
serial - keeps it. That is the platform rule, adopt where an identifier exists
and derive only where none does.

The prefix is NOT derived from the type name. "Access Point" and "Access Panel"
both initialise to AP, and assetnumber is unique, so the second type would
collide with the first on every device it created. It is nullable, so a type
that wants no prefix generates the bare name rather than needing one invented.

Names are sanitised before they reach a business key - the existing data
already shows why, with IDF-Telco-Demarc-#1 carrying a '#' into an identifier.
An existing prefix is never stacked: IDF-03 under type IDF stays IDF-03.
2026-08-14 13:45:51 -04:00
cproudlock
1078ac03df geenforce: shadow runs inside the enforce cycle, not on its own clock
Test bays reported 0 installed / 0 skipped / 0 failed and nothing was being
processed. The share is a TRANSIENT mount: GE-Enforce.ps1 maps it for the length
of its cycle and unmounts at the end - mounted 12:10:02, unmounted 12:10:27 on
FB9TP7V3. The shadow task ran on its own 15-minute schedule, so it woke with the
drive gone, handed the engine a path that no longer resolved, and the summary
zero-filled. A silent nothing, indistinguishable from a healthy no-op, which is
why it read as "not processing" rather than as a failure.

Register-ShopdbShadow.ps1 is replaced by Invoke-ShopdbShadow.ps1, which runs AS
a manifest entry and invokes the runner directly. The share is mounted because
the enforce cycle is what called us; there is one cadence instead of two; and
there is no task to register, drift, or heal. It also unregisters the superseded
task, so bays that already carry it clean themselves up rather than keeping a
run that cannot see the share and overwrites the real result with 0/0/0.

The runner gains the Test-Path guard that should have been there: an unreachable
-ShareManifestPath now logs, writes an event, and reports a real failure naming
the reason, instead of running the engine against nothing. That silent zero is
what made this expensive to find.

Verified on the win11 VM with a substituted drive removed between runs - the
same call gives real counts while mapped and the guard when not. The earlier
task self-heal is moot now; it was a fix at the wrong layer, and testing against
a local path is what hid a mount I had already noted was dynamic.
2026-08-13 14:52:56 -04:00
cproudlock
dfbc12a172 printers: part numbers for the HP 3201 / 3301 series
A 3201dw is monitored and alerts correctly - Zabbix reports its cartridge names
and levels straight off the printer - but the Toner Report had no reorder chip
for it, because part numbers come only from modelsupplies and no family matched.
lookupsupplies returns [] for a model with no rows, so the printer showed levels
with nothing to order.

Adds the 218 family: 218A standard and 218X high yield in all four colours,
matched on 3201 and 3301 so the MFP siblings are covered by the same entry.
Part numbers and yields cross-checked against three independent sources rather
than inferred from the series - a wrong toner number means someone orders a
cartridge that does not fit, which is worse than the blank chip it replaces.

No drum or waste unit: none is listed as a separately orderable part for this
series, so the family is toners only.
2026-08-13 13:45:45 -04:00
cproudlock
4bded210e4 geenforce: the backup hover names both dates, because they mean different things
"Checked 13 Aug 1:20 PM. Verified the backup taken 12 Aug 5:20 PM is still
current." Two facts, and one date could not carry both.

lastseenat moves on every successful confirmation and proves the check is still
running. collectedat moves only when the configuration actually CHANGED and says
what is stored. Showing lastseenat alone hid what is in the backup; showing
collectedat alone read as "last backup was a month ago" on a machine that is
perfectly healthy, because a stable config writes no new revision. The hover now
states the check and the capture separately and says outright that the second
being older is the normal case.

The stale wording gets the same treatment: it names the check that stopped AND
the newest copy that exists, which is the thing someone recovering from it
actually needs.

collectedat is exposed as backupcollectedat. Both stay tooltip-only - the badge
is still just the kind and a colour, so nothing here changes what a new backup
kind has to do to inherit it.
2026-08-13 13:35:05 -04:00
cproudlock
1d7191c2d3 geenforce: the fleet table links where it says, and judges backups instead of dating them
Two fixes to the same table, in the same regions of the same files.

ASSET LINK POINTED AT THE WRONG RECORD. The Asset chip linked
/machines/<assetid>, but /machines/:id keys on machineid - the plugin extension
id - as MachineDetail itself does everywhere. So the link landed on whichever
machine happened to carry that number: a wrong page that looks right, which is
worse than a 404. Same for /measuringtools/. The API now returns
machinepluginid / toolpluginid beside the asset ids and the view links on those.
Both lookups are import-guarded, and with no plugin id the number renders as
plain text rather than a link that misleads. AssetRelationships already resolved
this correctly; this brings the reports table in line.

BACKUP COLUMN READ AS NEGLECT. It showed a raw date, and a revision is only
written when the config CHANGES - dedup means a machine stable for months has a
months-old newest revision and is perfectly healthy. The column already used
lastseenat, the last time the collector CONFIRMED the config, but a bare
timestamp says "nothing has happened since", which at the default 24h collection
interval IS the healthy steady state. It made a working system look stalled and
made the reader do arithmetic against a setting they would have to go and find.

It now returns backupok and shows a badge naming the kind, green when confirmed
recently, red when not, with the date and an explanation in the hover. backupok
is tri-state on purpose: null means no revision at all, and renders as NO badge
rather than a green one, because "never seen" must not read as healthy. The
threshold is the backups plugin's own backups_staledays, read through its
service so there is one definition of stale rather than a second drifting here.

Nothing in the badge is kind-specific, so a backup kind added later inherits it
by existing. docs/BACKUP-KINDS.md records that, the BackupKind contract, and why
the rule is time-based rather than per-kind.
2026-08-13 13:20:15 -04:00
cproudlock
6dc363411d geenforce: the shadow task actually runs, and says so on disk
Registered fine and never fired once. Three faults, all found on the win11 VM.

A `-Once -At (Get-Date)` trigger does NOT run immediately: its first run is the
start boundary PLUS the repetition interval, 15 minutes out. This ships as a
DetectionMethod=Always entry, so it ran every enforce cycle, 5 minutes apart,
and each Register-ScheduledTask -Force reset the start boundary to "now" -
pushing the first run back to +15 before the previous +15 could elapse. 5 < 15,
so the task sat Ready at LastTaskResult 267011 (SCHED_S_TASK_HAS_NOT_RUN)
forever. It now registers only when the task is missing or its arguments
changed, and starts it once on first registration rather than waiting out the
first interval.

A bay provisioned by the broken version carries a task with correct arguments
that has never run, so "leave it alone if it matches" would have stranded
exactly the machines that hit the bug. If the task has never run it is kicked
once; after that LastRunTime is set and the check is a no-op.

None of this was visible. The engine records only "ps1: <path>" and an exit
code for a PS1 entry, so Write-Host reached nothing, and with the fail-safe
`exit 0` on every path a silent early-out was indistinguishable from success.
It now also writes C:\Logs\Shopfloor\shadow-setup-<date>.log.

Scope is no longer hardcoded either: this script is shipped by more than one
scope now, and a wrong value would shadow the wrong manifest silently. It is
derived from the script's own directory, the same way the share manifest path
already was, so the two cannot disagree.
2026-08-13 13:19:59 -04:00
cproudlock
3d83806135 Make the toner forecast an order, not a table
The report answers a purchasing question, and it was answering it in seven
columns, two tables and a rowspan. What someone actually needs from it is a
short list of what to buy.

So it opens with that list, grouped by part number with a quantity. Two
cartridges of the same part in different printers is a quantity of two, which
is the number an order needs and the one a per-printer table made the reader
count by hand. It covers what is empty plus what goes within a fortnight -
ordering only what is already empty means running empty. There is a copy
button, because it ends up pasted into a mail.

Below it the cartridges sit in urgency bands rather than in one long list
sorted by a number. The question is which pile a thing is in, and a pile that
is empty is worth seeing as empty. Everything past "empty" starts collapsed;
the order list above already covers the same ground in a tenth of the height.

The row is a cartridge now, not a printer, so it can carry its own part number,
its own level bar and its own countdown. Nesting supplies under a printer meant
opening a printer to find out whether anything on it needed doing.

Cartridges with no part mapped are counted on a single line rather than given
one each. They cannot be dropped, since that would quietly shorten the order,
and they cannot be ordered from here either - the job they represent is
mapping them, which is one job however many there are.

Bands and the order horizon are decided server-side, next to the arithmetic
that produces them, so a heading cannot disagree with what got added to the
list.

Checked against a fleet of 43 dev printers with real part mappings, driven by
a stub Zabbix - live Zabbix is not reachable from the dev box.
2026-08-13 13:08:39 -04:00
cproudlock
e67fe47fe2 relationships: refuse links that cannot both be true, and report the ones already stored
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
Prod grew rows saying both "PC controls 2005" and "2005 controls PC", and a CMM
PC showing "<- controls from CMM4" beside its own outgoing link. Only one
direction can be true: a PC drives a machine, never the reverse.

Nothing stopped it. The duplicate check was keyed on (source, target, type), so
the inverse inserted cleanly, and the Add Relationship dialog offers an incoming
direction that writes exactly that. The legacy import stores controls the wrong
way round as well. Directional creates now refuse the reverse with a 409 naming
the row that already holds it, and refuse self-links, which render as a
duplicate on the asset's own page and mean nothing. Symmetric types are exempt:
Dualpath stores both directions on purpose and the card collapses them. The
propagation fan-out got the same guard so a rail meant to spread one direction
across sibling bays cannot manufacture a pair.

fix-controls-direction only matched source assettype 'machine', so every
measuring_tool, printer and network_device row it was written to clean survived
it - which is why running it would never have fixed the CMM. It now matches any
non-computer controlled BY a computer.

New `flask relationships audit` reports what is already stored: reciprocal
pairs, self-links, and PCs controlling several assets of one type. Read-only,
and it prints each row's label because that usually names the writer outright -
collector:* means this code made it, anything else means a person or the import
did. That distinction decides the fix for duplicate device assets, which is not
in this commit: the collector keys idempotency on its own label, so a device
somebody created by hand is invisible to it and it mints another, and the
adoption rule needs the audit run against prod before it can be written.

Two false positives were found writing it, against the dev database, and both
would have made the report useless. A self-link is its own inverse, so it was
counted as a reciprocal pair AND printed twice. And Dualpath siblings looked
like duplicate devices - a dual-bay machine is one physical machine with one
controller and controls is propagated to both bays deliberately. That was 30 of
32 findings, consecutive bay numbers pair by pair.
2026-08-13 12:25:24 -04:00
cproudlock
6cdbea449a geenforce: ship shadow mode through the share, and correct the cutover doc
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 cheap cutover the doc described - manifest over API, payloads still on the
share - does not work. Measured on the win11 VM against lib 2.6 with the same
manifest and engine, varying only InstallerRoot: the payload cache gives
0 installed / 2 failed ("CMD/BAT not found" under the cache), the scope
directory gives 2 installed / 0 failed.

Two causes. Invoke-ShopdbEnforce.ps1 takes no -InstallerRoot and hardcodes the
payload cache outside shadow mode, which is right for displays where every
entry is inline and wrong for every smb entry. And -IncludeCommon cannot work
for smb payloads at all: they live under their own scope directory, so a merged
manifest needs two roots where the engine takes one, and Join-InstallerPath
rejects rooted values by design so absolute paths are not a way out. The real
dispatcher never merges - it calls the engine once per scope with that scope's
root - and a fix should follow that shape.

Both claims are corrected in the doc rather than deleted, because reading it
was what made this look like a twenty-minute job; it would have failed 29 of 31
entries on the pilot bay.

Shadow mode is unaffected - it points InstallerRoot at the share scope dir,
which is the working path - so the pilot proceeds. Register-ShopdbShadow.ps1
is the reference copy of the script that puts a share-attached bay into shadow:
it writes BaseUrl, then registers the task as SYSTEM. It derives the share
manifest from $PSScriptRoot rather than hardcoding a drive, because GE-Enforce
mounts the share to a letter it picks at run time and SYSTEM has no standing
UNC access. Delivered as a manifest entry gated to one hostname, so the pilot
needs no hands on the bay and heals if it drifts.
2026-08-13 11:21:28 -04:00
cproudlock
7d66551622 Forecast from the right end of the window, and from the level shown
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 6s
Four defects stacked into one nonsense report: cartridges at 20% claiming four
days, cartridges at 1% claiming weeks.

The root cause is a Zabbix API detail. `limit` caps the whole result set rather
than each item, and the query sorted ascending, so the cap kept the OLDEST rows
in the window. A four-cartridge printer polled every five minutes writes over
100k readings in 90 days; the forecast was fitted to the first few days of that
and nothing since. Every rate was real and every rate described a cartridge
thrown away three months ago. Nothing in the output looks wrong, which is why
it needed pinning in a test rather than a comment.

A 90-day burn rate does not need every individual poll, so a long window now
reads hourly trends - the table meant for this, a tenth of the rows, and kept
longer. Raw history serves short windows and any item a site keeps no trends
for. Both are fetched newest-first with the budget scaled per item.

Second, the countdown was computed from the last stored reading while the level
displayed was the live one, so the two could disagree by a whole cartridge. The
live level is now what the countdown divides. A live level far above the stored
run means it was swapped since the last reading, and that is reported as a
replacement rather than as a collapse in the burn rate.

Third, at or below 5% a cartridge reads as empty rather than as a slow drain.
At 1% losing a tenth of a point a day the arithmetic says ten days. The printer
is out of toner, and it is the first thing to order.

Fourth, the days-left column spanned the printer's rows, so the printer's
soonest figure was printed beside every supply it had. That alone accounts for
the shape of both complaints: a healthy cartridge wearing its neighbour's
deadline, and an empty one wearing a number that belonged to nothing on its row.

Also fixes float-typed supplies vanishing from any printer that also had an
integer-typed one - they live in different history tables and the fetch stopped
at whichever answered first.

Not verified against live data: Zabbix is not reachable from the dev box.
2026-08-13 10:54:23 -04:00
cproudlock
1e884dc02a geenforce: judge silence on both clocks, not just the server's
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 fleet table has two time columns and staleness only looked at one. A PC can
go quiet in either direction: silent to the server, or still posting while its
own cycle has stopped advancing. Only the first was caught, so a machine whose
Last check-in had not moved since the previous morning still showed 'ok'.

The two are tested independently rather than by taking the newer of them.
receivedat is the server's own record and cannot be argued with; lastcheckin is
asserted by the client, so a wrong clock there must not be able to vouch for a
PC the server has not actually heard from. Checking them separately means a
future-dated lastcheckin cannot mask real silence.

The tooltip now names both times, since which one stopped says what kind of
failure it is.
2026-08-13 09:53:56 -04:00
cproudlock
962979d483 geenforce: a PC that has gone quiet stops reading as healthy
A report records how ONE cycle went. Nothing ages it, so a PC that stops
reporting keeps the status of its last good cycle: switch a machine off after a
clean run and it shows 'ok' indefinitely. One had been offline more than a day
and still read 'ok'.

Silence is a different fact from the last cycle's outcome, so it is computed
separately rather than by rewriting the stored status. receivedat - the server's
own clock, not anything a client asserts - is compared against
geenforce_reportstaleminutes, default 30, which is roughly two missed cycles at
the usual cadence. Set it to 0 to turn the check off.

In the table 'stale' takes the badge, because a status from a machine that has
not spoken since is not evidence of anything. What it last reported stays in the
tooltip with the time it was heard. The stored status is untouched: it is still
a true record of that cycle, just not proof the PC is alive.

A site whose scope enforces less often than the threshold will read stale while
healthy, which is what the setting is for.
2026-08-13 09:28:37 -04:00
cproudlock
20a95013ad contract 0.18.0: one name per display role, the kiosk's own
Core called the roles dashboard / lobby / partskiosk. The kiosks call them
Dashboard / Lobby / 3DPrintRoom, which are the literal contents of
C:\Enrollment\display-type.txt, read by the GE-Enforce dispatcher to pick a
target. Two vocabularies for three kiosks, each with its own copy of the same
route map.

That is not cosmetic. A display reporting its own type sends what its file
says, so it could report a role core would not accept, and core could store
'partskiosk', a value no dispatcher would ever match. The enforcement report
column would have shown one vocabulary from the device and the other from the
DashboardDefault fallback, in the same column.

The machine's file wins, because that is what a person edits. DISPLAY_ROLE_PATHS
takes the kiosk spelling and the display scope now uses that dict rather than
holding a second one, so the two cannot drift again. normalize_display_role
resolves any casing and the retired 'partskiosk' forward; the dispatcher already
matched its map case-insensitively and the server now agrees with it.

Nothing is turned away over a capital: the API accepts any spelling and stores
the canonical one, displaypath resolves through the normalizer so rows written
before this keep working, and the settings dropdown canonicalises on open so an
old value does not render as a blank select.

A reported subtype is normalised on the way in, but an UNRECOGNISED one is kept
verbatim. That is a kiosk with a typo in its file or a role nobody declared, and
both are worth seeing in the fleet table rather than blanked or guessed at.

Contract bumped for the added names. DashboardDefault is finally listed in
__all__ too - 0.17.0 put it on the surface and never exported it.
2026-08-13 09:28:26 -04:00
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
279 changed files with 40836 additions and 7169 deletions

View File

@@ -10,6 +10,438 @@ ADR-007 and ADR-002.
## [Unreleased] ## [Unreleased]
## [0.11.3] - 2026-08-19
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
clients into the product: the collector reporter, EventSaver and a printer
assignment feature that lets a bay install its own printers.
**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
asset has a position but no level" for every asset, and the map PDF printed
markers from every floor onto one sheet.
### Fixed
- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
levelid, none of its seven call sites were taught to pass one - printer,
machine and PC detail, the toner report, enforcement reports, the warranty chip
and the dashboard cards. Two payloads behind them also emitted coordinates with
no level.
- **Map PDF export printed other floors' markers.** It plotted every filtered
asset onto one sheet, which is worse than the on-screen version was: nobody can
correct a sheet once it is printed and carried onto the floor.
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
with no level at three call sites. That loader is the one still to run against
production.
- The build gate that should have caught all of this asked whether a FILE
mentions `levelid`, not whether each position does - one module emitted `mapx`
six times and `levelid` once and passed. It now checks per occurrence, covers
`scripts/`, and fails any Vue file that binds tooltip coordinates without a
level.
### Added
- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
controls it, so a reimaged or swapped PC comes back with the bay's printers and
nothing had to be saved off the old one. New `usesprinter` relationship type,
propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
whole set in one call.
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
drivers cover 41 of the reference site's 44 printers; binding a driver to a
single model meant 21 near-duplicate rows. Resolution is model, then vendor,
then the pre-vendorid name convention.
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
(`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
(`Apply-ShopdbDefaultPrinter.ps1`).
- **The collector reporter is in the repository** at
`plugins/computers/client/`, with no site in it, and the server generates a
copy carrying this site's URL and ranges: Settings > Computers > Asset
reporter, or `GET /api/computers/client-script`. The collector key is
deliberately never stamped into it.
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
compiled into it is gone; a missing ini now fails visibly rather than pointing
a new site at the reference site's file server.
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
buildings-and-levels admin gains editable width AND height.
### Changed
- The map editor accepts a click. Its handler was bound only if the map was
already a picker when it mounted, and the editor opens with nothing selected,
so placing a marker by hand was impossible for the life of the page.
- Global search orders every query before truncating, so the same search cannot
return a different subset twice.
## [0.11.2] - 2026-08-17
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now
the map drew markers from every level on whichever floor plan was showing.
### Fixed
- **Markers from other levels were drawn on the displayed level.** The map
component filtered out positions with no coordinates and drew everything else,
never checking which drawing a position belonged to - the exact failure ADR-017
exists to prevent, in the one component that draws the map. Markers are now
filtered to the level being shown, and a position with no level is omitted
rather than approximated onto the default.
- **The map page had no way to choose a level.** It read the current level only
to title a PDF export, so a second floor was unreachable from the viewer most
people use. A level selector appears when a site has more than one, and
switching moves the drawing, the bounds, the coordinate space and the markers
together.
- **The map editor never accepted a click.** Its click handler was bound only if
the map was already in picker mode when it mounted, and the editor opens with
nothing selected, so selecting an asset and clicking did nothing at all for the
life of the page. The asset forms were unaffected, because their picker is
mounted inside a dialog that is already in picker mode.
- Existing markers are now drawn while placing one. Positioning an asset relative
to the machines already on the floor is the whole task.
- Searching the map follows results across levels: a search whose matches are all
on another floor showed an empty map while the filter counted them.
- **A level's native size could not be set.** The settings page had a width field
and no height field at all, and locked both once any marker was placed - which
blocked the case the feature was built for, a new blueprint of new dimensions
on a floor that already has markers. Both fields are editable, with a
confirmation and a pointer to landmark recalibration.
- **The same search could return different results.** Fourteen of the sixteen
searchers behind global search truncated with `LIMIT` and no `ORDER BY`, so the
database was free to return a different subset of matching rows each time.
Every searcher now ends in a total order.
- Re-searching a term already in the address bar did nothing: the router treats
it as a duplicate navigation and aborts, so the results page never re-queried.
This is why the sidebar box and the results-page box appeared to disagree.
- The empty legend no longer draws its bar and border, which read as a stray
input box under the map toolbar.
- The map editor's panel header no longer crams a heading and three controls onto
one row, which had squeezed the search box until its placeholder read
"Search na".
- Both map pages had a scrollbar. They subtracted the wrong amount from the
viewport height for the page chrome, so each overflowed by the difference; the
padding is now a value the pages and the layout share.
## [0.11.1] - 2026-08-17
A patch release: the pagination-cap fixes, plus the documentation that missed the
0.11.0 installer. No application behaviour changed on the server and the plugin
contract stays at 0.20.0.
`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
shipped the page an operator reads WHILE upgrading without the guidance about
that upgrade. An air-gapped server has no other way to reach it.
### Fixed
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
clamped to `MAX_PAGE_SIZE` (100) server-side and the response says nothing
about it, so a control asking for 500 or 1000 received the first 100 rows and
a success. On a site with 126 applications the 26 sorting last could not be
chosen as a knowledge-base topic; batch label printing rendered a sheet for
the first 100 of 262 machines that looked complete. New `fetchAllPages()`
helper plus `listAll()` on the applications, models, machines, computers,
printers, network, measuring-tools, USB and printed-parts APIs, used by the
KB article form and topic filter, the notification form, the report filter
builder, `AssetLabelBatch`, `PrinterQRBatch`, `USBLabelBatch`,
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
a page at a time are unchanged - they page deliberately.
### Changed
- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
floor plans, placing markers and moving them when a plan changes. Same reason
the Windows runbooks ship: the box cannot reach the wiki.
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
preserved and nothing moves on screen, and replacing a blueprint with one of
different dimensions moves every marker on that level, so recalibrate from
landmarks rather than editing the level's width and height.
## [0.11.0] - 2026-08-17
The floor map became a set of drawings instead of one picture. A site can hold
more than one building, a building more than one level, and every map position
now records which drawing its coordinates belong to. Driven by a real move: a
second floor was added, a new blueprint changed size, and machines relocated.
Alongside it, a sweep of what search can actually reach. Two identifiers the UI
collects were findable in almost no way, and two plugins' records were findable
in no way at all.
### Added
- Buildings and levels (ADR-017). Each level owns its own blueprint per theme
and its own native pixel size; `assets.mapx`/`mapy` are pixels of the level
named by the new `assets.levelid`, not of the site.
- A bulk repositioning tool on the map editor: filter by unplaced, by
needs-review, or by level, search, then place markers and confirm them. New
`POST /api/mappositions/positions` and `/verify`.
- Landmark recalibration (`POST /api/mappositions/transform`). Name two or more
points that appear on both the old and new drawing and every marker on the
level moves onto the new one. Defaults to a dry run that reports each old and
new position and anything that would land off the drawing. The transform is
solved PER AXIS from the landmarks, never derived from image dimensions: a
taller drawing that gained a level below did not rescale, and a
dimension-derived scale would stretch Y by 1.57 and be wrong everywhere.
- Position snapshots with restore, taken before any bulk change including a
restore, so an undo is itself undoable.
- A buildings and levels admin under Settings: name and order levels, see each
level's id and marker count, upload a blueprint per theme, choose the default.
- `assets.mapverifiedat`, the record of when a position was last confirmed
against the current drawing. A bulk transform clears it, because a transform
is a starting guess and nothing in the coordinates says which markers moved.
### Changed
- Plugin contract to 0.20.0 (additive; see CONTRACT-STABILITY.md). A plugin that
writes a map position must now write its level.
- A position whose level is unknown renders as "level unknown" and is NOT drawn
on the default level. Drawing it there would look entirely correct while
pointing at the wrong part of the building.
- `GET /api/printers/install-list?format=text` gained `levelid` as a NINTH
field, appended. Fields 0-7 are unchanged because the shipped Pascal installer
reads them by index.
- Knowledge base: an article whose topic is a retired (`isactive = 0`)
application no longer appears in listings, searches or the counts, and the
topic picker offers every active application rather than only installable
ones.
### Migration
- `7d33_buildings_and_levels` converts an existing single-map site: it creates
one building and one default level carrying the blueprint paths and dimensions
from the old `map_*` settings, then assigns every already-placed asset and
location to it. Nothing moves on screen. The old settings rows are left in
place so a rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
### Fixed
- `gaugelabreference` was matched only on measuring tools and
`maintenancereference` was matched nowhere at all, for any asset type, while
Settings offers both identifiers on machines, PCs, printers and network
devices. Both are now matched by global search AND by every asset list's
`?search=`. An identifier that can be entered has to be findable, or it is a
write-only field, and the tag is read off the physical machine by someone
looking at the list page.
- USB devices and printed items were unreachable from global search entirely.
Neither is an asset, so the generic asset search could not see them and no
searcher existed. They now match on serial, asset tag and label, and on bin
code (`itemcode`), gage-lab tag, name and description respectively - both
honouring `isactive`, both with Settings toggles, result labels and filter
chips.
- Global search returned knowledge-base articles whose topic application is
retired, while `GET /api/knowledgebase` hid them. A filter that only some paths
apply is not a filter: the article stayed two keystrokes away and the result
printed the retired application as its subject.
### Known gaps
- The Pascal printer-installer map still compiles in one drawing's dimensions and
bundles one blueprint, so it is accurate for the default level only. See
section 6 of PRINTER-INSTALLER.md.
- The setup wizard still asks for a single site-wide blueprint and writes
settings that no longer drive the map.
- Map PDF export covers the current level only.
## [0.10.0] - 2026-08-17
A security release. The Windows installer left the directories it creates
writable by every logged-on user, which made two of the files it puts there a
local privilege escalation; that is fixed and verified on Windows. Alongside it,
the work that makes this product genuinely adoptable by a second site: one
site's hostnames, share paths and networks are out of the product code and out
of the published documentation, and the rule that keeps them out now fails the
build rather than printing a warning nobody read.
The plugin contract moved to 0.19.0, which is a BREAKING change - see the
Changed section and CONTRACT-STABILITY.md before upgrading a plugin you wrote.
### Added
- Applications accept an uploaded image and an uploaded installer, rather than
only a path typed into a text box. The path field kept working only while
someone maintained a share by hand, and a link to a file nobody could still
find was indistinguishable from a link to one that worked. The installer
download is gated on `applications.view` because it is licensed vendor
software; the image is public, since tiles render before login.
- Warranty records accept an uploaded proof document - the invoice or
certificate that actually settles a claim. The documentation path stays as it
was: it points at a vendor portal or a share, which an upload cannot replace.
Downloads require `warranty.view`, since a proof carries pricing and a service
tag, and it comes back under the vendor's own filename so it is recognisable
in a support ticket.
- Toner forecast report: days until each cartridge runs out, soonest first,
from Zabbix level history. Ordering by days left rather than by level is the
point - a cartridge at 60% draining fast needs ordering before one sitting at
8% that has not moved in a month. It also counts how many cartridges each
printer has been through in the window, which is the number a purchasing
conversation asks for. Printers with no honest estimate are listed
separately with the reason (too little history, gauge has not moved, replaced
recently) instead of being sorted in as zero or as safe, and an unreachable
Zabbix says so rather than rendering an empty table that reads as "nothing is
due".
- Fiscal week under the site name in the sidebar and on the shop-floor board,
as the classic ASP site showed it. People quote a week number across both
sites, so the arithmetic is a port of the old one (ISO 8601: week 1 contains
4 January, the week's Thursday decides the year) rather than a fresh
interpretation.
- Backup history distinguishes CAPTURED from LAST VERIFIED. Captured is when a
configuration was written; verified is when a PC last posted it and the hash
still matched. They differ by design, because an unchanged config writes no
new revision - so a machine checked daily for a year shows one revision from
last spring, and previously read as abandoned. Shown on the history page and
on the asset panel; only the current revision carries it, since an older one
is not what the PC holds now.
- A backup now records that it was CHECKED, not only that it changed. Dedup
means an unchanged configuration writes no revision, so the stored timestamps
moved only on a change: a machine whose settings had been stable for six
months was indistinguishable from a machine whose backup died six months ago.
The only evidence a backup still ran was a line in a log file on the PC.
`lastseenat` is touched on every matching post, including the no-op, and is
backfilled from the timestamps that already exist.
- The low-supplies report lists only the cartridges that need replacing. A
printer with one empty black and three full colour cartridges was listing all
four, so the reader had to hunt for the problem inside the row. The report
exists to answer "what needs replacing".
- Dashboard cards for enforcement failures, machine numbers claimed by two PCs,
PCs not reporting, backups that have stopped, printer supplies, expiring
warranties, machines out of service, and the notifications currently showing
on the floor. The dashboard now renders cards plugins declare, which it never
did before - five plugins had been declaring widgets into a void, pointing at
components nobody wrote. Cards are ordered by severity, hide themselves when
there is nothing to report, gate on a permission, and each fetches
independently so one broken endpoint cannot blank the board.
- Tech Tools: a section for the small conversions and code generation that
otherwise happen in a browser tab on somebody's phone. Starts with barcode and
QR codes laid out for the label stock actually in the printers.
- Kiosks report what they are. The dispatcher already read
`C:\Enrollment\display-type.txt` to pick its target; now it tells ShopDB, so
the Asset column on an enforcement report shows a reported value instead of
one inferred from a mapping table nobody filled in.
- Enforcement reports carry what the PC is, where it is, and whether it is
backed up: the host links to its own page, the Asset column names the machine
or instrument the PC controls, a map pin shows the location on hover, and the
backup column judges the chain rather than printing its last date.
- `flask relationships audit`, read-only, listing links that cannot both be
true, plus an inverse guard that refuses to create one.
- An employees endpoint that says WHY a name did not resolve, rather than
returning nothing and leaving the caller to guess.
- The display client module updates itself through the manifest, so a client
change no longer means hands on every kiosk.
- `docs/START-HERE.md` routes a reader by what they came to do, and is now the
wiki's landing page - which previously recommended the one install document
the docs themselves forbid for a new site. `docs/FLEET-ARCHITECTURE.md` is the
page nothing else could assume: how the server, GE-Enforce, the asset
reporter, the backup collectors and EventSaver relate, where credentials live
at both ends, and which of the five to open for a given symptom.
- `docs/ADOPTING-AT-ANOTHER-SITE.md`: how another site repoints the asset
reporter and the EventSaver screensaver at its own ShopDB, with worked
examples for Intune (including Machine Configuration/DSC), a GE-Enforce
manifest entry, and manual installation. Both tools were already built to be
repointed - the URL, the key and the targeting are inputs - but nothing said
so, and the two things that catch people out are written down: EventSaver's
compiled-in fallback path is not neutral, and a config enforced by hash
reverts a hand edit by design.
- `docs/PROJECT-MAP.md`, generated by `scripts/gen_project_map.py`: versions,
the plugin inventory, every Alembic chain head, the ADR index and the size of
the codebase, derived from the code. The hand-written equivalents had drifted
by two contract releases and 400 tests.
### Changed
- Plugin contract **0.19.0**: `get_dashboard_widgets` returns data and shape,
not a component name. The old shape named a Vue component per widget, which
cannot survive a lean build (ADR-013) and had five plugins pointing at
components nobody wrote. The change itself shipped earlier without a bump and
with `BasePlugin` and PLUGIN-HOOKS.md still teaching the dead shape; this
records it and corrects both.
- Plugin contract **0.17.0**: `DashboardDefault` is part of the `shopdb.api`
surface, so a plugin can resolve a display without reaching into core.
- Plugin contract **0.18.0**: one name per display role, and it is the kiosk's
own (`Dashboard`, `Lobby`, `3DPrintRoom`). Server and dispatcher had three
vocabularies for the same three kiosks, and a display could report a role core
could not store. `normalize_display_role` resolves case and the retired
spelling; an unknown value is kept verbatim so a typo stays visible.
Contract versions are their own series (ADR-002) and belong in this file;
0.17.0 and 0.18.0 shipped without an entry, which is what this note corrects.
- Publishing a GE-Enforce manifest is refused when PCs reporting for that scope
run an older client library than the manifest needs, naming the hosts. A minor
version bump that NARROWS behaviour is not backward compatible: an old library
reads a gated entry as ungated and installs every version it cannot detect.
- Shadow mode runs inside the enforce cycle instead of on its own schedule. The
share is mounted only for the length of a cycle, so a task on its own clock
woke to a missing drive and reported 0 installed / 0 skipped / 0 failed, which
is indistinguishable from a healthy no-op.
- A GE-Enforce report is judged stale on both clocks, the server's and the
client's asserted check-in, so a PC cannot mask real silence with a future
timestamp. A PC that has gone quiet stops reading as healthy.
### Fixed
- Documentation a reader could not follow. 98 curl examples single-quoted
`$TOKEN`, so the shell never expanded it and the server answered 422; a JSON
body carried a `//` comment, which made the server report a missing hostname
about a body that has one; the IIS install omitted `flask plugin upgrade-all`,
which is the 1054 error a deploy hits days later; a pilot loop exited 1 on
every iteration; and the external-plugin guide pinned a contract version six
releases stale, so a plugin built by following it is refused at startup. Nine
documents now point at the generated project map rather than typing a version,
and a test enforces that. The API inventory gained the 22 routes that were
serving traffic undocumented, with a parity test against the live URL map so
the next one cannot slip in silently.
- **Every plugin migration chain re-ran its head on each MySQL deploy.** Core's
Alembic env got a `connection.commit()`; the per-plugin template did not, so
the last migration of every run lost its version stamp while its DDL survived.
Invisible for as long as every head happened to be idempotent - and two were
not. The backups head cleared a column for every row, and the geenforce head
added a column unconditionally and failed on the second attempt. Both are now
bounded and guarded, and the template commits.
- A part-marker PC reported 200 on the cycle that created its marker and 500 on
every cycle after. Both of the links it makes were looked up by label, so a row
made by hand or by the legacy import was invisible and the insert violated the
uniqueness of (source, target, type); and the marker's asset number is derived
from the PC, so it could already be taken.
- A CMM reported the same way: the instrument IS the reported bay, so adoption
was relabelling the row the machine sync owns and finding it by that label.
- A reported identifier is matched exactly rather than with `ilike`, so `_` and
`%` in a file on a shopfloor PC are no longer wildcards. `MT-600_` had been
adopting MT-6001, and a bare `%` adopted whatever active asset came first.
- Measuring tools are adopted before they are minted. Minting derived the asset
number from the HOSTNAME, so a permanent instrument took the identity of
whichever PC drove it that week, and the collector could not see a tool it had
not created - which left legacy instruments shadowed by minted twins.
- An unanchored rsync exclude in the export was matching at any depth, so a
whole plugin was silently dropped from the published payload. Root excludes
are anchored and the export now aborts if a plugin manifest fails to survive.
- Backup revisions are ordered in Python, not with `ORDER BY ... NULLS LAST`,
which SQLite accepts and MySQL rejects.
### Security
- The Windows installer breaks ACL inheritance on the roots it creates. A
directory made directly under `C:\` inherits an inherit-only Modify grant for
Authenticated Users, so `C:\shopdb-flask`, `C:\Python314` and `C:\MySQL84`
were writable by any logged-on user. Two files inside them make that a local
privilege escalation: `shopdb-admin.ps1`, which self-elevates, and
`instance/config.py`, which the app executes unconditionally. The MySQL data
directory is locked down as well, since it holds the password hashes. A Python
installation this installer did not create is reported rather than seized.
- Site-specific values no longer ship in product code (ADR-015). The backups
share root defaults to blank instead of one site's file server, the shadow
client no longer overwrites a site's own ShopDB URL in the registry on every
cycle, the kiosk dispatcher takes its URL from the `site_base_url` setting and
refuses rather than guessing, and one site's hostname, FQDN and internal
subnets are out of the documentation, the generated API specs and the UI
placeholders. The ADR-015 scanner now covers PowerShell, the installer, the
seeds and generated JSON, case-insensitively, and FAILS the build; deliberate
organisation-wide defaults are declared with an `ADR-015-OK` marker. Both
publication gates carry the site patterns.
## [0.9.0] - 2026-08-11 ## [0.9.0] - 2026-08-11
Driven by a fleet that had been failing quietly. A bay had been returning 500 Driven by a fleet that had been failing quietly. A bay had been returning 500
@@ -20,8 +452,48 @@ that consumed it. The theme of the release is making those failures visible:
each fix ships with the check, report or document that would have surfaced it. each fix ships with the check, report or document that would have surfaced it.
### Changed
- The toner forecast is read as an order, not as a table. It opens with what to
buy - grouped by part number, with a quantity - because two cartridges of the
same part in different printers is a quantity of two, and that was a number
the reader had to work out by hand. Below it, cartridges sit in urgency bands
(empty / two weeks / thirty days / later) rather than in one long sortable
list, since the question being asked is which pile a thing is in. A row is
now a cartridge rather than a printer, carries its own part number and a
level bar, and everything past "empty" starts collapsed. Cartridges with no
part mapped are counted on one line instead of a line each: they still have
to be ordered, but not from this page - the job is to map them.
### Fixed ### Fixed
- The toner forecast was reading the wrong end of the window. Zabbix applies a
query's `limit` to the whole answer rather than to each item, and combined
with an ascending sort that kept the OLDEST rows: a four-cartridge printer
polled every five minutes writes over 100k readings in 90 days, so a "90 day"
forecast was fitted to the first few days of the window and nothing since.
That is where "4 days left" beside a cartridge at 20% came from - the rate
was real, it just described a cartridge thrown away in May. Long windows now
read hourly trends, which is the table meant for this and is a tenth of the
rows; short ones read raw history, newest first, with a budget scaled per
item.
- The forecast now counts down from the level it displays. It showed the live
reading but computed days-left from the last stored one, so the two could
disagree by a whole cartridge. A live level far above the stored run is
treated as a swap that happened since the last reading rather than as a
collapse in the burn rate.
- A cartridge at or below 5% reads as empty rather than as a slow drain. At 1%
losing a tenth of a point a day the arithmetic said ten days; the printer is
out of toner, and it is the first thing that should be ordered.
- Days-left in the forecast is per cartridge again. It was rendered in a cell
spanning the printer's rows, which put the printer's soonest figure beside
every supply it had - a cartridge at 20% displaying "4 days" that belonged to
the black beside it, and a cartridge at 1% displaying weeks that belonged to
nothing on that row at all.
- Supplies typed as floats were dropped from the forecast on any printer that
also had an integer-typed one. The two live in different Zabbix history
tables and the fetch stopped at whichever answered first, so half a printer's
cartridges silently had no history at all.
- A shop-floor PC that reported the machine number of a machine ShopDB already - A shop-floor PC that reported the machine number of a machine ShopDB already
knew got a 500 from the collector, on every report, forever. The reported knew got a 500 from the collector, on every report, forever. The reported
machine number was being written to the PC's own `assets.assetnumber`, which machine number was being written to the PC's own `assets.assetnumber`, which
@@ -779,7 +1251,9 @@ letting other GE Aerospace sites stand up their own self-hosted instance
integration that passed the key as a query parameter. See integration that passed the key as a query parameter. See
`docs/COLLECTOR-INTEGRATION.md`. `docs/COLLECTOR-INTEGRATION.md`.
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...HEAD [Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.10.0...HEAD
[0.10.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...v0.9.0
[0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1 [0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1
[0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0 [0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0 [0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0

234
CLAUDE.md
View File

@@ -1,145 +1,131 @@
# ShopDB Flask Project # ShopDB Flask Project
Modern rewrite of the classic-ASP shopdb. Built as a framework so sister GE Aerospace sites can adopt it. Plugin system is the product. Modern rewrite of the classic-ASP shopdb. Built as a framework so sister GE
Aerospace sites can adopt it. The plugin system is the product.
**This site is not the only site.** Anything that names West Jefferson - a
hostname, a share path, a subnet, a machine-number format, a label prefix - is a
defect unless it is a setting with a neutral default, a site-namespaced
directory, or seed data. That is ADR-015, and it is the rule most often broken
in the places tooling cannot see: PowerShell, the installer, JSON, the imaging
share.
## Facts that move live in the map
`docs/PROJECT-MAP.md` is GENERATED. It carries the product and contract
versions, the plugin inventory, every Alembic chain head, the ADR index with
status, and the size of the codebase. Read it instead of asking; regenerate it
rather than correcting it by hand:
```bash
venv/bin/python scripts/gen_project_map.py # write
venv/bin/python scripts/gen_project_map.py --check # CI form
```
Hand-written version numbers in this file were wrong by two contract releases
and 400 tests before the map existed. Nothing that the generator can derive
should be typed here again.
## Where to look
| question | read |
|---|---|
| what a name may be called | `CONTRIBUTING.md` |
| why the architecture is like that | `docs/adr/` (index in the map) |
| what a plugin may touch | `docs/PLUGIN-HOOKS.md` |
| what the collector sends | `docs/COLLECTOR-INTEGRATION.md`, ADR-006 |
| how a site is deployed | `docs/INSTALL-WINDOWS.md`, `docs/OPERATE-WINDOWS.md` |
| how code reaches GitHub | `tools/export-github.sh` |
## Standards are gates, not prose
Compliance is checked, not remembered. Run these rather than re-reading the
rules:
```bash
bash scripts/check-naming-and-style.sh # naming, style, site literals (ADR-015)
venv/bin/python -m pytest -q # includes the docs publishability gate
venv/bin/python -m pytest tests/test_plugin_migrations.py # chain heads registry
venv/bin/python scripts/gen_project_map.py --check
```
A new plugin table means updating `PLUGIN_TABLE_OWNERS` and
`EXPECTED_HEAD_REVISION` in `tests/test_plugin_migrations.py`.
## Database ## Database
- **Active database:** `shopdb_flask` (MySQL, asset-based schema) - **Active:** `shopdb_flask` (MySQL, asset-based schema)
- **Legacy database:** `shopdb` (Classic ASP schema, used only for one-time data import via `scripts/import_from_mysql.py`) - **Legacy:** `shopdb` (classic ASP schema; one-time import only)
- **Connection:** `.env` file. See `.env.example`. - **Connection:** `.env`, see `.env.example`
Architecture decisions live in `docs/adr/`. Read those before making schema or contract changes. Core migrations run with `flask db upgrade`; every plugin owns its own chain
(ADR-008) and runs with `flask plugin upgrade-all`. **A deploy needs both** -
skipping the second is how a new column reaches production as a 1054 error.
- ADR-001: Asset model is the platform contract (Machine retires) - ACCEPTED Tests run on SQLite, production is MySQL. Dialect drift is not caught by the
- ADR-002: Plugin contract versioning (semver) - ACCEPTED suite: `ORDER BY ... NULLS LAST` parses on SQLite and is rejected by MySQL. Sort
- ADR-003: Plugin distribution model (in-tree bundled + filesystem-based external) - ACCEPTED in Python when in doubt.
- ADR-004: Deployment topology (per-site instances, not multi-tenant) - ACCEPTED
- ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED
- ADR-006: Plugin collector contract pattern - ACCEPTED
- ADR-007: Product versioning and releases - ACCEPTED
- ADR-008: Plugin migration ownership (per-plugin chains) - ACCEPTED
- ADR-009: Frontend plugin route gating - ACCEPTED
- ADR-010: Frontend plugin hook contract - ACCEPTED
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
- ADR-012: GE-Enforce manifest ownership in shopdb - ACCEPTED
- ADR-013: Plugin catalog, curated shelf, and lean per-site builds - PROPOSED
- ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, prune not-installed plugin tables) - ACCEPTED
- ADR-015: Where a site's own data is allowed to live (setting with a neutral default, site-namespaced dir, or seed) - ACCEPTED
- ADR-016: Credential delivery to the fleet (ShopDB resolves targeting, encrypted at rest, dedicated fetch scope) - ACCEPTED
## Coding convention ## Coding convention
`CONTRIBUTING.md` defines naming rules (DB tables, columns, Python, JS, Vue, API). Pre-commit hook at `scripts/check-naming-and-style.sh` enforces them. Read `CONTRIBUTING.md` before naming any new identifier. `CONTRIBUTING.md` is authoritative: lowercase concatenated DB columns, snake_case
pure-code identifiers, a closed acronym list, banned shorthand. The pre-commit
## Current state (as of 2026-07-13) hook `scripts/check-naming-and-style.sh` enforces what it can.
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely complete; the last big milestone is the legacy-data import + a production pilot.
### Phases done
- **Phase 0**: 6 ADRs accepted, naming convention v1, pre-commit style hook
- **Phase 1**: 8 smoke tests + 7 production-config tests, Flask-SQLAlchemy 3 fixtures, uv lockfile, hardened ProductionConfig
- **Phase 2**: contract surface defined (`__contract_version__`, `BasePlugin` hooks, `docs/PLUGIN-HOOKS.md`), 51 contract tests
- **Phase 3**: manifest-first loader, fail-loud/isolate policy, contract-version range checking, auto-register core blueprints, `shopdb.api` namespace, `BasePlugin.get_setting/set_setting` helpers
- **Phase 4**: `flask plugin new <name>` CLI, scaffold templates, 14 canary tests, `docs/PLUGIN-QUICKSTART.md`
- **Phase 5**: ADRs moved to `docs/adr/`, Alembic baseline migration, per-site deploy artifacts (`Dockerfile`, `docker-compose.yml`, `docs/DEPLOY.md`)
### Active state
- 1159 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- GE-Enforce HTTPS cutover: the displays/kiosks cohort now fetches manifest + inline payloads entirely over HTTPS (share-less); the `gea-shopfloor-display` scope is authored in code (`plugins/geenforce/seed_display_scope.py`) and published via `seed_display_scope(publish=True)`. Other fleet PC types still enforce from the SMB share and only report. See `docs/geenforce-api-cutover.md`.
- `__contract_version__` at 0.16.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook, 0.15.0 authorized_service_token, 0.16.0 get_settings_defaults) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
- Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.
- Windows sites install from a single air-gapped installer `.exe` built per site from its plugin profile (`deploy/windows/installer/`, built by `build-installer.sh` or `build-installer.ps1`). Operator docs: `docs/INSTALL-WINDOWS.md` + `docs/OPERATE-WINDOWS.md` - these are canonical for a NEW site. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are the MANUAL procedure, kept for hand-built servers only. The installer verifies its third-party payload against `bundle-lock.json` and installs wheels with `pip --require-hashes`; every build stages a CycloneDX SBOM (`sbom.cdx.json`) onto the server.
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 16 stages, validated end-to-end including on a Windows + MySQL 8 VM).
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script import the whole legacy DB (X-Import-Mode preserves timestamps).
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
### Deferred
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
- (DONE) Frontend plugin hook contract (ADR-010): get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation shipped; generic renderers for panels/overlays land incrementally.
- (DONE) Per-plugin Alembic chains (ADR-008): every bundled plugin carries its own chain; no plugin uses db.create_all().
- Legacy ASP data import against the renamed schema (unblocked; run via docs/IMPORT-API.md) + a production pilot deployment.
## Quick start
```bash
# Start dev environment
~/start-dev-env.sh
# Activate venv and install deps
cd /home/camp/projects/shopdb-flask
source venv/bin/activate
pip install -r requirements.txt
# Configure environment
cp .env.example .env
# Edit .env with DB credentials, JWT secrets
# Create / update database tables via the Alembic chain
flask db upgrade
# Seed RBAC, default settings, and reference data (all idempotent)
flask seed permissions
flask seed settings
flask seed reference-data
# Restart services
pm2 restart shopdb-flask-api shopdb-flask-ui
```
## Service URLs
- Flask API: http://localhost:5001
- Flask UI: http://localhost:5173
- Legacy ASP (data source for one-time import): http://192.168.122.151:8080
## Plugin structure ## Plugin structure
``` ```
plugins/ plugins/<name>/
<plugin_name>/ manifest.json # metadata, single source of truth (ADR-002)
__init__.py plugin.py # BasePlugin implementation
plugin.py # BasePlugin implementation models/ # __init__.py exports every model
manifest.json # Plugin metadata (name, version, dependencies, api_prefix) api/routes.py # Flask blueprint
models/ migrations/ # this plugin's own Alembic chain
__init__.py # Export all models services/ schemas/ frontend/ client/ # as needed
<model>.py # SQLAlchemy models
api/
__init__.py
routes.py # Flask Blueprint
services/ # Optional, business logic
schemas/ # Optional, marshmallow schemas
migrations/ # Optional, plugin-specific Alembic migrations
``` ```
Each plugin must have: No plugin imports core directly - everything goes through the `shopdb.api`
contract surface. A manifest-less directory under `plugins/` is core frontend
- `models/__init__.py` exports all models surface and always ships.
- `plugin.py` extends `BasePlugin`
- `manifest.json` with metadata (single source of truth per ADR-002)
- No direct imports from core code (use the contract surface defined in ADR-001)
## Key files ## Key files
- `shopdb/__init__.py` - app factory, blueprint registration - `shopdb/__init__.py` - app factory, version constants
- `shopdb/plugins/base.py` - BasePlugin ABC, PluginMeta dataclass - `shopdb/plugins/base.py` - BasePlugin, PluginMeta
- `shopdb/plugins/loader.py` - filesystem discovery, dependency-aware loading - `shopdb/plugins/loader.py` - discovery and dependency-aware loading
- `shopdb/core/api/assets.py` - example of optional plugin imports - `shopdb/plugins/alembic_template.py` - the env every plugin chain runs through
- `frontend/src/router/index.js` - frontend routing - `frontend/src/router/index.js`, `frontend/src/components/AppSidebar.vue`
- `frontend/src/components/AppSidebar.vue` - navigation menu - `tools/export-github.sh` - scrub and publication gate
- `docs/adr/` - architecture decision records
## Migration notes ## State
- `migrations/DATA_MIGRATION_GUIDE.md` - one-time import from legacy ASP shopdb Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
- `migrations/MIGRATE_USB_DEVICES_FROM_EQUIPMENT.md` - USB device migration from equipment table complete. The last milestone before 1.0 is unchanged and still open: the
- `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix legacy-data import plus a production pilot (`scripts/site_imports/wjf/` is the
- `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods reference loader, validated end to end).
- `migrations/rename_underscore_columns.sql` - one-time rename of snake_case columns to lowercase concatenated (per CONTRIBUTING.md)
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d26_settings_description_text`). Run `flask db upgrade` to apply. Pre-1.0 framework. Sister sites pin tight `core_version` ranges until the
contract reaches 1.0.
### Deferred
- Equipment data migration (legacy ASP -> assets). Per ADR-001 only
`category='Equipment' AND machinenumber IS NOT NULL` migrates.
- Printers retirement: legacy `PrinterData` model, coordinated with the above.
## Quick start
```bash
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # DB credentials, JWT secrets
flask db upgrade # core chain
flask plugin upgrade-all # every plugin chain
flask seed permissions && flask seed settings && flask seed reference-data
pm2 restart shopdb-flask-api shopdb-flask-ui
```
Dev API on :5001, dev UI on :5173. `venv/bin/python tools/shot.py /route` logs
in and screenshots a page.

View File

@@ -108,8 +108,10 @@ SQLite). Do not run dev or production against SQLite.
### Distribution ### Distribution
The application is distributed through the internal GE Aerospace git server. Clone Source lives here. The Windows installer ships as a release asset rather than a
it from there; there is no public package or image registry. file in the repository - it is around 240 MB, well past what a repository takes
and well inside what a release asset does. There is no package or image
registry; a site installs from that `.exe` or builds from source.
### Fast path (Docker) ### Fast path (Docker)
@@ -172,7 +174,8 @@ project wiki.
To import a site's legacy data, use the HTTP import surface: an admin API To import a site's legacy data, use the HTTP import surface: an admin API
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration
through documented endpoints (`X-Import-Mode` preserves original timestamps). through documented endpoints (`X-Import-Mode` preserves original timestamps).
`scripts/site_imports/wjf/` is the West Jefferson reference loader. `scripts/site_imports/wjf/` is the reference site's loader, kept as a worked
example of the whole sequence.
### Which deployment route ### Which deployment route
@@ -184,6 +187,29 @@ through documented endpoints (`X-Import-Mode` preserves original timestamps).
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md). For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
### What the Windows installer requires
The installer checks all of this before it changes anything and refuses rather
than half-installing.
| | Minimum | Notes |
|---|---|---|
| Windows Server | **2019** (build 10.0.17763) | Ships **IIS 10.0**, which is therefore the earliest IIS supported. Server 2016 is also IIS 10.0 but falls below the build floor and is refused. |
| Windows client (test boxes) | **10 22H2** (build 10.0.19045) or 11 | **Pro or higher** - Home has no IIS at all. |
| Architecture | 64-bit | The payload is cp314 win_amd64. |
| IIS Web Server role | installed beforehand | `Install-WindowsFeature -Name Web-Server -IncludeManagementTools`. Needs no internet. |
| Free disk | 5 GB | Refused below this. 40 GB is comfortable once uploads and config backups accumulate. |
| Database | bundled MySQL 8.4 LTS, or your own | Bring credentials if you use an existing server. |
The binding constraint is the operating system rather than IIS: the payload is
Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs
on older IIS. An older host is untested rather than known-broken.
If you must deploy onto something older, [docs/INSTALL-WINDOWS-IIS.md](docs/INSTALL-WINDOWS-IIS.md)
is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is
that era of machine. The trade is stated there: it produces a server the
installer will not subsequently upgrade.
## Configuration ## Configuration
Environment variables (`.env`): Environment variables (`.env`):
@@ -220,12 +246,22 @@ Query parameters for list endpoints:
- `search` - Search term - `search` - Search term
- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool) - `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool)
## Where to start
[docs/START-HERE.md](docs/START-HERE.md) routes by what you are here to do -
standing up a site, deploying the shop-floor tools, writing a plugin,
integrating with the API, or diagnosing something. On the published mirror the
same page is the wiki's front door.
## Plugin System ## Plugin System
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines. ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles thirteen plugins; only the ones a site installs are loaded: The image bundles these plugins; only the ones a site installs are loaded. The
current count and each plugin's version and migration head are in
[docs/PROJECT-MAP.md](docs/PROJECT-MAP.md), which is generated:
- **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs
- **computers** - Shopfloor PCs and workstations, collector fleet ingest - **computers** - Shopfloor PCs and workstations, collector fleet ingest
- **employees** - Employee directory - **employees** - Employee directory
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance - **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
@@ -237,6 +273,7 @@ The image bundles thirteen plugins; only the ones a site installs are loaded:
- **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts - **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts
- **printers** - Extended printer management with Zabbix integration - **printers** - Extended printer management with Zabbix integration
- **slides** - TV/kiosk slideshows - **slides** - TV/kiosk slideshows
- **tools** - Tech Tools: barcode and QR generation laid out for real label stock
- **usb** - CMMC USB check-in/out tracking - **usb** - CMMC USB check-in/out tracking
- **warranty** - Warranty records with Dell lookups - **warranty** - Warranty records with Dell lookups

View File

@@ -13,6 +13,7 @@
"printedparts", "printedparts",
"printers", "printers",
"slides", "slides",
"tools",
"usb", "usb",
"warranty" "warranty"
], ],

View File

@@ -436,6 +436,43 @@ function Protect-File {
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL' Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL'
} }
function Protect-InstallRoot {
# A directory created directly under C:\ INHERITS the root's DACL, and the
# default C:\ DACL carries an inherit-only Modify grant for Authenticated
# Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by
# every logged-on user until inheritance is broken.
#
# That is a local privilege escalation here specifically, because this
# installer puts two things a non-admin could then replace inside those
# roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and
# instance\config.py, which the app loads unconditionally through
# from_pyfile. Overwrite either, wait for an administrator or the app pool
# to run it, and the code runs as them.
#
# RE-APPLIED EVERY TIME, not only on creation - same reasoning as the
# ProgramData backups directory. An upgrade over an installation that
# predates this must repair the ACL, or the hole survives the fix.
#
# The app pool's own RX/Modify grants are applied in stage 4, AFTER this,
# so breaking inheritance here does not lock the site out of its own tree.
param([string] $Path)
if (-not (Test-Path $Path)) { return }
Write-Log "hardening $Path (breaking inheritance from its drive root)"
Protect-File $Path -Directory
}
function Test-UserWritableRoot {
# Whether a non-admin can write to a path. Used ONLY on roots this
# installer did not create: hardening a Python another application already
# depends on would break that application, so a pre-existing root is
# reported rather than seized. Silence would be worse - the app would be
# running on an interpreter any user can replace.
param([string] $Path)
if (-not (Test-Path $Path)) { return $false }
$acl = & icacls.exe $Path 2>$null
return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)')
}
function New-Secret { function New-Secret {
param([int] $Bytes = 48) param([int] $Bytes = 48)
$b = New-Object byte[] $Bytes $b = New-Object byte[] $Bytes
@@ -461,6 +498,9 @@ function Test-SamePath {
} }
# Paths derived once. # Paths derived once.
# Default so a stage that runs on its own still knows where the interpreter is;
# the Python stage overwrites it with what it actually installed.
$script:PyRoot = 'C:\Python314'
$Py = Join-Path $AppRoot 'venv\Scripts\python.exe' $Py = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' $Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' $Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe'
@@ -956,6 +996,10 @@ Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand.
# bootstrapper returned 0 while installing to the wrong directory. Verify. # bootstrapper returned 0 while installing to the wrong directory. Verify.
if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
Track 'mysql-files' $MysqlRoot Track 'mysql-files' $MysqlRoot
# The service is registered with `mysqld --install` and no obj=, so it runs
# as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write
# here; a user who can replace mysqld.exe owns the service account.
Protect-InstallRoot $MysqlRoot
Write-Log 'MySQL binaries installed' 'OK' Write-Log 'MySQL binaries installed' 'OK'
# --- config ------------------------------------------------------------- # --- config -------------------------------------------------------------
@@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database.
Write-Log 'initializing data directory' Write-Log 'initializing data directory'
Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
Track 'mysql-datadir' $MysqlDataDir Track 'mysql-datadir' $MysqlDataDir
# The data directory holds the users table and its password hashes, and it
# sits under ProgramData, which grants Users read by inheritance - the same
# exposure the backup directory already had to be locked against. mysqld
# runs as LocalSystem, so Administrators + SYSTEM is sufficient.
Protect-File $MysqlDataDir -Directory
# --- service ------------------------------------------------------------ # --- service ------------------------------------------------------------
# Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified # Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified
@@ -1048,6 +1097,9 @@ directory to install cleanly.
# with default ACLs and populated afterwards is readable in the gap. # with default ACLs and populated afterwards is readable in the gap.
$sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null
# Before the file goes in: this may be the first thing that creates AppRoot,
# and the file below holds the root and app database passwords.
Protect-InstallRoot $AppRoot
New-Item -ItemType File -Force -Path $sqlPath | Out-Null New-Item -ItemType File -Force -Path $sqlPath | Out-Null
Protect-File $sqlPath Protect-File $sqlPath
$sql = @" $sql = @"
@@ -1150,8 +1202,17 @@ function Invoke-Stage2 {
# Same class of failure as MySQL's --defaults-file. Never put a space in a # Same class of failure as MySQL's --defaults-file. Never put a space in a
# path this installer controls. # path this installer controls.
$pyTarget = 'C:\Python314' $pyTarget = 'C:\Python314'
# Stage 4 needs it to grant the app pool read access; a venv's python.exe is
# a copy, but python314.dll and the stdlib still come from here.
$script:PyRoot = $pyTarget
if (Test-Path (Join-Path $pyTarget 'python.exe')) { if (Test-Path (Join-Path $pyTarget 'python.exe')) {
Write-Log "Python already present at $pyTarget" 'OK' Write-Log "Python already present at $pyTarget" 'OK'
# NOT hardened: something else installed it and may depend on the ACL it
# has. Say so instead - the app would otherwise run on an interpreter
# any logged-on user can replace.
if (Test-UserWritableRoot $pyTarget) {
Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN'
}
} elseif ($null -eq $pyInstaller) { } elseif ($null -eq $pyInstaller) {
Fail "no Python installer in $BundleRoot\python" Fail "no Python installer in $BundleRoot\python"
} else { } else {
@@ -1164,6 +1225,9 @@ function Invoke-Stage2 {
'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641
Track 'python' $pyTarget Track 'python' $pyTarget
# Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314
# with the drive root's inherited Authenticated Users Modify.
Protect-InstallRoot $pyTarget
} }
} }
$sysPy = Join-Path $pyTarget 'python.exe' $sysPy = Join-Path $pyTarget 'python.exe'
@@ -1174,6 +1238,7 @@ function Invoke-Stage2 {
New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
Track 'dir' $AppRoot Track 'dir' $AppRoot
} }
Protect-InstallRoot $AppRoot
# --- upgrade detection --------------------------------------------------- # --- upgrade detection ---------------------------------------------------
$script:BundleVersion = Get-BundleVersion $script:BundleVersion = Get-BundleVersion
$script:InstalledVersion = Get-InstalledVersion $script:InstalledVersion = Get-InstalledVersion
@@ -2022,6 +2087,13 @@ has to come from the bundle either way.
$ident = "IIS AppPool\$AppPool" $ident = "IIS AppPool\$AppPool"
Write-Log "granting $ident access" Write-Log "granting $ident access"
Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot'
# The Python root no longer inherits from the drive root, so the pool has
# no access to it by default. The venv's python.exe is a copy; the DLL
# and the standard library are still read from the base install, so
# without this grant the site 500s on every request.
if ($script:PyRoot -and (Test-Path $script:PyRoot)) {
Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root'
}
foreach ($sub in @('logs','instance')) { foreach ($sub in @('logs','instance')) {
Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
} }

View File

@@ -0,0 +1,464 @@
# Adopting the fleet tools at another site
Two tools run on shop-floor PCs and talk to a ShopDB instance: the **asset
reporter**, which posts what a PC is to the collector API, and **EventSaver**,
a screensaver that shows a slideshow served by ShopDB. Both are designed to be
repointed at another site's server. This page is how.
Neither tool is site-specific by design. What is site-specific is the URL, the
API key, and which PCs it runs on - and all three are inputs, not code.
> **Prerequisite.** The reporter's `-ApiUrl` parameter still carries a default
> pointing at the reference site's server. Adopt it with an explicit `-ApiUrl`
> (every example below does) until that default is removed. See
> [ADR-015](adr/ADR-015-site-specific-configuration.md).
## Where these files come from
The commands below copy files. This is where each one is obtained, because they
do not all ship from the same place and two of them are not in this repository
at all yet.
| Artifact | Where it is today |
|---|---|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
A site that would rather not run a binary it cannot rebuild should take
EventSaver's source and compile it locally - the build needs no SDK and is one
command.
---
## Part 1: the asset reporter
### What it does
Runs as SYSTEM, collects hostname, BIOS serial, PC type, logged-in user, IP
addresses and the machine number where one is configured, and POSTs them to:
```
POST https://<your-shopdb>/api/collector/computers
X-API-Key: <collector token>
Content-Type: application/json
```
The payload contract is [COLLECTOR-INTEGRATION.md](COLLECTOR-INTEGRATION.md) and
[ADR-006](adr/ADR-006-collector-contract.md). A field your site does not collect
is simply absent; the server upserts on `hostname` and leaves the rest alone.
### What you must provide
| Input | Where it comes from |
|---|---|
| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
generates this script with your server's URL and ranges already in the parameter
defaults, and shows its SHA-256. The key is deliberately not included.
### Minting the key on your ShopDB
Create a **managed personal access token scoped to `collector.ingest` and
nothing else**, and use it as the `X-API-Key` value. That scope authorises the
collector ingest API and no normal route, so a token recovered off a shop-floor
PC cannot read your asset register. A shared `COLLECTOR_API_KEY` environment
variable also works and is simpler for a pilot, but it cannot be rotated per
fleet or revoked individually.
### Deploying it
The three paths below are alternatives - pick one. All of them end with the same
two facts on the PC: the script exists somewhere it can run from, and the key is
in the registry.
#### Option A: Microsoft Intune
**A1. The key, as a platform script.** Devices > Scripts and remediations >
Platform scripts > Add > Windows 10 and later. Run as SYSTEM, do not run in the
64-bit context only.
```powershell
# Set-ShopdbCollectorKey.ps1 (Intune platform script, runs once per device)
$RegPath = 'HKLM:\SOFTWARE\GE\ShopDB'
$Key = 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN'
if (-not (Test-Path $RegPath)) { New-Item -Path $RegPath -Force | Out-Null }
$current = (Get-ItemProperty -Path $RegPath -Name CollectorKey -ErrorAction SilentlyContinue).CollectorKey
if ($current -ne $Key) { Set-ItemProperty -Path $RegPath -Name CollectorKey -Value $Key }
exit 0
```
Putting a token in an Intune script body means anyone who can read the Intune
configuration can read the token. That is an argument for the `collector.ingest`
scope, not against Intune: the blast radius of that token is one API.
**A2. The reporter, as a remediation on a schedule.** Devices > Scripts and
remediations > Remediations. A remediation is the natural fit because it re-runs
on a schedule, which is exactly what reporting is.
*Detection script* - forces the remediation to run every cycle:
```powershell
# Always "not compliant": reporting is not a state to converge on, it is an
# event that should happen on every schedule tick.
exit 1
```
*Remediation script* - the reporter itself, with your URL:
```powershell
& "$PSScriptRoot\Report-AssetToShopDB.ps1" `
-ApiUrl 'https://shopdb.example.net/api/collector/computers'
exit 0
```
Assign it with a daily schedule. Run as SYSTEM, 64-bit.
**A3. Intune with DSC (Machine Configuration).** If your estate is already
governed by Azure Machine Configuration, express the *key* as configuration and
leave the *reporting* to a scheduled task, because a report is an event and DSC
converges state:
```powershell
Configuration ShopdbCollector
{
Import-DscResource -ModuleName PSDesiredStateConfiguration
Node localhost
{
Registry CollectorKey
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorKey'
ValueData = 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN'
ValueType = 'String'
Ensure = 'Present'
}
Registry CollectorUrl
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'BaseUrl'
ValueData = 'https://shopdb.example.net'
ValueType = 'String'
Ensure = 'Present'
}
# Optional. Only for a site whose bays carry both a controller NIC and a
# corporate one AND whose default route is not the corporate NIC. Leave
# this resource out otherwise - the script picks the default-route NIC.
Registry CollectorRanges
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorRanges'
ValueData = '10.20.0.0/23,10.21.4.0/26'
ValueType = 'String'
Ensure = 'Present'
}
Script ReportingTask
{
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
SetScript = {
# No -ApiUrl: BaseUrl above is where the script reads it from,
# and one source beats two that can disagree.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
}
}
}
}
```
#### Option B: GE-Enforce
If you already run the GE-Enforce client, this is one manifest entry, and it is
how the reference site does it. `Type: PS1` with `DetectionMethod: Always` runs
the script from the share every cycle with no local copy and no hash to bump
when the script changes.
```json
{
"_comment": "Reports host, BIOS serial, pc-type, logged-in user, IPs and machine number to ShopDB every cycle, as SYSTEM, straight off the share.",
"Name": "Report asset to ShopDB",
"Type": "PS1",
"Script": "apps/Report-AssetToShopDB.ps1",
"Args": "-ApiUrl https://shopdb.example.net/api/collector/computers",
"DetectionMethod": "Always"
}
```
Provision the key with a second entry, ordered **before** it, so the key is
present the same cycle the first report fires:
```json
{
"_comment": "Writes the collector key from an ACL'd share file. The token is NOT in this manifest - manifests sync broadly. Rotate by replacing configs/collector-key.txt on the share; every PC picks it up next cycle.",
"Name": "Provision the ShopDB collector key",
"Type": "PS1",
"Script": "scripts/Set-CollectorKey.ps1",
"DetectionMethod": "Always"
}
```
Keep the token in `configs/collector-key.txt` on your share, locked to SYSTEM
and administrators - not in the manifest JSON.
#### Option C: manual
For a pilot, a single bay, or an estate with no management plane:
```powershell
# As administrator, once per PC.
New-Item -Path 'C:\ProgramData\ShopDB' -ItemType Directory -Force | Out-Null
Copy-Item .\Report-AssetToShopDB.ps1 'C:\ProgramData\ShopDB\'
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name CollectorKey `
-Value 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument @'
-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"
'@
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action -Trigger $trigger `
-User 'SYSTEM' -RunLevel Highest -Force
Start-ScheduledTask -TaskName 'ShopDB asset report'
```
Lock down `C:\ProgramData\ShopDB` if you put it there: a script a user can edit,
run by SYSTEM on a schedule, is a local privilege escalation. Break inheritance
and grant Administrators and SYSTEM only.
### Verifying
```powershell
Invoke-RestMethod -Uri 'https://shopdb.example.net/api/computers/by-hostname/<HOSTNAME>'
```
The PC should exist with its serial, type and last-reported timestamp. The
script also logs to `C:\Logs\Shopfloor`.
---
## Part 2: EventSaver
### What it does
A Windows screensaver (`EventSaver.scr`) that shows a slideshow. It reads
`EventSaver.ini` from the directory it lives in, **on every launch**, so
retargeting it needs no recompile:
```ini
# HTTP mode (recommended): pull slides from ShopDB, cache locally. No share.
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
# Folder mode (fallback): used only when url is blank. SMB or local path.
# folder=\\fileserver\shopfloor\tv
interval=10 # seconds per image, when a slide carries no time of its own
shuffle=0 # 1 = random, 0 = ordered
fadems=600 # crossfade length, reserved
```
HTTP mode is the one to adopt: slides are managed in ShopDB (Slides), and the
screensaver caches them locally, so a PC that cannot reach the server keeps
showing the last set instead of going black.
### The two things that catch people out
**The compiled-in fallback is not yours.** If `EventSaver.ini` is missing or
both `url` and `folder` are blank, the binary falls back to a folder path
compiled into `EventSaver.cs`, which is the reference site's file server. Ship
the ini. A missing ini is not a neutral default.
**If you enforce the ini by hash, your own edits revert.** The reference
manifest pins `EventSaver.ini` with `DetectionMethod: Hash`, which is what makes
a retune propagate to the fleet automatically - and equally means a hand edit on
one PC is undone next cycle. That is the feature working. Edit the copy on
**your** share and update `DetectionValue` to its new SHA-256:
```powershell
(Get-FileHash .\configs\EventSaver.ini -Algorithm SHA256).Hash
```
### Deploying it
#### Option A: Intune
Package the three files as a Win32 app (`EventSaver.scr`, `EventSaver.ini`, and
the enable script) with this install command:
```powershell
# install.ps1 for the Win32 app
Copy-Item .\EventSaver.scr 'C:\Windows\System32\EventSaver.scr' -Force
Copy-Item .\EventSaver.ini 'C:\Windows\System32\EventSaver.ini' -Force
exit 0
```
Detection rule: file `C:\Windows\System32\EventSaver.scr` exists **and** matches
the expected SHA-256, so a changed binary reinstalls.
The screensaver itself is per-user, so set it with a configuration profile
(Settings catalog > Screen saver) or a user-context script:
```powershell
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value 'C:\Windows\System32\EventSaver.scr'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '1'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '480'
```
With Machine Configuration/DSC, express the two files with `File` resources and
the screensaver with a `Registry` resource in the user hive - noting that DSC
runs as SYSTEM, so a per-user setting needs the logged-on user's hive or a
default-user template, which is usually more trouble than a login script.
#### Option B: GE-Enforce
Three entries, which is exactly how the reference fleet does it. Note the
per-PC-type targeting: a kiosk showing a live dashboard must **not** get a
screensaver over the top of it.
```json
{
"Name": "EventSaver screensaver (binary)",
"Type": "File",
"Source": "apps/EventSaver.scr",
"Destination": "C:\\Windows\\System32\\EventSaver.scr",
"DetectionMethod": "Hash",
"DetectionPath": "C:\\Windows\\System32\\EventSaver.scr",
"DetectionValue": "<sha256 of your staged .scr>",
"PCTypes": ["gea-shopfloor-common"]
},
{
"Name": "EventSaver screensaver (config)",
"Type": "File",
"Source": "configs/EventSaver.ini",
"Destination": "C:\\Windows\\System32\\EventSaver.ini",
"DetectionMethod": "Hash",
"DetectionPath": "C:\\Windows\\System32\\EventSaver.ini",
"DetectionValue": "<sha256 of YOUR .ini, pointing at YOUR shopdb>",
"PCTypes": ["gea-shopfloor-common"]
},
{
"Name": "EventSaver enable (per-user screensaver)",
"Type": "PS1",
"Script": "scripts/Set-EventSaverScreensaver.ps1",
"Args": "-TimeoutSeconds 480",
"DetectionMethod": "Always",
"PCTypes": ["gea-shopfloor-common"]
}
```
#### Option C: manual
```powershell
Copy-Item .\EventSaver.scr 'C:\Windows\System32\' -Force
Copy-Item .\EventSaver.ini 'C:\Windows\System32\' -Force # with YOUR url=
# Per user, in the user's own session:
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value 'C:\Windows\System32\EventSaver.scr'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '1'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '480'
```
Run `EventSaver.scr /c` to see which source it resolved and where it is caching.
### Verifying
Open the feed in a browser from the PC itself:
```
https://shopdb.example.net/api/slides/feed?surface=shopfloor
```
It is a public endpoint - the screensaver has no credentials - and returns the
playlist with per-slide durations. An empty `slides` array means slides have not
been uploaded for that surface, not that the PC is misconfigured.
---
---
## Part 3: bootstrapping GE-Enforce itself
The two parts above assume the tools are already on the PC. GE-Enforce is what
puts them there and keeps them there - so the question is how GE-Enforce gets
onto a bay in the first place. Two paths are supported, and they are the two
that actually occur: imaging time, and a management plane such as Intune.
Whichever you use, the end state is the same three things:
1. The client files in `C:\Program Files\GE\Shopfloor`.
2. `HKLM:\SOFTWARE\GE\ShopDB` carrying `BaseUrl`, and `ApiToken` where the scope
is served over HTTPS.
3. A scheduled task running the enforce cycle as SYSTEM.
### At imaging time (the usual path)
The shop-floor imaging pipeline registers the enforce task as its last step,
once the PC type is known - the type is what decides which manifest scope the
bay enforces. Self-contained types are skipped deliberately: a display kiosk
gets its configuration at imaging time and no share, so registering an enforce
task on one would give it a cycle with nothing to do.
If you are building your own imaging pipeline, the equivalent step is: copy the
engine into a runtime directory, write the PC type where the client can read it,
and register the task. That is what `Install-GEEnforce.ps1` does in one call.
### Through Intune
`Install-GEEnforce.ps1` is parameterised for exactly this, and this is how the
display cohort is deployed today - those PCs are Entra-joined, have no file
share, and fetch their manifest over HTTPS:
```powershell
.\Install-GEEnforce.ps1 `
-PCType 'gea-shopfloor-common' `
-ShopdbUrl 'https://shopdb.example.net' `
-ShopdbToken 'shopdb_pat_REPLACE_WITH_YOUR_TOKEN'
```
Package it as a Win32 app with the client files. Detection rule: the scheduled
task exists **and** `HKLM:\SOFTWARE\GE\ShopDB\BaseUrl` matches your server, so a
PC that was imaged for another site is repaired rather than skipped.
The token is a managed service token scoped to the GE-Enforce fetch scope, not
an admin credential. Scope it that way and a token read off a bay buys the
reader a manifest they could have read anyway.
### With DSC (Machine Configuration)
There is no shipped DSC configuration, but nothing about the end state resists
one - it is two registry values, a set of files and a scheduled task, which is
ordinary DSC territory. Express it the same way as the reporter example above:
`Registry` resources for `BaseUrl` and `ApiToken`, `File` resources for the
client, and a `Script` resource that registers the task.
One caution. DSC converges state on a schedule and GE-Enforce is itself a
convergence loop, so running both against the same PC means two things fighting
to own the same configuration. Use DSC to install and configure the client, and
let GE-Enforce own everything downstream of that. Deciding which tool owns what
is the whole job; splitting it by layer is what keeps it answerable.
## Which path to choose
| You have | Use |
|---|---|
| Intune, no GE-Enforce | Option A. A remediation for the reporter, a Win32 app for EventSaver. |
| GE-Enforce | Option B. One manifest entry each, and the fleet converges on its own cycle. |
| Neither, or a pilot of a few bays | Option C, then move to A or B once it earns its place. |
GE-Enforce and Intune are not exclusive. The reference site uses Intune for
enrolment and imaging and GE-Enforce for shop-floor configuration state, because
Intune is not on the shop floor network path in the same way. If you have both,
the deciding question is which one you would look at first to answer "why is
this bay wrong".

View File

@@ -22,7 +22,7 @@ The repo ships hosted, generated API docs. Start there:
The MCP server exposes a curated set of GET endpoints as tools for an agent to The MCP server exposes a curated set of GET endpoints as tools for an agent to
query the asset database over HTTPS with a scoped read token; it never runs on query the asset database over HTTPS with a scoped read token; it never runs on
the prod box. Set it up on a work PC with the prod box. Set it up on a work PC with
`pxe-images/github/setup-mcp.cmd`. `<imaging-share>/github/setup-mcp.cmd`.
The docs blueprint is `shopdb/core/api/docs.py` (a core blueprint, always The docs blueprint is `shopdb/core/api/docs.py` (a core blueprint, always
mounted regardless of which plugins are staged into a site build). mounted regardless of which plugins are staged into a site build).
@@ -75,8 +75,8 @@ keying) are covered there as well.
Everything else is the core UI API: the endpoints the Vue frontend calls. As a Everything else is the core UI API: the endpoints the Vue frontend calls. As a
rule these are JWT-authenticated (a login token or a managed Personal Access rule these are JWT-authenticated (a login token or a managed Personal Access
Token) and versioned by the plugin contract (`__contract_version__`, currently Token) and versioned by the plugin contract (`__contract_version__`; the
0.16.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**; current value is in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
sister sites should pin tight `core_version` ranges until the contract reaches sister sites should pin tight `core_version` ranges until the contract reaches
1.0. 1.0.
@@ -96,6 +96,25 @@ Two auth patterns dominate the reads:
Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is
gated by `require_role` or `require_permission`; none are public. gated by `require_role` or `require_permission`; none are public.
The map write surface splits along that line. Repositioning markers
(`/api/mappositions/*`) needs `assets.edit` - the same permission the old
per-asset `PUT /api/assets/<id>` always needed, so bulk editing grants nobody
anything new. Creating buildings and levels or uploading a blueprint
(`POST|PUT|PATCH|DELETE /api/maplevels/*`) needs `role:admin`, because a level's
dimensions are the coordinate space every marker on it is expressed in.
Two GETs deliberately break the optional-auth convention and require a
permission, because what they return is not asset metadata but a file that
carries value or liability of its own:
| Endpoint | Requires | Why |
|---|---|---|
| `GET /api/applications/package/<filename>` | `applications.view` | Licensed vendor installers. An anonymous URL is a redistribution channel. |
| `GET /api/warranty/proof/<filename>` | `warranty.view` | Invoices and certificates carry pricing and a service tag. |
Their sibling upload/delete routes are `applications.edit` / `warranty.edit`
like any other mutation.
### Fully public endpoints (auth = none) ### Fully public endpoints (auth = none)
| Endpoint | Purpose | | Endpoint | Purpose |
@@ -103,10 +122,14 @@ gated by `require_role` or `require_permission`; none are public.
| `POST /api/auth/login` | Obtain a JWT. | | `POST /api/auth/login` | Obtain a JWT. |
| `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. | | `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. |
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. | | `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
| `GET /api/settings/map-blueprint/<filename>` | Serve the floor-map blueprint image. | | `GET /api/settings/map-blueprint/<filename>` | Serve a floor-map blueprint image. Pre-0.11.0 single-map path, retained; new uploads are served per level below. |
| `GET /api/maplevels` | Buildings and their levels, each with blueprint paths, native pixel size and marker count. Public because the printer-installer map draws a floor plan before anyone logs in; it exposes level names and image paths, nothing about assets. |
| `GET /api/maplevels/<levelid>` | One level's name, building, blueprints and size. |
| `GET /api/maplevels/<levelid>/blueprint/<filename>` | Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script. |
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). | | `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
| `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. | | `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. |
| `GET /api/models/image/<filename>` | Serve a model image. | | `GET /api/models/image/<filename>` | Serve a model image. |
| `GET /api/applications/image/<filename>` | Serve an application image (tiles render before login). |
| `GET /api/dashboard/navigation` | Public navigation tree. | | `GET /api/dashboard/navigation` | Public navigation tree. |
| `GET /api/dashboard/health` | Liveness / health probe. | | `GET /api/dashboard/health` | Liveness / health probe. |
| `GET /api/plugins/enabled` | List enabled plugins (no claims used). | | `GET /api/plugins/enabled` | List enabled plugins (no claims used). |

View File

@@ -12,8 +12,8 @@ platform contract. What is new is the recipe.
A machine number is supposed to identify one thing. Sometimes it does not. A machine number is supposed to identify one thing. Sometimes it does not.
At West Jefferson, several Telesis part markers serve one operation number: At the reference site, several Telesis part markers serve one operation number:
0613, 0615 and WJPRT each have more than one. Their configurations differ, most 0613, 0615 and PRTMK01 each have more than one. Their configurations differ, most
often by COM port. Treating the operation as the device collapsed them into a often by COM port. Treating the operation as the device collapsed them into a
single record, and the damage was quiet: single record, and the damage was quiet:

125
docs/BACKUP-KINDS.md Normal file
View File

@@ -0,0 +1,125 @@
# Fleet config backups: adding a kind, and how health is judged
This is about the `backups` plugin - the per-PC CONFIG backups the collector
takes off shopfloor machines (NTLARS settings, part-marker configs). It is not
about backing up the ShopDB database itself; that is `docs/BACKUP-RESTORE.md`.
Two things this covers:
1. [Adding a new backup kind](#adding-a-new-backup-kind)
2. [How the Backup badge decides good vs stale](#how-the-backup-badge-decides-good-vs-stale)
---
## Adding a new backup kind
A kind is one class in `plugins/backups/services/registry.py`, added to
`REGISTRY` at the bottom of that file:
```python
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
```
Subclass `BackupKind` and override what applies. `NtlarsKind` is the fullest
example (parseable, renderable, has an info panel); `PartMarkerKind` is the
lean one.
| Member | What it is |
|--------|------------|
| `key` | Wire value. This is what lands in `backuprevisions.backupkind` and what the fleet table shows on the badge. Keep it short and lowercase. |
| `displayname` | Human label for the UI. |
| `storagebackend` | `'shopdb'` (bytes live in the DB, deduped on a semantic hash) or `'share'` (file lives on the SMB share, deduped on a raw byte hash). |
| `assettypes` | Which core asset types this kind can attach to; `['*']` for any. |
| `emptytext` | Text when an asset has no revisions of this kind. `None` HIDES the panel, which is the right default - a kind applies to an asset TYPE, but whether a given machine ever has that backup is per-machine. A part-marker panel on all 144 machines is noise. |
| `parse(raw)` | Opaque kinds return `None`; parseable kinds return a projection dict. |
| `formats()` / `render(...)` | Download formats, for `shopdb` kinds. |
| `resolveassetid(payload)` | Map a collector payload to the asset the backup belongs to. |
| `infopanel()` / `buildinfo(...)` | Optional at-a-glance card (ADR-010). Declared by the KIND, not hardcoded in the plugin, so a successor technology ships its own card by adding a class. |
| `sharedir(...)` | Conventional UNC directory for a `share` kind. Advisory - the authoritative path is whatever the collector reported, because the PC is what actually wrote the file. |
### What you do NOT have to touch
The GE-Enforce **Enforcement Reports** Backup column needs no change. It reads
`backuprevisions` generically: newest revision per host, whatever the kind, and
shows `backupkind` on the badge. A new kind inherits the badge, the colour and
the tooltip with no work.
Same for the staleness rule below - it is time-based and kind-agnostic.
---
## How the Backup badge decides good vs stale
### The trap this is built around
**A revision is only written when the config CHANGES.** Dedup compares against
the latest revision for the chain `(asset, kind, sourcehostname)`, so a machine
whose config has been stable for six months has a six-month-old newest revision
and is perfectly healthy. The question worth answering is not "when was the last
backup taken" - it is "is this still being checked".
So the timestamp the fleet table uses is `backuprevisions.lastseenat`: the last
time the collector CONFIRMED this config, whether or not anything changed. It
moves on every successful collection; the revision does not.
That was shown as a raw date at first, and it read as neglect. At the default
`backups_intervalhours` of **24**, the collector only attempts once a day, so a
day-old confirmation IS the healthy steady state. The date made a working system
look like a stalled one, and made the reader do arithmetic against a setting
they would have to go and look up.
### What it does now
`GET /api/geenforce/reports` returns, per host:
| Field | Meaning |
|-------|---------|
| `backupkind` | Which kind was most recently confirmed. `null` = no backup at all. |
| `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. |
| `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. |
| `backupok` | `true` good, `false` stale, `null` nothing to judge. |
| `backupstaleafterdays` | The threshold in force, so the UI can explain itself. |
Both dates are reported because they answer different questions and one cannot
stand for both. The hover reads *"ntlars: checked 13 Aug 1:20 PM. Verified the
backup taken 12 Aug 5:20 PM is still current."* - the first date proves the
check is running, the second says what is actually stored. Collapsing them was
what made a healthy machine look neglected.
`backupok` is deliberately **tri-state**. `null` means there is no revision for
that host, or the check is disabled - and it renders as no badge, never green.
"Never seen" must not read as healthy.
The threshold is the backups plugin's own `backups_staledays` setting (default
**3**), read through `plugins.backups.services.staleness.staledays()` rather
than re-derived, so there is ONE definition of stale. `0` disables the check.
The import is guarded, so a lean site build without the backups plugin returns
`null` instead of failing.
### Why time-based rather than per-kind
Every kind answers the same question the same way: something confirmed this
recently, or it did not. Making the rule per-kind would mean each new kind has
to define health before it can show a badge, for no gain. If a kind ever needs
its own window - a weekly backup that should not be judged on a 3-day rule -
add a threshold override on `BackupKind` and have `_backup_stale_cutoff` prefer
it; the tri-state contract stays as it is.
### Where the code lives
- `plugins/geenforce/api/routes.py` - `_attach_backup_state` (newest revision
per host), `_backup_stale_cutoff` (threshold), `_backup_ok` (verdict)
- `plugins/geenforce/frontend/views/EnforcementReports.vue` - `backupClass`
(green / red / none), `backupTitle` (the hover text)
- `plugins/backups/services/staleness.py` - the shared threshold, also behind
the dashboard's stale-backups card
Tests: `tests/test_plugins/test_geenforce_reporting.py`, the backup-verdict
block - recent-is-good, older-than-threshold-is-stale, and none-is-not-green.
---
## See also
- `docs/BACKUP-RESTORE.md` - backing up the ShopDB database itself
- `docs/geenforce-api-cutover.md` - the fleet reporting path these fields ride on

View File

@@ -166,10 +166,53 @@ Two Windows-specific notes:
using a remote MySQL needs `mysqlclient\` in its installer bundle, or the using a remote MySQL needs `mysqlclient\` in its installer bundle, or the
pre-upgrade backup is skipped. `shopdb-admin.ps1 check` reports this. pre-upgrade backup is skipped. `shopdb-admin.ps1 check` reports this.
Restoring is the standard `mysql < dump.sql`, then ### Restoring on Windows, in order
`.\shopdb-admin.ps1 restart`. Also restore `C:\shopdb-flask\instance\` if you are
rebuilding a server - it holds uploaded branding and map blueprints, which the Order matters. Restoring the database under running code that expects a
database does not. different schema is how a restore turns into a second incident.
```powershell
cd C:\shopdb-flask
# 1. Stop serving. The pool, not the whole site: other applications on this
# IIS server are unaffected.
.\shopdb-admin.ps1 stop
# 2. Restore the database. Use the dump taken closest BEFORE the problem,
# not the newest one - the newest may already contain it.
mysql -u root -p shopdb_flask < C:\ProgramData\ShopDB-Flask\backups\shopdb_flask-pre-upgrade-20260814-0730.sql
# 3. Restore instance\ if you are rebuilding a server rather than just
# reverting data. It holds uploaded branding, map blueprints, application
# images and warranty proofs - none of which are in the database.
robocopy D:\backups\instance C:\shopdb-flask\instance /MIR
# 4. Start, then prove it.
.\shopdb-admin.ps1 start
.\shopdb-admin.ps1 check -Json
```
`check -Json` reports version, publishing method, IIS and pool state, HTTP
reachability, database reachability, Python version and installed plugins. If it
passes, the restore worked; if the version it reports is not the version you
expect, see "Rolling back a release" in [UPDATES-WINDOWS.md](UPDATES-WINDOWS.md),
because a restored database and newer code is the one combination the installer
cannot fix for you.
### What is lost if `.env` is lost
`.env` is not in the database dump, and rebuilding it is not simply retyping it:
| Value | If it is lost |
|---|---|
| `SECRET_KEY`, `JWT_SECRET_KEY` | Every issued token and session becomes invalid. Users log in again; managed API tokens must be reissued, which means every collector and GE-Enforce client needs its key replaced. Recoverable, but it is a fleet-wide job. |
| `DATABASE_URL` password | Recoverable: reset the MySQL user's password and write the new one in. |
| `MYSQL_ROOT_PASSWORD` | Recoverable through MySQL's own reset procedure, which requires stopping the server. |
| `ZABBIX_TOKEN` and similar integration tokens | Reissue at the far end. Nothing else breaks. |
So back it up with the database, to somewhere as protected as the dump - it is
ACL'd to Administrators and SYSTEM on the server for the same reason. A dump
without its `.env` restores the data and locks everyone out of it.
See [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md). See [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md).

View File

@@ -80,7 +80,7 @@ owner is an admin - it cannot act with admin authority anywhere.
1. Settings > API Tokens > New Token. 1. Settings > API Tokens > New Token.
2. Check **Restrict permissions**, then in the permissions grid tick only 2. Check **Restrict permissions**, then in the permissions grid tick only
**Submit collector payloads (fleet reporting)** (the `collector.ingest` **Submit collector payloads (fleet reporting)** (the `collector.ingest`
permission under the Collector category). Name it (e.g. `wj-fleet-collector`), permission under the Collector category). Name it (e.g. `fleet-collector`),
optionally set an expiry, Create. optionally set an expiry, Create.
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the 3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
same way as the env key: the `collectorApiKey` field in per-site same way as the env key: the `collectorApiKey` field in per-site
@@ -138,7 +138,7 @@ Response body (HTTP 200), wrapped in the standard envelope
"status": "ok", "status": "ok",
"action": "created", "action": "created",
"assetid": 12345, "assetid": 12345,
"identityvalue": "WJRP2335", "identityvalue": "SHOPPC2335",
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"] "warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
}, },
"message": "computers collector created", "message": "computers collector created",
@@ -337,7 +337,7 @@ Before (no longer works - the key is ignored and the request is rejected 401):
POST /api/collector/computers?api_key=SECRET POST /api/collector/computers?api_key=SECRET
Content-Type: application/json Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" } { "hostname": "SHOPPC2335", "machinenumber": "2335" }
``` ```
After (correct): After (correct):
@@ -347,7 +347,7 @@ POST /api/collector/computers
X-API-Key: SECRET X-API-Key: SECRET
Content-Type: application/json Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" } { "hostname": "SHOPPC2335", "machinenumber": "2335" }
``` ```
PowerShell before/after: PowerShell before/after:
@@ -470,8 +470,8 @@ function Get-ShopdbCorpIPv4 {
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as # Pick the corp/AESFMA NIC IP. Same allowed-range gate as
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs. # Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
$allowedRanges = @( $allowedRanges = @(
@{ Network = '10.134.48.0'; PrefixLen = 23 }, @{ Network = '192.0.2.0'; PrefixLen = 24 },
@{ Network = '10.48.249.0'; PrefixLen = 26 } @{ Network = '198.51.100.0'; PrefixLen = 26 }
) )
function ConvertTo-Uint32([string]$ip) { function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes() $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
@@ -495,7 +495,7 @@ function Get-ShopdbCorpIPv4 {
function Send-ShopdbCollectorReport { function Send-ShopdbCollectorReport {
param( param(
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net', [string]$SiteHost = 'shopdb.example.net',
[string]$ApiKey = (Get-ShopdbCollectorApiKey), [string]$ApiKey = (Get-ShopdbCollectorApiKey),
[int]$TimeoutSec = 30, [int]$TimeoutSec = 30,
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) [string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))

View File

@@ -196,12 +196,21 @@ The batch generator at `/print/asset-label-batch/<assettype>` (reached from the
### map ### map
**SUPERSEDED as of 0.11.0.** A blueprint and its pixel dimensions are properties
of a LEVEL now, not of the site (ADR-017), because a site can have more than one
building and a building more than one floor. Manage them in
**Settings > Buildings and levels**, or through `/api/maplevels`.
These four keys are still present so that downgrading finds the blueprint it had,
and the levels migration copied their values onto the first level. Editing them
changes nothing on the map.
| Key | Default | Notes | | Key | Default | Notes |
|-----|---------|-------| |-----|---------|-------|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. | | `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintlight`. Retained for downgrade only. |
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). | | `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintdark`. |
| `map_width` | `3300` | Blueprint native width in pixels. | | `map_width` | `3300` | Superseded by `maplevels.mapwidth`. |
| `map_height` | `2550` | Blueprint native height in pixels. | | `map_height` | `2550` | Superseded by `maplevels.mapheight`. |
### integrations ### integrations

View File

@@ -8,12 +8,14 @@ the live code, not aspiration. The authoritative hook reference is
## Current version ## Current version
The plugin contract is at **0.16.0**, declared in `shopdb/__init__.py` as The plugin contract version is declared in `shopdb/__init__.py` as
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor `__contract_version__`, and the current value is in
bump is allowed to break the contract, and this project has used that latitude. [PROJECT-MAP.md](PROJECT-MAP.md), which is generated. It is pre-1.0, which under
semver means any 0.x minor bump is allowed to break the contract, and this
project has used that latitude - see 0.19.0 below.
The product release version (`__version__`, currently 0.7.0) is a separate The product release version (`__version__`) is a separate series with its own
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
Do not pin against it for compatibility - pin against `__contract_version__`. Do not pin against it for compatibility - pin against `__contract_version__`.
### 0.x history ### 0.x history
@@ -34,9 +36,19 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) | | 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) |
| 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) | | 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) |
| 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) | | 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) |
| 0.17.0 | Added `DashboardDefault` to the `shopdb.api` surface, so a plugin can resolve a display without reaching into core | additive surface (minor) |
| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) |
| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** |
| 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, 0.11.0, and The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
0.16.0; 0.12.0 through 0.15.0 are recorded in this table only. Earlier points 0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded
in this table only.
**0.19.0 is the one to read before pinning.** It is the only entry in this
series that took something away, and it shipped before it was recorded here -
which is precisely the reason to pin tight rather than to trust that a minor
bump is safe. Earlier points
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback (0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the `core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline. 0.2 baseline.

View File

@@ -1,222 +0,0 @@
# Deploy shopdb-flask to Windows IIS (MySQL 5.6)
> **Not the route for a new site.** Sister sites install from the Windows
> installer - one `.exe`, no manual IIS work: **[INSTALL-WINDOWS.md](INSTALL-WINDOWS.md)**.
>
> This is the **manual** procedure for the West Jefferson server, which was built
> by hand against its existing MySQL 5.6 and predates the installer. Keep it for
> that box.
Runbook for standing up a single-site instance on the production Windows Server
that already runs the classic ASP shopdb, using IIS + HttpPlatformHandler +
waitress, against the existing MySQL 5.6. This is the test-instance path; keep
developing on the Linux dev box and redeploy as needed.
The Docker path in `DEPLOY.md` does NOT apply on Windows (gunicorn is Linux
only, and there is no MySQL container here). This file replaces it for IIS.
Notation: `APP_ROOT` = the deploy folder, e.g. `C:\shopdb-flask`. The IIS site
physical path must be `APP_ROOT` (where `wsgi.py` lives).
## 0. Prerequisites on the box
- Python 3.14 (same minor as dev and CI). `py -3.14 --version` to confirm.
- IIS with the **HttpPlatformHandler** module:
https://www.iis.net/downloads/microsoft/httpplatformhandler
- **URL Rewrite** module (only for the optional real-client-IP rule).
- Network access to the MySQL 5.6 server.
- If the box is air-gapped, you cannot `pip install` live. On the dev box run
`pip download -r requirements.txt -d wheels\` (on a matching
Windows/Python target, or use `--platform` wheels), copy `wheels\` over, and
install with `pip install --no-index --find-links wheels\ ...`.
## 1. Copy the code
Copy the repo to `APP_ROOT`, INCLUDING `frontend/dist` (the built SPA the API
serves). Build it on dev first if stale:
```bash
# on the dev box
cd frontend && npm run build # produces frontend/dist
```
Ship `frontend/dist` with the code (Node is not needed on the prod box).
## 2. Python venv + dependencies
```powershell
cd C:\shopdb-flask
py -3.14 -m venv venv
venv\Scripts\python -m pip install --upgrade pip
venv\Scripts\pip install -r requirements.txt
```
The DB driver is `pymysql` (pure Python) so no C compiler / MySQL client libs
are needed. `waitress` is the WSGI server and ships in `requirements.txt`
(unlike gunicorn, which the Docker image installs separately).
## 3. Prepare MySQL 5.6 (the utf8mb4 gotcha)
MySQL 5.6 defaults cannot index utf8mb4 VARCHAR(255) columns (767-byte prefix
limit) and often defaults the server charset to latin1. The schema is utf8mb4,
so the server needs Barracuda + large-prefix, made durable in `my.ini` under
`[mysqld]`, then restart the MySQL service:
```ini
[mysqld]
innodb_file_per_table = 1
innodb_file_format = Barracuda
innodb_large_prefix = 1
```
Then create the database as utf8mb4 and a least-privilege app user:
```sql
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'shopdb'@'%' IDENTIFIED BY 'CHANGE_ME';
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
FLUSH PRIVILEGES;
```
Without the `[mysqld]` flags, `flask db upgrade` fails with error 1071
("Specified key was too long"). The migration chain emits `ROW_FORMAT=DYNAMIC`
per table (see `migrations/env.py`), which fits the 3072-byte prefix those
flags unlock.
## 4. Configure secrets and connection (.env)
Create `APP_ROOT\.env` (loaded by `wsgi.py` via `load_dotenv()`). Keep secrets
here, not in `web.config`. Lock the file's ACLs to the IIS app-pool identity +
administrators.
```
FLASK_ENV=production
SECRET_KEY=<64+ random chars>
JWT_SECRET_KEY=<another 64+ random chars>
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
CORS_ORIGINS=https://<the site's own hostname>
```
`ProductionConfig.validate()` refuses to boot if any of `SECRET_KEY`,
`JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS` is missing or left at a dev
default. `CORS_ORIGINS` is the browser origin users hit (the IIS binding).
Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`.
## 5. Initialize schema, data, plugins, admin
Run from `APP_ROOT` with the venv active and `.env` present:
```powershell
$env:FLASK_APP="shopdb"
venv\Scripts\flask db upgrade
venv\Scripts\flask seed reference-data
# Install the plugins this site tracks (registry lives in the gitignored
# instance/plugins.json, so a fresh box starts with none installed). Run
# `flask plugin list` to see the current bundled set; the 13 bundled plugins are
# computers, employees, geenforce, knowledgebase, machines, measuringtools,
# network, notifications, printedparts, printers, slides, usb, warranty. Install
# only the ones this site wants:
venv\Scripts\flask plugin list
venv\Scripts\flask plugin install machines
venv\Scripts\flask plugin install printers
venv\Scripts\flask plugin install computers
venv\Scripts\flask plugin install network
venv\Scripts\flask plugin install notifications
venv\Scripts\flask plugin install usb
venv\Scripts\flask plugin install knowledgebase
venv\Scripts\flask plugin install slides
venv\Scripts\flask plugin install employees
venv\Scripts\flask plugin upgrade-all
# First admin (password is generated and printed once):
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
```
Cleaner than a hand list: declare the set once in a site profile and apply it:
```powershell
venv\Scripts\flask plugin apply-profile deploy\site-profile.json # install + enable the chosen set, in dependency order
venv\Scripts\flask plugin upgrade-all
venv\Scripts\flask plugin prune-schema --yes --force # FIRST PROVISIONING ONLY - see the warning below
```
(Alternatively copy the dev box's `instance/plugins.json` to `APP_ROOT\instance\`
to reproduce the exact set, then just run `flask plugin upgrade-all`.)
> **`prune-schema --force` is for first provisioning only.** It drops the tables
> of plugins this site did not install *even when they hold rows*. On a site that
> already has data, run `flask plugin prune-schema` with no flags first and read
> what it says it would drop. Re-running with `--force` after a feature has been
> used deletes that feature's records with no prompt and no backup.
## 6. Create the IIS site + web.config
This describes the own-site method (the app gets its own IIS site + port). To
mount the app at a subpath under an existing site instead (e.g.
`https://<host>/ops/` sharing the classic site's binding and cert), see
**docs/INSTALL-WINDOWS-IIS.md section 7b**: same web.config, but the site is a
`New-WebApplication` under the parent, `MOUNT_PATH=/ops` is set (web.config or
`.env`), and the frontend is built with `VITE_BASE_PATH=/ops/`.
1. In IIS Manager, add a new **Site** (separate from the classic ASP site):
- Physical path: `APP_ROOT`
- Binding: a free port or a dedicated hostname (e.g. `https` 443 with the
facility cert, or `http` on a test port like 8081 to start).
- App pool: No Managed Code, and an identity that can read `APP_ROOT`.
2. Copy `deploy\windows\web.config` to `APP_ROOT\web.config` and edit the paths
(`C:\shopdb-flask` -> your `APP_ROOT`). It launches
`waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` and sets
`FLASK_ENV=production` + `PYTHONPATH`.
3. Create `APP_ROOT\logs` for the HttpPlatform stdout log.
4. **Unlock the handler sections** (locked server-wide by default; without this
IIS returns **HTTP 500.19** "section cannot be used at this path"):
```powershell
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/handlers
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/httpPlatform
```
5. Grant the app-pool identity read/execute on `APP_ROOT` and modify on
`APP_ROOT\logs` (e.g. `icacls APP_ROOT /grant "IIS AppPool\<pool>:(OI)(CI)RX" /T`).
6. Recycle the app pool / restart the site.
TLS terminates at the IIS binding. The `X-Forwarded-For` URL Rewrite rule in the
web.config (real client IP for audit logs / kiosk visitor-location) is
**commented out by default** because it needs the URL Rewrite module - with it
active but URL Rewrite absent, IIS returns HTTP 500.19. Install URL Rewrite and
uncomment the `<rewrite>` block to enable it.
## 7. Smoke test
```powershell
# SPA loads:
curl.exe -k https://<host>/ # returns index.html
# API rejects an empty login with a validation error (health signal):
curl.exe -k -X POST https://<host>/api/auth/login -H "Content-Type: application/json" -d "{}"
# expect JSON containing VALIDATION_ERROR
```
Then log in through the browser as the admin from step 5 and confirm the
dashboard renders.
## 8. Redeploying as dev advances
Because this is a test instance you keep iterating on:
1. Pull/copy new code to `APP_ROOT` (rebuild `frontend/dist` on dev if the UI
changed).
2. `venv\Scripts\pip install -r requirements.txt` (if deps changed).
3. `venv\Scripts\flask db upgrade` (if new migrations).
4. Recycle the app pool.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Site 502 / process won't start | Check `APP_ROOT\logs\httpplatform*`. Usually a bad `processPath`, missing waitress, or `wsgi:app` not importable (set `PYTHONPATH`). |
| Boots but SQL echoes / debug on | `FLASK_ENV` not `production` (web.config env var or `.env`). |
| `flask db upgrade` error 1071 | MySQL 5.6 `[mysqld]` flags in step 3 not applied / server not restarted. |
| ConfigError on boot | A required var (SECRET_KEY / JWT_SECRET_KEY / DATABASE_URL / CORS_ORIGINS) missing or left at a dev default in `.env`. |
| Login works, CORS errors in browser | `CORS_ORIGINS` does not match the exact origin (scheme + host + port) the browser used. |
| Audit logs show 127.0.0.1 | Expected without the URL Rewrite X-Forwarded-For rule (step 6). |

135
docs/EVENTSAVER.md Normal file
View File

@@ -0,0 +1,135 @@
# EventSaver
A rotating-image screensaver for shop-floor PCs. Idle bays show the slides
managed in ShopDB, so a safety notice or a shift message reaches the floor
without anyone walking to each machine.
It is a small C# WinForms screensaver (`.scr`), not part of the ShopDB server.
It is documented here because ShopDB serves its content and because a site
adopting the fleet tools needs to know how it is configured.
For how to deploy it at a new site, see
[ADOPTING-AT-ANOTHER-SITE.md](ADOPTING-AT-ANOTHER-SITE.md). This page is what it
is, how it decides what to show, and how to diagnose it.
## Two sources, one of them preferred
EventSaver reads `EventSaver.ini` from the directory it lives in, **on every
launch**. Retargeting it never needs a recompile.
```ini
# HTTP mode: pull the playlist from ShopDB and cache it locally.
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
# Folder mode: used only when url is blank. SMB or local path.
# folder=\\fileserver\shopfloor\tv
interval=10 # seconds per image, unless a slide carries its own time
shuffle=0 # 1 = random order, 0 = ordered
fadems=600 # crossfade length in ms, reserved (v1 hard-cuts)
```
**HTTP mode is the one to use.** Slides are managed in ShopDB (Slides), the
feed is public so the screensaver needs no credentials, and each PC caches what
it fetched under its own `LocalApplicationData`. A bay that cannot reach the
server keeps showing the last set it saw instead of going black - which matters,
because the failure would otherwise be visible to the whole floor.
**Folder mode** predates the server and stays as a fallback for a site with no
ShopDB instance yet, or for content nobody wants in the database.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
> no source to read and the screensaver shows nothing. That is deliberate: the
> compiled-in fallback used to be the path of the site it was first built for, so
> a missing ini silently pointed a new site at someone else's file server. It is
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
> the ini.
## What decides the running order
| Situation | Behaviour |
|---|---|
| No `order.txt` | Every image shows, sorted by filename. Number them `001_`, `002_` to sequence. |
| `shuffle=1` | Random order, ignored when a playlist is present. |
| `order.txt` present | ONLY the listed files show, in the listed order, looped. |
`order.txt` is the strict-sequence option and is the whole playlist - a file not
listed does not appear. Per-slide duration is appended with a pipe:
```
# Lines starting with # or ; are ignored.
001_welcome.jpg
002_safety_week.jpg|15
003_quality_board.jpg
```
In HTTP mode you do not write `order.txt` by hand: ShopDB's feed carries the
order and each slide's own seconds, and EventSaver writes the equivalent
`order.txt` into its cache so the same playlist logic runs either way.
A running screensaver rescans about every 30 seconds, so a slide change reaches
the floor without touching any PC.
## Command-line switches
Windows calls a screensaver with these, and EventSaver answers all three:
| Switch | Meaning |
|---|---|
| `/s` | Show fullscreen. What Windows uses on idle. |
| `/c` | Configuration. Reports the resolved source and cache directory, and points at the ini. |
| `/p <hwnd>` | Preview pane. Deliberately a no-op - it keeps the Settings dialog happy without drawing a thumbnail nobody looks at. |
`EventSaver.scr /c` is the first diagnostic: it says which source it resolved
and where it is caching, which answers most "why is it showing the wrong thing"
questions immediately.
## Where it should and should not run
A screensaver over a live display is a defect, not a feature. The reference
fleet excludes:
- **Kiosks and lobby displays**, which show a live dashboard.
- **Metrology bays** - CMM, Genspect, Keyence, wax-trace - where an operator
watches a measurement run.
- **Bays that sleep**, where the screensaver never wins anyway.
Targeting is per PC type in the GE-Enforce manifest, or by group assignment in
Intune. Decide it deliberately: the cost of getting it wrong is a screensaver
covering something someone needed to see.
## Building it
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
compiles with the in-box .NET Framework compiler on any Windows 10 or 11
machine:
```
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
/target:winexe /out:EventSaver.scr ^
/reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll ^
EventSaver.cs
```
Ship `EventSaver.scr` and `EventSaver.ini` together. A `.scr` is an executable
with a different extension, so it goes to `C:\Windows\System32\`.
## Diagnosing it
| Symptom | Cause to check first |
|---|---|
| Blank screen, no images | Source unreachable and cache empty. Run `/c` and open the `url` in a browser on that PC. |
| Old slides only | Feed reachable at first run, not since. The cache is doing its job; fix the network path. |
| Wrong content | Pointing at another surface, or another site's server. `/c` reports which. |
| Local ini edits revert | Correct behaviour if the config is enforced by hash. Edit the copy on the share and update its `DetectionValue`. |
| Screensaver never starts | Per-user setting missing. Check `HKCU:\Control Panel\Desktop` for `SCRNSAVE.EXE`, `ScreenSaveActive` and `ScreenSaveTimeOut`. |
The feed is a plain public endpoint, so it can always be checked from the PC
itself:
```
https://shopdb.example.net/api/slides/feed?surface=shopfloor
```
An empty `slides` array means no slides have been uploaded for that surface -
the PC is configured correctly and there is nothing to show.

118
docs/FLEET-ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,118 @@
# How the pieces fit together
ShopDB is a server. Around it are four things that run on shop-floor PCs, each
documented in its own page, and each of those pages assumes you already know how
it relates to the others. This page is that relationship.
## The shape
```
+-------------------------------+
| ShopDB (one per site) |
| Flask API + Vue UI + MySQL |
+-------------------------------+
^ ^ ^ |
reports | | | | serves
(X-API-Key) | | | v
+----------------+ | | | +------------------+
| asset reporter |---+ | | | kiosks, displays |
| on every PC | | | | (browser, HTTPS) |
+----------------+ | | +------------------+
| |
+----------------+ | | +------------------+
| backup |----------+ +----| EventSaver |
| collectors | config revisions | screensaver |
+----------------+ +------------------+
pulls the slide feed
+---------------------------+
| GE-Enforce client | pulls its manifest, applies it,
| scheduled task, SYSTEM | reports the cycle back
+---------------------------+
^ |
| v
manifest the PC's
(share or actual state
HTTPS)
```
Every arrow is one direction on purpose. **Nothing on the server reaches out to
a PC.** The server holds state and answers questions; the fleet asks. That is
what lets a bay behind a firewall, asleep, or on a different network segment be
merely out of date rather than broken - and it is why every one of these tools
degrades to "stale" instead of "failed".
## What each piece is for
| Piece | Direction | What it does | Page |
|---|---|---|---|
| **Asset reporter** | PC to server | Says what this PC is: hostname, serial, type, user, IPs, machine number. Creates the PC record the rest of the system hangs off. | [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) |
| **GE-Enforce client** | both | Pulls the manifest for this PC's type, makes the PC match it, reports what it installed, skipped or failed. | [GE-ENFORCE](GE-ENFORCE.md) |
| **Backup collectors** | PC to server | Post machine configuration (NTLARS/DNC, CMM, part marker, UDC) as revisions, so a controller can be restored and a change can be seen. | [BACKUP-RESTORE](BACKUP-RESTORE.md) |
| **EventSaver** | PC to server | Pulls the slide playlist and shows it when a bay is idle. | [EVENTSAVER](EVENTSAVER.md) |
| **Kiosks and displays** | PC to server | A browser in kiosk mode on a ShopDB route. No client software beyond the browser. | [GE-ENFORCE-DISPLAY](GE-ENFORCE-DISPLAY.md) |
## The three ways a PC talks to the server
**The collector API**, `POST /api/collector/*`, with `X-API-Key`. Used by the
asset reporter and the backup collectors. The key is a managed token scoped to
`collector.ingest` and nothing else, so one recovered off a bay cannot read the
asset register. Idempotent by design: the same report twice changes nothing.
See [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md) and ADR-006.
**The GE-Enforce API**, `/api/geenforce/*`, with a token scoped to the fetch
scope. Manifests out, cycle reports back. Most PC types still take their
manifest from the SMB share and use this only to report; the display cohort
takes everything over HTTPS, because those PCs have no share.
**Plain public reads.** The slide feed and the kiosk dashboard routes need no
credentials at all, because a screensaver and a wall display have nowhere to
keep one. They are read-only and carry nothing a floor visitor should not see.
## Where the credentials live
Three documents each named a different place, which is what happens when a
credential is described from whichever end the author was working on. Both ends
are here.
| Credential | On the server | On the PC | Rotate by |
|---|---|---|---|
| Collector key | A managed token scoped to `collector.ingest` only. `COLLECTOR_API_KEY` in `.env` also works and is simpler for a pilot, but cannot be revoked per fleet. | `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` | Replacing the token file the fleet reads from, so every PC picks it up on its next cycle. Never put it in the manifest - manifests sync broadly. |
| GE-Enforce fetch token | A managed service token scoped to the GE-Enforce fetch scope | `HKLM:\SOFTWARE\GE\ShopDB` value `ApiToken` | Same path as the collector key |
| Server URL | n/a | `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl` | Setting it once at install; nothing should overwrite a bay's own value |
| Application secrets | `.env`: `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, integration tokens | n/a | See [BACKUP-RESTORE](BACKUP-RESTORE.md) - losing these invalidates every issued token |
Two rules behind that table. A shop-floor PC is not a trusted place, so what it
holds is scoped to exactly what it does: a collector key that leaks buys the
reader the ability to file a PC report, not to read the asset register. And a
credential is delivered, never typed - a value that has to be entered per machine
is a value that is wrong on some machine.
## What owns what
The question that decides most arguments: **who is allowed to change this?**
- **ShopDB owns the record.** What a PC is, what it drives, what it should have.
- **The manifest owns the PC's configuration.** If GE-Enforce enforces a value,
editing it on the PC is temporary - the next cycle puts it back. That is the
feature, and it is the single most common surprise.
- **The PC owns its own identity.** Serial, hostname, installed software: the
server records what it is told rather than deciding.
Where two systems both converge state - GE-Enforce and Intune DSC, say - decide
which owns which layer before deploying both, or they will fight in a way that
is very hard to see. [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md) has
the recommended split.
## When something looks wrong
| Symptom | The piece to look at first |
|---|---|
| A PC is missing from ShopDB entirely | Asset reporter: never ran, or no collector key |
| A PC is there but its details are old | Asset reporter: stopped running. The dashboard's "PCs not reporting" card is this |
| A PC has the wrong software | GE-Enforce: check its last cycle report, then its manifest scope |
| A bay shows the wrong thing on screen | EventSaver config, or the kiosk dispatcher - both are per PC type |
| A machine has no recent config backup | Backup collector on the controlling PC. "Backups that have stopped" on the dashboard |
| Two PCs claim one machine number | Neither: it is a claim in progress, or a typo. The dashboard card names both |
Each row is a different program on a different schedule. Knowing which one to
open is most of the diagnosis.

135
docs/FLOOR-MAP.md Normal file
View File

@@ -0,0 +1,135 @@
# The floor map: buildings, levels and marker positions
The map shows where things physically are. This page is for whoever maintains it:
loading a floor plan, adding a second floor or a second building, and moving the
markers when the plan changes.
The reasoning behind the model is in
[ADR-017](adr/ADR-017-buildings-and-levels.md). This page is the operator's view.
## The model in three sentences
A **building** holds one or more **levels**. A level owns a floor-plan image (one
per theme) and the pixel size of that image. A marker's position is an X and Y in
the pixels of ONE level, so every position names the level it belongs to.
That last point is the whole design. Coordinates alone are meaningless: `1200,900`
is a spot on the ground floor and a different spot on the second floor. A marker
whose level is unknown shows as **level unknown** and is not drawn, because
drawing it on the default plan would look completely correct while pointing at
the wrong part of the building.
## Loading a floor plan
**Settings > Buildings and levels.**
1. Add a building if you do not have one. A single-building site needs exactly
one, and it can be called anything.
2. Add a level. Give it a name people use out loud - "Ground floor", "Mezzanine",
"Second floor" - and a sort order that puts the list in the order someone
would walk it. Name and order are separate on purpose: levels are not reliably
numbered, and leaving gaps in the order lets you insert one later without
renumbering everything.
3. Upload the plan, per theme. A light-theme image is enough to start; if there
is no dark version, the light one is used in both.
**The level takes its pixel size from the image you upload, while the level is
still empty.** Once markers are on it, an upload whose size does not match reports
the mismatch and changes nothing. That is deliberate - see below.
Supported: PNG, JPEG, GIF, WEBP and SVG. An SVG is served with headers that stop
it executing as script, so a plan exported from a CAD tool is safe to use.
## Placing markers
Two ways, both fine:
- **One at a time**, from the asset's own form: the map-position picker opens on
the level the marker is already on, and you can switch level inside the picker.
The saved position states which drawing it is on.
- **Many at once**, in the map editor. Filter to what you are working through -
unplaced, needs-review, or one level - search for a specific thing, place it,
and confirm. Placing a marker by hand counts as confirming it.
A location can carry a position too, which acts as the default for assets in it.
## When the floor plan changes
This is the case that needs care, and the reason the tooling exists.
**A new plan of the SAME dimensions**: upload it and you are done. Markers keep
their coordinates and still line up.
**A new plan of DIFFERENT dimensions, or a real move**: the coordinates are pixels
of the old image, so every marker is now in the wrong place relative to the
drawing. Do NOT fix this by editing the level's width and height - that just
reinterprets every existing position against a new scale.
Use **Recalibrate** in the map editor:
1. Pick two or more **landmarks** - features you can find on both the old and the
new plan. Corners of the building, a doorway, a column. Two is the minimum;
three or four spread across the plan is better.
2. For each, give the old coordinates and the new ones.
3. Review the **dry run**. It lists every marker's old and new position and flags
anything that would land off the drawing. Nothing has changed yet.
4. Apply it. The previous positions are snapshotted first.
The transform is worked out separately for the X and Y axes from your landmarks,
and never guessed from the image dimensions. That matters in the exact case this
was built for: a plan that gained a floor below it got taller without anything
being rescaled, and a size-derived scale would have stretched everything
vertically.
After a recalibration every affected marker is marked **needs review**, because a
transform is a good starting guess and nothing in the coordinates can tell you
which machines actually moved. Work the needs-review filter, correct what is off,
and confirm as you go.
## Undo
Every bulk change snapshots the positions first, and a restore snapshots before
it restores, so an undo is itself undoable. The history is in the map editor:
what caused each snapshot, when, and whether it has been restored. A restore puts
back the level and the review state, not only the coordinates.
Assets deleted since a snapshot are reported and skipped rather than failing the
whole restore.
## Adding a second building or a second floor
Add it, upload its plan, then move the markers that belong on it. In the map
editor, markers on other levels are badged, so you can see what still needs
moving.
Do not stack two floors onto one tall image. It looks simpler and it makes the
level implicit in the Y coordinate - "above 2550 means upstairs" - which nothing
can validate and which breaks the moment either plan is redrawn.
## Things that are not levels
- **Locations** are where something is in words ("Cell 4", "Gage lab"). A level is
which drawing. A location can name a level, but they are different things.
- **Business units** are organisational, not physical.
## Known limits
- The printer-installer map (the standalone program that installs printers by
clicking a floor plan) has one plan compiled into it and does not know about
levels. It is accurate for the default level only. See
[PRINTER-INSTALLER](PRINTER-INSTALLER.md) section 6.
- The first-run setup wizard still asks for one site-wide blueprint; those
settings no longer drive the map. Use Settings > Buildings and levels.
- Map PDF export covers the level you are looking at.
## For integrators
`GET /api/maplevels` is unauthenticated - the printer installer draws a plan
before anyone logs in - and returns every building with its levels, each level's
blueprint paths, native size and marker count, plus which level is the default.
Writes require an admin. Repositioning is `/api/mappositions/*` and needs
`assets.edit`. Full parameters in [API-REFERENCE](API-REFERENCE.md) and the
generated spec at `/api/docs`.
If you import positions from another system, send `levelid` with every
coordinate - see [IMPORT-API](IMPORT-API.md).

View File

@@ -63,14 +63,17 @@ being edited in shopdb, so a half-finished edit can never reach a PC.
Each enforcement cycle, POST the result (best-effort; a failed report never Each enforcement cycle, POST the result (best-effort; a failed report never
fails the cycle): fails the cycle):
`appliedversion` is the published version the client actually ran, not the
latest one available.
``` ```
POST /api/geenforce/report POST /api/geenforce/report
X-API-Key: <token> X-API-Key: <token>
Content-Type: application/json Content-Type: application/json
{ {
"hostname": "WJCMM01", "hostname": "CMMPC01",
"scopename": "gea-shopfloor-cmm", "scopename": "gea-shopfloor-cmm",
"appliedversion": 3, // the published version you actually ran "appliedversion": 3,
"enforcerversion": "2.6", "enforcerversion": "2.6",
"counts": { "installed": 1, "skipped": 3, "failed": 0, "filtered": 2 }, "counts": { "installed": 1, "skipped": 3, "failed": 0, "filtered": 2 },
"results": [ "results": [

View File

@@ -69,7 +69,7 @@ selected:
``` ```
powershell -ExecutionPolicy Bypass -File Install-GEEnforce.ps1 ` powershell -ExecutionPolicy Bypass -File Install-GEEnforce.ps1 `
-PCType gea-shopfloor-cmm -MachineNumber 0615 -CmmVersion 2019 ` -PCType gea-shopfloor-cmm -MachineNumber 0615 -CmmVersion 2019 `
-ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" ` -ShareRoot \\server\share\dt\shopfloor -Site "the reference site" `
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx ` -ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
-EngineSource \\server\share\dt\shopfloor\common -EngineSource \\server\share\dt\shopfloor\common
``` ```

View File

@@ -113,7 +113,7 @@ Run keys, and every per-user and common Startup folder, matching by legacy name
and by the old URLs, then kills any old-URL Edge. The kiosk shortcut it writes is and by the old URLs, then kills any old-URL Edge. The kiosk shortcut it writes is
a direct Edge shortcut (no launcher or VBS). The fix ships by re-publishing this a direct Edge shortcut (no launcher or VBS). The fix ships by re-publishing this
code-authored scope (`seed_display_scope(publish=True)`), not an import-share. code-authored scope (`seed_display_scope(publish=True)`), not an import-share.
`pxe-images/github/find-legacy-kiosk-autostart.ps1` is a read-only locator for `<imaging-share>/github/find-legacy-kiosk-autostart.ps1` is a read-only locator for
stragglers. stragglers.
## Self-sufficient: displays do NOT inherit common ## Self-sufficient: displays do NOT inherit common

View File

@@ -36,7 +36,7 @@ site's schema. So the import is split in two layers:
2. **A per-site loader is thin glue.** It reads *your* source database and POSTs 2. **A per-site loader is thin glue.** It reads *your* source database and POSTs
to those endpoints. Nobody runs another site's loader - you copy the pattern. to those endpoints. Nobody runs another site's loader - you copy the pattern.
The West Jefferson loader in `scripts/site_imports/wjf/` is reference The the reference site loader in `scripts/site_imports/wjf/` is reference
implementation #1. Read it alongside this guide. implementation #1. Read it alongside this guide.
## The shape of a loader ## The shape of a loader
@@ -89,7 +89,7 @@ onboarding path.
UI spot-check (log in, eyeball the lists / map / a detail page). UI spot-check (log in, eyeball the lists / map / a detail page).
5. Only then point a real instance at the imported database. 5. Only then point a real instance at the imported database.
## What the WJ loader demonstrates ## What the reference loader demonstrates
- Fanning one legacy "machine" table out to the flask asset types - Fanning one legacy "machine" table out to the flask asset types
(computer/machine/network/measuring-tool) by a routing rule, with the (computer/machine/network/measuring-tool) by a routing rule, with the

View File

@@ -1,7 +1,7 @@
# Import API: migrating the classic ASP shopdb through HTTP alone # Import API: migrating the classic ASP shopdb through HTTP alone
This is the operator manual for importing the legacy Classic-ASP shopdb database This is the operator manual for importing the legacy Classic-ASP shopdb database
(`prodscratch` on the dev MySQL container) into shopdb-flask using ONLY the HTTP (`legacyshopdb` on the dev MySQL container) into shopdb-flask using ONLY the HTTP
API. No direct writes to the `shopdb_flask` database are needed or wanted: every API. No direct writes to the `shopdb_flask` database are needed or wanted: every
row is created through a documented endpoint so authorization, validation, row is created through a documented endpoint so authorization, validation,
auditing, and plugin hooks all run exactly as they do for a human operator. auditing, and plugin hooks all run exactly as they do for a human operator.
@@ -190,12 +190,28 @@ Common `machines` columns -> core Asset fields (same for every target plugin):
| `serialnumber` | `serialnumber` | | | `serialnumber` | `serialnumber` | |
| `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses | | `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses |
| `businessunitid` | `businessunitid` | remap via imported business units | | `businessunitid` | `businessunitid` | remap via imported business units |
| `mapleft` | `mapx` | | | `mapleft` | `mapx` | pixels of a LEVEL; send `levelid` with it |
| `maptop` | `mapy` | | | `maptop` | `mapy` | as above |
| n/a | `levelid` | which floor plan the coordinates belong to (ADR-017) |
| `machinenotes` | `notes` | | | `machinenotes` | `notes` | |
| `dateadded` | `createddate` | import mode only | | `dateadded` | `createddate` | import mode only |
| `lastupdated` | `modifieddate` | import mode only | | `lastupdated` | `modifieddate` | import mode only |
**A position needs its level.** `mapx`/`mapy` are absolute pixels in the native
coordinate space of ONE level (ADR-017), so an import that sends coordinates
without `levelid` produces markers the map renders as "level unknown" rather than
drawing them on the default level. A legacy source that predates levels has one
floor plan, so the right value is that site's default level:
```bash
curl -s http://server/api/maplevels | python -c "import json,sys; print(json.load(sys.stdin)['data']['defaultlevelid'])"
```
Send that `levelid` on every row carrying coordinates. If an import already ran
without it, the levels migration's backfill is not retroactive - assign the rows
with `POST /api/mappositions/positions`, which requires `levelid` per row for
exactly this reason.
Per-plugin extension fields: Per-plugin extension fields:
- **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`, - **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`,
@@ -365,7 +381,7 @@ dispositions below are DECIDED, not open questions.
### DECIDED: skip (structure only or low value) ### DECIDED: skip (structure only or low value)
- **`compliance`, `compliancescans`** - 0 rows in `prodscratch`. No data to - **`compliance`, `compliancescans`** - 0 rows in `legacyshopdb`. No data to
migrate; a future compliance plugin would own them. Skip. migrate; a future compliance plugin would own them. Skip.
- **`ednc_installations`, `ednc_logs`** - 0 rows, and they belong to the eDNC - **`ednc_installations`, `ednc_logs`** - 0 rows, and they belong to the eDNC
tooling rather than the asset catalog. Skip. tooling rather than the asset catalog. Skip.
@@ -482,7 +498,7 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
client = ImportClient(args.token, dryrun=args.dry_run) client = ImportClient(args.token, dryrun=args.dry_run)
# read legacy rows from prodscratch (read-only) and call the import_* fns # read legacy rows from legacyshopdb (read-only) and call the import_* fns
# in the order of section 2, keeping a legacy-id -> new-id map as you go. # in the order of section 2, keeping a legacy-id -> new-id map as you go.
``` ```
@@ -497,7 +513,7 @@ need it to remap foreign keys (a machine's `businessunitid`, a checkout's
After each phase, compare counts. Legacy side (read-only), for example: After each phase, compare counts. Legacy side (read-only), for example:
```bash ```bash
docker exec dev-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" prodscratch \ docker exec dev-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" legacyshopdb \
-e "SELECT COUNT(*) FROM vendors;" -e "SELECT COUNT(*) FROM vendors;"
``` ```

View File

@@ -125,6 +125,11 @@ foreach ($p in "computers","machines","network","notifications","printers","know
venv\Scripts\flask plugin install $p venv\Scripts\flask plugin install $p
} }
# EVERY plugin owns its own Alembic chain (ADR-008), and `flask db upgrade`
# above ran only the core one. Skipping this is how a new column reaches
# production as a 1054 "Unknown column" error on the page that uses it.
venv\Scripts\flask plugin upgrade-all
# first admin (password generated + printed once - store it): # first admin (password generated + printed once - store it):
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
``` ```
@@ -237,6 +242,23 @@ each gets its own site, app pool, port, and venv.
--- ---
## Redeploying a hand-built server
A hand-built server has no installer to run, so an update is done by hand in the
same order the installer would:
1. Copy the new code to the application root, rebuilding `frontend/dist` first
if the UI changed.
2. `venv\Scripts\pip install -r requirements.txt`, if dependencies changed.
3. `venv\Scripts\flask db upgrade` for the core chain, then
`venv\Scripts\flask plugin upgrade-all` for the plugin chains. Both, every
time - the second is the one people skip, and it surfaces days later as a
1054 "Unknown column".
4. Recycle the application pool.
Take a database backup before step 3. The installer does this automatically and
restores from it when a migration fails; by hand, it is yours to remember.
## Troubleshooting ## Troubleshooting
| Symptom | Cause / fix | | Symptom | Cause / fix |

View File

@@ -5,7 +5,7 @@ and get a working application. Nothing here needs an internet connection, and yo
do not need to know IIS, Python or MySQL. do not need to know IIS, Python or MySQL.
If you are looking after an existing hand-built server, see If you are looking after an existing hand-built server, see
[DEPLOY-WINDOWS-IIS.md](DEPLOY-WINDOWS-IIS.md) instead - that is the manual [INSTALL-WINDOWS-IIS.md](INSTALL-WINDOWS-IIS.md) instead - that is the manual
procedure, and the installer will not adopt a server it did not build. procedure, and the installer will not adopt a server it did not build.
--- ---
@@ -27,6 +27,47 @@ Installing IIS, if it is missing (this needs no internet):
Install-WindowsFeature -Name Web-Server -IncludeManagementTools Install-WindowsFeature -Name Web-Server -IncludeManagementTools
``` ```
### Sizing the server
Small, and the reasons are worth stating so a site does not over-provision.
ShopDB serves one plant's own staff, not the internet: a busy instance handles a
few dozen concurrent users and a fleet reporting a few hundred PCs a day, and
both are trivial loads. What actually consumes resources is MySQL and the
uploads.
| | Minimum | Comfortable | Why |
|---|---|---|---|
| Disk | 5 GB free (the installer refuses below this) | 40 GB | The application and Python are about 1 GB. The rest is MySQL, config-backup revisions, and uploads - branding, floor-plan blueprints, application installers and warranty proofs, which are the only ones that grow without bound. |
| RAM | 4 GB | 8 GB | MySQL wants most of it. The application itself is a Python worker per request. |
| CPU | 2 cores | 4 cores | Reports and the map are the heaviest reads and are cached. |
A VM is entirely normal. The one thing worth insisting on is that the database
directory and the backup directory are not on the same disk as anything you
would be sad to fill up.
### Getting the installer
The `.exe` is a release asset on the GitHub repository, not a file in it - it is
around 240 MB, which is well past what a repository accepts and well inside what
a release asset does. Take it from the release page for the version you want,
and keep it: you need the previous one to roll a release back, and you cannot
download it from your own server.
Every build is stamped with its version and ships a CycloneDX SBOM onto the
server, so `shopdb-admin.ps1 verify -Path <name>` answers "does this server
carry component X" without a network call - which is the question that arrives
on a Friday afternoon after a CVE announcement.
### If you get stuck
Raise an issue on the repository. Include the output of
`shopdb-admin.ps1 check -Json`, which reports version, IIS and pool state,
database reachability, Python version and installed plugins, and contains no
secrets - it is the single most useful thing you can attach, and it is designed
to be pasted.
---
The installer **checks all of this before it changes anything**, and it will not The installer **checks all of this before it changes anything**, and it will not
let you continue until the check passes. You do not have to get it right first let you continue until the check passes. You do not have to get it right first
time. time.
@@ -90,7 +131,7 @@ latin1 database mangles any non-ASCII text you store.
is not already running a website. Simplest. is not already running a website. Simplest.
- **Under this server's existing address** - `http://yourserver/shopdb/`. Choose - **Under this server's existing address** - `http://yourserver/shopdb/`. Choose
this when the server already serves something else and you do not want a second this when the server already serves something else and you do not want a second
port or a new DNS name. This is what West Jefferson uses. port or a new DNS name. This is what the reference site uses.
You cannot change your mind later without re-running the installer, because the You cannot change your mind later without re-running the installer, because the
web interface has the address compiled into it. web interface has the address compiled into it.

View File

@@ -102,6 +102,35 @@ venv/bin/python -m pytest ../wjsf-shipping/tests
Edits in `../wjsf-shipping` are picked up on the next framework restart, because Edits in `../wjsf-shipping` are picked up on the next framework restart, because
the symlink points back at your working tree. the symlink points back at your working tree.
### If your plugin owns tables, step 5 needs a change to the framework
This is the one place an external plugin is not self-contained, and it is better
said plainly than discovered at the first migration.
`flask plugin upgrade-all` builds each plugin's metadata from
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`, and raises rather
than guessing when a plugin has no entry:
```
RuntimeError: PLUGIN_TABLE_OWNERS has no entry for plugin 'shipping'.
Update shopdb/plugins/alembic_template.py.
```
So a plugin that owns tables needs its table names registered in that dictionary
in the framework repository. It is deliberate - the registry is what stops one
plugin's migration touching another's tables, and `tests/test_plugin_migrations.py`
tests it - but it does mean a table-owning external plugin is a two-repository
change: yours, plus a one-line addition upstream.
Two ways to live with it:
- **Send the entry upstream.** One line in `PLUGIN_TABLE_OWNERS` plus one in
`EXPECTED_HEAD_REVISION`, and your plugin is a normal citizen from then on.
- **Own no tables.** A plugin that stores nothing of its own - a report, a
dashboard card, a settings page over existing models - has nothing to
register and stays entirely in your repository. More plugins fit this than
expect to.
## core_version pinning for sister sites ## core_version pinning for sister sites
The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break
@@ -109,20 +138,22 @@ the contract, and this project uses that latitude (see the history in
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that
admits only the contract minor you tested against, not the whole 0.x line. admits only the contract minor you tested against, not the whole 0.x line.
The current contract version is declared in `shopdb/__init__.py`: The current contract version is declared in `shopdb/__init__.py` as
`__contract_version__`, and is reported in
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code. Read it
there - a version typed into this page is wrong within a fortnight, and a
plugin pinned to a stale one is refused at startup.
```python Pin a tight range in your `manifest.json`, per ADR-002 (pip-style `>=,<`),
__contract_version__ = '0.13.0' admitting only the contract minor you tested against. With the contract at
``` 0.19.0 that would be:
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
```json ```json
{ {
"name": "shipping", "name": "shipping",
"version": "1.0.0", "version": "1.0.0",
"description": "Tracks shipping-station scanners and label printers", "description": "Tracks shipping-station scanners and label printers",
"core_version": ">=0.13.0,<0.14.0", "core_version": ">=0.19.0,<0.20.0",
"dependencies": [] "dependencies": []
} }
``` ```

View File

@@ -78,7 +78,8 @@ plugin's identity (ADR-002):
Two fields deserve attention. Two fields deserve attention.
`core_version` is a semver range against the framework's `__contract_version__` `core_version` is a semver range against the framework's `__contract_version__`
(declared in `shopdb/__init__.py`, currently `0.13.0`). The loader refuses to load (declared in `shopdb/__init__.py`; the current value is in
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated). The loader refuses to load
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0 plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because (see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because

View File

@@ -6,11 +6,11 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
## Contract version ## Contract version
The framework declares its contract version in `shopdb/__init__.py`: The framework declares its contract version in `shopdb/__init__.py` as
`__contract_version__`. The current value is in
```python [PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code - this page
__contract_version__ = '0.16.0' does not restate it, because a version copied into prose is stale within a
``` fortnight and a plugin pinned against a stale one is refused at startup.
Each plugin's `manifest.json` declares the range of contract versions it supports: Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -154,20 +154,39 @@ class PrintersPlugin(BasePlugin):
### `get_dashboard_widgets() -> List[Dict]` ### `get_dashboard_widgets() -> List[Dict]`
Returns dashboard widget definitions for the home page. Returns dashboard card definitions for the home page.
A card declares DATA AND SHAPE, never a component name. Core owns a small set of
generic renderers and draws the card; the plugin says what to show, where it
comes from, and how to link it.
**Changed in contract 0.19.0.** The previous shape named a Vue component per
widget (`'component': 'NotificationsWidget'`). That cannot survive a lean build,
because a plugin's component may never be staged into the frontend bundle
(ADR-013), and in practice five plugins declared widgets pointing at components
nobody had written - so they rendered as nothing. A card using the old shape is
ignored. This is the same correction ADR-010 already made for asset panels.
```python ```python
class NotificationsPlugin(BasePlugin): class GeEnforcePlugin(BasePlugin):
def get_dashboard_widgets(self): def get_dashboard_widgets(self):
return [{ return [{
'name': 'recent_notifications', 'id': 'geenforce-failures', # stable, unique across plugins
'component': 'NotificationsWidget', 'title': 'Enforcement failures',
'endpoint': '/api/notifications/recent', 'endpoint': '/api/geenforce/dashboard/failures',
'size': 'medium', 'render': 'exceptions', # a core renderer, not a component
'position': 1, 'severity': 'critical', # orders cards on the page
'permission': 'geenforce.manage', # hidden without it
'empty': 'hide', # say nothing when there is nothing
'position': 10,
'viewall': '/geenforce', # optional link behind the heading
'map': {'title': 'hostname', 'detail': 'entryname'},
}] }]
``` ```
`empty: 'hide'` is not cosmetic. A card that reports "nothing wrong" every day
teaches people to stop reading the page.
Consumed by `GET /api/dashboard/widgets`, which merges widgets from all enabled Consumed by `GET /api/dashboard/widgets`, which merges widgets from all enabled
plugins sorted by `position` (disabled plugins are skipped; a broken plugin is plugins sorted by `position` (disabled plugins are skipped; a broken plugin is
isolated in prod, re-raised in dev/test). isolated in prod, re-raised in dev/test).
@@ -350,6 +369,12 @@ class MeasuringToolsPlugin(BasePlugin):
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
plugin isolated in prod, re-raised in dev/test). plugin isolated in prod, re-raised in dev/test).
An overlay keys on `assetid` and never on coordinates, so it needs to know
nothing about buildings or levels (ADR-017): it decorates whichever marker that
asset already has, on whatever level that marker is on. Do not return `mapx` /
`mapy` / `levelid` from an overlay endpoint - a position belongs to the asset,
and a second copy of it in an overlay is one that can disagree.
### `get_asset_presentation() -> List[Dict]` ### `get_asset_presentation() -> List[Dict]`
Returns asset-type presentation/routing definitions. Added in contract 0.7.0 Returns asset-type presentation/routing definitions. Added in contract 0.7.0

View File

@@ -46,19 +46,30 @@ Fields per row:
| `installpath` | Installer path for this printer (see install-batch). | | `installpath` | Installer path for this printer (see install-batch). |
| `iscsf` | CSF flag. | | `iscsf` | CSF flag. |
| `locationname` | Location name, if the asset has one. | | `locationname` | Location name, if the asset has one. |
| `mapx` / `mapy` | Floor-map hotspot position. | | `mapx` / `mapy` | Floor-map hotspot position, in the native pixels of `levelid`. |
| `levelid` | Which level (drawing) those pixels belong to. Null when unplaced. |
`?format=text` returns a pipe-delimited line per printer, one printer per line, `?format=text` returns a pipe-delimited line per printer, one printer per line,
with a fixed field order so the Inno / Pascal installer does a `split()` instead with a fixed field order so the Inno / Pascal installer does a `split()` instead
of parsing JSON: of parsing JSON:
``` ```
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy|levelid
``` ```
Any pipe or newline inside a value is neutralized to a space so the field count Any pipe or newline inside a value is neutralized to a space so the field count
stays fixed. The web map uses the default JSON. stays fixed. The web map uses the default JSON.
**Fields 0-7 are frozen.** The shipped installer reads them positionally
(`GetField(Line, 6)` is mapx), so a new field goes on the END and nowhere else -
inserting one shifts every later field and the installer keeps running while
reading a model number as a coordinate. `levelid` is field 8 for that reason, and
installers built before levels existed ignore it.
An installer that ignores `levelid` draws every printer on whichever single
blueprint it ships, which is correct only while a site has one level. See
ADR-017 and section 6.
--- ---
## 3. `GET /api/printers/pc-default?machine=NNNN` ## 3. `GET /api/printers/pc-default?machine=NNNN`
@@ -102,3 +113,27 @@ name / number.
The `common` scope's `printer map` manifest entry (see `GE-ENFORCE-DISPLAY.md`) The `common` scope's `printer map` manifest entry (see `GE-ENFORCE-DISPLAY.md`)
lays down the signed installer that consumes these endpoints. The web map page lays down the signed installer that consumes these endpoints. The web map page
covers the same install flow for a human at a browser. covers the same install flow for a human at a browser.
---
## 6. The shipped installer predates levels (known gap)
The Pascal installer in the `inno` repo (`PrinterInstallerMap`) still assumes one
drawing per site, in two places that must change together:
- `MAP_SOURCE_W = 3300` / `MAP_SOURCE_H = 2550` are compiled-in constants, and
every hotspot is scaled by them. They are the dimensions of ONE level.
- A downsized copy of that level's blueprint is bundled into the installer
(`880x680`), so the picture is fixed at build time.
Two consequences, neither of which the installer can detect:
1. It ignores field 8, so printers on any level are drawn on the bundled image.
Coordinates from a different drawing land somewhere plausible and wrong.
2. When a level's blueprint is replaced with one of different dimensions, the
constants and the bundled image are both stale and every hotspot shifts.
The API side is ready: `GET /api/maplevels` is deliberately unauthenticated so
the installer can fetch level dimensions and blueprint URLs at runtime rather
than compiling them in, which is what fixes both. Until the installer is
rebuilt against it, treat its map as accurate for the default level only.

90
docs/PROJECT-MAP.md Normal file
View File

@@ -0,0 +1,90 @@
<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
Regenerate with: venv/bin/python scripts/gen_project_map.py -->
# Project map
Derived from the code on every regeneration. If something here is wrong, the
code changed and the map was not regenerated - fix it by running the generator,
never by editing this file.
## Versions
| series | value | governed by |
|---|---|---|
| product `__version__` | `0.11.3` | ADR-007 |
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
They move independently. A contract bump is not a release.
## Alembic chains (ADR-008)
Core runs with `flask db upgrade`; every plugin chain runs
with `flask plugin upgrade-all`. Both are needed on a deploy.
| chain | head |
|---|---|
| core | `7d33_buildings_and_levels` |
| backups | `backups0003clearlastseen` |
| computers | `computers0001anchor` |
| employees | `employees0002photo` |
| geenforce | `geenforce0004minlib` |
| knowledgebase | `knowledgebase0001anchor` |
| machines | `machines0002rename` |
| measuringtools | `measuringtools0001baseline` |
| network | `network0003prefix` |
| notifications | `notifications0005boardorder` |
| printedparts | `printedparts0004txnrev` |
| printers | `printers0004drivervendor` |
| slides | `slides0001anchor` |
| usb | `usb0002dropmachineid` |
| warranty | `warranty0002proof` |
## Bundled plugins (15)
| plugin | version | core_version | owns migrations |
|---|---|---|---|
| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| computers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| employees | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes |
| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| machines | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| measuringtools | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| network | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| notifications | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| printedparts | 0.1.0 | >=0.16.0,<1.0.0 | yes |
| printers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| slides | 2.0.0 | >=0.2.0,<1.0.0 | yes |
| tools | 1.0.0 | >=0.16.0,<1.0.0 | no |
| usb | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| warranty | 1.0.0 | >=0.2.0,<1.0.0 | yes |
Manifest-less directories under `plugins/` are core frontend surface and always ship: `applications`.
## Architecture decisions
| ADR | title | status |
|---|---|---|
| ADR-001-asset-as-platform-contract.md | ADR-001: Asset model is the platform contract | ACCEPTED |
| ADR-002-plugin-versioning.md | ADR-002: Plugin contract versioning | ACCEPTED |
| ADR-003-plugin-distribution.md | ADR-003: Plugin distribution model | ACCEPTED |
| ADR-004-deployment-topology.md | ADR-004: Deployment topology (per-site instances) | ACCEPTED |
| ADR-005-equipment-vs-measuringtools.md | ADR-005: Equipment plugin scope vs measuringtools plugin | ACCEPTED |
| ADR-006-collector-contract.md | ADR-006: Plugin collector contract pattern | ACCEPTED |
| ADR-007-product-versioning-and-releases.md | ADR-007: Product versioning and releases | ACCEPTED |
| ADR-008-plugin-migration-ownership.md | ADR-008: Plugin migration ownership (per-plugin chains from the cutover) | ACCEPTED |
| ADR-009-frontend-plugin-gating.md | ADR-009: Frontend plugin gating | ACCEPTED |
| ADR-010-frontend-plugin-hooks.md | ADR-010: Frontend plugin hook contract | ACCEPTED |
| ADR-011-machines-rename.md | ADR-011: Rename the equipment domain to machines; retype the models catalog with modeltypes | ACCEPTED |
| ADR-012-geenforce-manifest-ownership.md | ADR-012: GE-Enforce manifest ownership in shopdb | ACCEPTED |
| ADR-013-plugin-catalog-and-lean-builds.md | ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds | ACCEPTED |
| ADR-014-schema-lean-per-site.md | ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) | ACCEPTED |
| ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | ACCEPTED |
| ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | ACCEPTED (decided; NOT yet implemented - |
| ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | ACCEPTED |
## Size
- test functions defined: **1113** (parametrised cases collect higher)
- documented API paths: **279** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -1,56 +0,0 @@
# ShopDB Flask - Project Health Review
As of HEAD `ecf4ef6` (2026-07-30), product `__version__ 0.7.0`, contract `__contract_version__ 0.15.0` (verified in `shopdb/__init__.py`).
## 1. Executive Summary
**Overall: healthy engineering, drifting focus.** The stated product vision ("plugin system is the product," the project brief) is delivered: all 7 refactor phases are done, 13 bundled plugins are contract-compliant, the per-plugin migration regime (ADR-008) is live and exercised (geenforce is at `geenforce0002blobs`, proving the post-cutover chain works in anger), and CI enforces naming, contract, and real-MySQL migration idempotency. Test count grew 1077 -> 1159 since the last the project brief snapshot.
The concern is not quality but trajectory. the project brief and ROADMAP.md both name the last big milestone before 1.0 as "legacy-ASP data import + production pilot." The loader is built and VM-validated (16 stages, `scripts/site_imports/wjf/`), but the prod run has not happened, and ~35 of the last 60 commits went to the GE-Enforce HTTPS cutover instead. That work is legitimate and high-value, but it is feature/fleet work on one plugin, and it has accumulated two process debts that violate the project's own discipline: the cutover playbook (`docs/geenforce-api-cutover.md`) is the only dirty file in the repo and is **untracked**, and the `prod-patch-geenforce` robocopy fast-path can leave prod ahead of git.
**Verdict: on track against standards, behind against goals.** The 1.0 gate has four items; only one is arguably done, and the roadmap doc does not know it.
## 2. Standards Compliance
| Rule | Status | Evidence |
|---|---|---|
| Naming (tables/columns/vars, CONTRIBUTING.md) | **MET** | `scripts/check-naming-and-style.sh` present and executable; dedicated `naming` job in the internal CI workflow. One borderline: `asset.py:175` documents a derived API key `location_name` with an underscore - not a DB-mirrored column so likely legal, but worth a glance since "response keys match column names exactly" is the spirit of the rule. |
| Plugin contract (manifest.json, BasePlugin, `shopdb.api` only) | **MET** | 13 `plugins/*/manifest.json` verified; contract test suites in `tests/`; contract bumped correctly to 0.15.0 for the geenforce resource-scope fetch tokens (75386d2), per ADR-002 discipline. |
| Migration ownership (ADR-008) | **MET** | `PLUGIN_TABLE_OWNERS` registry tested by `tests/test_plugin_migrations.py` (`EXPECTED_HEAD_REVISION` lines 47-51); `migrations-mysql` CI job does fresh utf8mb4 MySQL 8 upgrade + all plugin chains + second-upgrade-no-op assertion. The geenforce `0002blobs` revision shows the per-plugin chain is being used as designed, not just anchored. |
| Versioning/release discipline (ADR-007) | **AT RISK** | Tags through v0.7.0 exist and contract bumps are disciplined, but the documentation half of the procedure has drifted - see Gaps 3. |
| ADRs canonical, new priorities get an ADR | **AT RISK** | 14 ADRs present. But lean per-site builds are half-shipped (ADR-014 ACCEPTED and implemented; `default_enabled: false` on 5 plugins) while ADR-013, which defines the catalog/tiers/signed-artifact model those builds imply, is still PROPOSED. The GE-Enforce HTTPS cutover itself - a major architectural shift off the SMB share - lives in an untracked doc, not an ADR or ADR-012 amendment. |
| Style (plain ASCII, no emojis, comment discipline) | **MET** | Enforced by the same pre-commit hook + CI naming job. |
| Everything in git / repo as source of truth | **VIOLATED** | `docs/geenforce-api-cutover.md` untracked (only dirty file, verified `git status`); `prod-patch-geenforce` robocopy path acknowledged in-doc as leaving prod ahead of git. |
## 3. Roadmap Status
**Done:** Phases 0-6 (contract lock through multi-site distribution, tags v0.5.0-v0.7.0). Legacy import machinery complete: `docs/IMPORT-API.md` contract, 16-stage wjf loader VM-validated. Air-gapped deploy kit (6534590).
**1.0 must-haves (ROADMAP.md), honestly scored:**
1. *Asset model fully wired* - **appears DONE but unrecorded.** `Asset.mapx` (`shopdb/core/models/asset.py:121`), `inheritsposition` (`relationship.py:132`), and propagation logic (`relationship.py`, `core/api/assets.py`, `cli/__init__.py`) are all in code. ROADMAP still lists this as outstanding. Verify the ADR-001 contract tests cover it, then strike it.
2. *Equipment data migration one-shot* - **NOT DONE.** `scripts/migration/` contains only `fix_legacy_schema.sql`, `one-offs/`, and a README. No equipment script.
3. *Printers legacy-table cleanup* - **NOT DONE.** Recent printers commits (0d40780..c075658) are installer/feature work, not retirement.
4. *External plugin UI packaging* - **NOT DONE**, and gate criterion 3 (one external plugin built end-to-end) has no evidence.
**In-flight:** GE-Enforce HTTPS cutover dominates (~35/60 recent commits). Per the cutover doc's own section 12: only displays/kiosks are on the API; cmm/collections/keyence/genspect/heattreat/partmarker/common fleet still enforce from the SFLD SMB share; loggedinuser resolution unwired; registry cleanup pending; 3DPrintRoom route is a placeholder. Secondary streams: printers install-batch, applications notes, server reclassification, TV dashboard.
**Pace/scope health:** Velocity is high and test coverage tracks the work (17 of ~29 plugin test files are geenforce). But the project has been at 0.7.0 with "prod pilot is the last big milestone" as the stated goal since mid-July, while shipping ~185 commits of plugin-feature work. That is a real product being used - good - but the 1.0 gate is not moving, and a half-migrated fleet (API for displays, SMB for everything else) is the worst place to pause the cutover.
## 4. Gaps and Risks
1. **Untracked cutover playbook** (`docs/geenforce-api-cutover.md`). The single most valuable in-flight document is one `rm` away from gone, and invisible to any other machine or contributor.
2. **Prod-ahead-of-git debt.** The `prod-patch-geenforce` fast-path means production behavior may not be reproducible from any commit. This directly undermines ADR-012's "engine is source of truth" and the release discipline of ADR-007.
3. **Documentation drift, three concrete instances (all verified):** ROADMAP.md header says contract 0.13.0 (actual 0.15.0); the project brief says 1077 tests (actual 1159 collected) and claims a "lean-build" CI job that does not exist in the internal CI workflow (jobs: backend, naming, frontend, migrations-mysql - lean coverage is folded into pytest via `tests/test_lean_build_guards.py`). Also `.github/workflows/ci.yml` differs from the internal CI workflow - one of them is stale.
4. **Split-brain fleet enforcement.** Displays/kiosks on the API, the rest of the fleet on the SMB share, with staged-but-unpushed manifest fixes elsewhere (MTConnect v1 stranding). Two delivery mechanisms means two failure modes and doubles the audit surface until the cutover finishes.
5. **1.0 gate criterion 4 unproven:** `docs/DEPLOY.md` has not been validated by an actual fresh-host prod deploy. The air-gapped kit exists; the pilot does not.
6. **ADR-013 limbo:** lean builds shipped under ADR-014 while the catalog/signing model that makes external distribution safe remains PROPOSED. Fine short-term, but gate criterion 3 (external plugin) will force the question.
## 5. Prioritized Recommendations
1. **Commit `docs/geenforce-api-cutover.md` today.** Zero-cost, eliminates the worst single-point-of-loss risk.
2. **Reconcile prod-patched geenforce files back into git** and gate or retire the robocopy fast-path. Until prod == some tag, ADR-007 is fiction for this plugin.
3. **One doc-sync pass (30 min):** ROADMAP header to 0.15.0, the project brief test count and CI job list, strike must-have (a) if contract tests confirm the Asset wiring, delete or sync the stale `.github` workflow.
4. **Finish the cutover or park it cleanly.** Either drive the remaining fleet groups onto the API per section 12, or write down the frozen state as an ADR-012 amendment so the split-brain period is a documented decision, not drift.
5. **Schedule the prodscratch import run and prod pilot.** This is the actual 1.0 milestone and everything is built for it; it validates DEPLOY.md (gate 4) for free.
6. **Pair the equipment one-shot migration with printers retirement** (must-haves b and c) - they are coordinated by design; doing them together avoids touching the legacy tables twice.
7. **Decide ADR-013** before building the external-plugin end-to-end proof (gate 3); the geenforce client work is the natural seed for that external plugin.

View File

@@ -1,6 +1,6 @@
# Roadmap # Roadmap
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. shopdb-flask is pre-1.0 on both series. Current contract and product versions are in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code rather than typed here. This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
## Phase status ## Phase status

67
docs/START-HERE.md Normal file
View File

@@ -0,0 +1,67 @@
# Start here
ShopDB is a per-site asset platform: one instance per plant, plugins for what
each site tracks. These pages document the server, the plugin contract, and the
tools that run on shop-floor PCs.
Find yourself below. Each row is the shortest correct path, not everything that
exists.
## I am standing up ShopDB at a new site
1. [INSTALL-WINDOWS](INSTALL-WINDOWS.md) - one offline installer, start to finish.
2. [OPERATE-WINDOWS](OPERATE-WINDOWS.md) - restart, logs, backups, upgrades.
3. [CSV-IMPORT](CSV-IMPORT.md) if the site's data is in spreadsheets, or
[IMPORT-API](IMPORT-API.md) if there is a source database to script against.
4. [FLOOR-MAP](FLOOR-MAP.md) - load the site's floor plans and place markers.
Also the page to read BEFORE a plan changes: it covers moving every marker
onto a redrawn plan without doing it one at a time.
**Do not** follow [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) for a new site.
That is the manual procedure, kept for hand-built servers that predate the
installer, and following it produces a server the installer then refuses to
upgrade.
## I am deploying the shop-floor tools
Read [FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) first - it is one page and it
is what makes the rest make sense.
Then [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md), which has worked
examples for Intune, GE-Enforce and manual installation, and says where each
artifact comes from.
## I am writing a plugin
1. [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - scaffold and first run.
2. [PLUGIN-HOOKS](PLUGIN-HOOKS.md) - the contract. Authoritative.
3. [PLUGIN-LAB-PRINTEDPARTS](PLUGIN-LAB-PRINTEDPARTS.md) - a real plugin built
end to end, to type along with.
4. [PLUGIN-EXTERNAL-REPO](PLUGIN-EXTERNAL-REPO.md) if it lives in its own
repository, and [CONTRACT-STABILITY](CONTRACT-STABILITY.md) before you choose
a version pin.
## I am integrating with the API
1. [LLM-GUIDE](LLM-GUIDE.md) (`docs/llms.txt` in the repo, also served at
`/api/docs/llms.txt`) - auth, the response envelope, common recipes.
Short, and the envelope section is the part people get wrong.
2. `GET /api/docs` on any running instance - the full spec, browsable.
3. [API-REFERENCE](API-REFERENCE.md) for prose, [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md)
if you are feeding data in from a PC.
## I am trying to understand why something is built this way
[The ADRs](ADR-001-asset-as-platform-contract.md). They are the decision record,
they say what was rejected and why, and they are the fastest way to avoid
relitigating a settled question. [PROJECT-MAP](PROJECT-MAP.md) lists them all
with their status, along with the current versions and every migration head -
it is generated, so it is never stale.
## Something is broken
- Server: [OPERATE-WINDOWS](OPERATE-WINDOWS.md), then `shopdb-admin.ps1 check -Json`.
- A PC or a bay: the symptom table at the end of
[FLEET-ARCHITECTURE](FLEET-ARCHITECTURE.md) says which piece to open first.
- A deploy that half-worked: [UPGRADE](UPGRADE.md) and
[BACKUP-RESTORE](BACKUP-RESTORE.md).

View File

@@ -83,8 +83,45 @@ Installing an older build over a newer one is blocked outright. Once migrations
have moved the schema forward, older code cannot read it, and the failures are have moved the schema forward, older code cannot read it, and the failures are
difficult to unpick. difficult to unpick.
To go back you restore a backup taken before the update. Keep the previous Keep the previous `.exe` until you are satisfied with a release. You cannot
`.exe` until you are satisfied with a release. download it again from the server, and it is what a rollback needs.
### Rolling back a release
Rolling back is restoring a matched pair: the code AND the database it expects.
Doing one without the other is the state the installer refuses to create, and it
is worth understanding why - newer code against an older schema fails at the
first query that touches a new column, while older code against a newer schema
often appears to work until something silently misreads a column that changed
meaning.
The update takes its own backup before touching the schema, so the pair you need
already exists.
```powershell
cd C:\shopdb-flask
.\shopdb-admin.ps1 stop
# 1. Database first, from the pre-upgrade dump the update took.
mysql -u root -p shopdb_flask < C:\ProgramData\ShopDB-Flask\backups\shopdb_flask-pre-upgrade-<stamp>.sql
# 2. Then the code: run the PREVIOUS installer .exe. It is an install over a
# restored older schema, which is the ordinary case, not a downgrade.
.\ShopDBFlask_Installer_<previous-version>.exe
# 3. Prove the pair matches.
.\shopdb-admin.ps1 check -Json
```
If a migration failed mid-update, the installer has already restored its own
backup and there is nothing to roll back - read the log it names and fix
forward. Rollback is for a release that installed cleanly and then behaved
badly, which is a different situation and the only one this procedure is for.
**Uploaded files are not in the dump.** If the release you are leaving behind
accepted uploads, restore `instance\` from your own backup as well, or those
files stay while the rows referring to them do not. See
[BACKUP-RESTORE.md](BACKUP-RESTORE.md).
## Will an update affect other sites on the same IIS server? ## Will an update affect other sites on the same IIS server?

View File

@@ -89,9 +89,39 @@ Confirm the app is healthy (login page renders, `/api/auth/login` returns a
## Version-specific notes ## Version-specific notes
### Upgrading to v0.5.0 or later: bundled West Jefferson floor plan removed ### Upgrading to v0.11.0: the floor map became buildings and levels
Versions before 0.5 shipped the West Jefferson facility floor-plan PNGs as the Nothing to do, and nothing moves on screen. Run both migration steps as always:
```bash
flask db upgrade
flask plugin upgrade-all
```
`7d33_buildings_and_levels` creates one building ("Main") and one default level
("Ground floor") carrying the blueprint paths and dimensions from your `map_*`
settings, then assigns every already-placed asset and location to that level.
Unplaced things stay unplaced. The old settings rows are deliberately left in
place, so a downgrade still finds the blueprint.
Two things to know afterwards:
- **A second floor or building is a Settings task**, not a migration: add it under
Settings > Buildings and levels, upload its blueprint, then move the markers
that belong on it. `POST /api/mappositions/transform` moves a whole level's
markers onto a redrawn plan from landmark pairs, dry run by default.
- **Replacing a blueprint with one of DIFFERENT dimensions moves every marker on
that level**, because the coordinates are pixels of the old size. Upload it, then
recalibrate with landmarks rather than editing the level's width and height by
hand. The API refuses to adopt a new size on a level that already has markers,
for this reason.
If you wrote a plugin that stores map positions, see CONTRACT-STABILITY.md: the
contract is 0.20.0 and a position must now carry its `levelid`.
### Upgrading to v0.5.0 or later: bundled the reference site floor plan removed
Versions before 0.5 shipped the the reference site facility floor-plan PNGs as the
map default (`/static/images/sitemap2025-light.png` and `-dark.png`). v0.5+ map default (`/static/images/sitemap2025-light.png` and `-dark.png`). v0.5+
removes those bundled PNGs and ships a generic placeholder SVG instead. removes those bundled PNGs and ships a generic placeholder SVG instead.

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
## Context ## Context
@@ -25,7 +25,7 @@ Three viable distribution models:
## Decision ## Decision
**PROPOSED:** Use a **hybrid model** with two clearly-labeled paths. Use a **hybrid model** with two clearly-labeled paths.
1. **Bundled plugins**: a small set of plugins ships with the framework, in-tree at `plugins/`. These are the reference implementations and the default install (printers, computers, network, equipment, usb, notifications). A site that wants only what's bundled needs no extra work. 1. **Bundled plugins**: a small set of plugins ships with the framework, in-tree at `plugins/`. These are the reference implementations and the default install (printers, computers, network, equipment, usb, notifications). A site that wants only what's bundled needs no extra work.
2. **External plugins**: sister sites or third parties build plugins in their own git repos. The site running the framework drops the plugin into `plugins/<name>/` (clone, submodule, or symlink) and runs `flask plugin install <name>`. No pip packaging required for v1. 2. **External plugins**: sister sites or third parties build plugins in their own git repos. The site running the framework drops the plugin into `plugins/<name>/` (clone, submodule, or symlink) and runs `flask plugin install <name>`. No pip packaging required for v1.

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
## Context ## Context
@@ -17,7 +17,7 @@ The codebase today is single-tenant per deployment. There is no `siteid` column,
## Decision ## Decision
**PROPOSED:** **Per-site instances.** Each adopting site runs its own dedicated stack. The framework does not support multi-tenancy. **Per-site instances.** Each adopting site runs its own dedicated stack. The framework does not support multi-tenancy.
Each site: Each site:

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-05-08 - **Date:** 2026-05-08
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-10 - **Date:** 2026-07-10
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-10 - **Date:** 2026-07-10
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004 - **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-10 - **Date:** 2026-07-10
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context

View File

@@ -3,7 +3,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-11 - **Date:** 2026-07-11
- **Accepted:** 2026-07-11 - **Accepted:** 2026-07-11
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Supersedes:** none - **Supersedes:** none
## Context ## Context
@@ -316,5 +316,3 @@ sites.
- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers) - docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers)
- shopdb/plugins/base.py (existing declarative hooks this pattern extends) - shopdb/plugins/base.py (existing declarative hooks this pattern extends)
- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent) - shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent)
</content>
</invoke>

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-11 - **Date:** 2026-07-11
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Relates to:** ADR-005 (equipment vs measuringtools scope), ADR-008 (plugin migration ownership) - **Relates to:** ADR-005 (equipment vs measuringtools scope), ADR-008 (plugin migration ownership)
## Context ## Context

View File

@@ -2,7 +2,7 @@
- **Status:** ACCEPTED - **Status:** ACCEPTED
- **Date:** 2026-07-13 - **Date:** 2026-07-13
- **Deciders:** cproudlock - **Deciders:** ShopDB maintainers
- **Relates to:** ADR-002 (plugin contract versioning), ADR-004 (per-site - **Relates to:** ADR-002 (plugin contract versioning), ADR-004 (per-site
deployment), ADR-006 (collector contract), ADR-008 (per-plugin Alembic chains) deployment), ADR-006 (collector contract), ADR-008 (per-plugin Alembic chains)
@@ -18,7 +18,7 @@ We want shopdb to own the manifests as data (author, version, publish, roll
back) and to observe fleet compliance, while NOT taking on the GE-Enforce engine back) and to observe fleet compliance, while NOT taking on the GE-Enforce engine
itself (which is the GE-Enforce framework's, maintained separately) and NOT itself (which is the GE-Enforce framework's, maintained separately) and NOT
dictating any site's imaging path (per ADR-004, each site is single-tenant with dictating any site's imaging path (per ADR-004, each site is single-tenant with
its own provisioning - PXE at West Jefferson, OOBE provisioning packages at its own provisioning - PXE at the reference site, OOBE provisioning packages at
others). others).
The manifests are an enforcement PROGRAM, not an application inventory: entry The manifests are an enforcement PROGRAM, not an application inventory: entry

View File

@@ -1,8 +1,8 @@
# ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds # ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds
- Status: PROPOSED - Status: ACCEPTED
- Date: 2026-07-18 - Date: 2026-07-18
- Deciders: cproudlock - Deciders: ShopDB maintainers
- Relates to: ADR-002 (contract versioning), ADR-003 (plugin distribution), ADR-004 (per-site instances), ADR-008 (per-plugin migrations), ADR-009 (frontend plugin gating), ADR-010 (frontend hook contract) - Relates to: ADR-002 (contract versioning), ADR-003 (plugin distribution), ADR-004 (per-site instances), ADR-008 (per-plugin migrations), ADR-009 (frontend plugin gating), ADR-010 (frontend hook contract)
## Context ## Context
@@ -397,3 +397,17 @@ Deferred, each to its own future decision: schema-lean core-baseline re-org
(blocked on the installedapps -> machines FK question), pip/entry-point (blocked on the installedapps -> machines FK question), pip/entry-point
distribution (ADR-003 v2), hook-based search/report aggregation contract, and distribution (ADR-003 v2), hook-based search/report aggregation contract, and
any revisit of Path B. any revisit of Path B.
## Amendment, 2026-08-14
Status corrected from PROPOSED to ACCEPTED. The lean-build half of this decision
shipped some time ago - `scripts/build-site.sh`, `SITE_PLUGINS` staging,
`flask plugin prune-schema` and `default_enabled: false` are all live, and
ADR-014 was accepted on top of it - while the record still said the decision was
under consideration. A decision that has been implemented and depended upon is
not proposed, whatever the header says, and leaving it that way makes every
other status in the index worth less.
The catalog and signed-artifact parts of the decision remain unbuilt. That is
the ordinary state of an accepted decision: accepted means settled, not
delivered.

View File

@@ -2,7 +2,7 @@
- Status: ACCEPTED - Status: ACCEPTED
- Date: 2026-07-19 - Date: 2026-07-19
- Deciders: cproudlock - Deciders: ShopDB maintainers
- Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model) - Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model)
## Context ## Context

View File

@@ -2,7 +2,7 @@
- Status: ACCEPTED - Status: ACCEPTED
- Date: 2026-08-11 - Date: 2026-08-11
- Deciders: cproudlock - Deciders: ShopDB maintainers
- Relates to: ADR-004 (per-site instances, not multi-tenant), ADR-013 (plugin catalog + lean per-site builds), ADR-014 (schema-lean per-site builds) - Relates to: ADR-004 (per-site instances, not multi-tenant), ADR-013 (plugin catalog + lean per-site builds), ADR-014 (schema-lean per-site builds)
## Context ## Context
@@ -17,14 +17,17 @@ That is not a hypothetical drift. A scan of `plugins/` and `shopdb/` for a site
host, a site FQDN or a site network returns 19 hits, of which roughly 8 are a host, a site FQDN or a site network returns 19 hits, of which roughly 8 are a
shipped default or a hardcode rather than a comment: shipped default or a hardcode rather than a comment:
- `plugins/geenforce/seed_display_scope.py` bakes `https://tsgwp00525.wjs. - `plugins/geenforce/seed_display_scope.py` baked one site's own ShopDB URL and
geaerospace.net/shopdb` and `device.geaerospace.net` into an inline PowerShell `device.geaerospace.net` into an inline PowerShell payload. A sister site
payload. A sister site cannot override either without editing the source. could not override either without editing the source. FIXED 2026-08-14: the
- `plugins/backups/services/registry.py` defaults the backup share root to a WJ kiosk base URL now comes from the `site_base_url` setting at seed time and
from HKLM at run time, and the dispatcher refuses rather than guessing when
neither is set.
- `plugins/backups/services/registry.py` defaults the backup share root to one site's own
UNC path. UNC path.
- `shopdb/core/api/settings.py` ships `device.geaerospace.net` and - `shopdb/core/api/settings.py` ships `device.geaerospace.net` and
`Printer-{ip}.printer.geaerospace.net` as SETTING DEFAULTS, so another site `Printer-{ip}.printer.geaerospace.net` as SETTING DEFAULTS, so another site
inherits WJ's domain silently until someone notices. inherits that site's domain silently until someone notices.
- `shopdb/core/models/dashboarddefault.py` holds the same domain a second time - `shopdb/core/models/dashboarddefault.py` holds the same domain a second time
as a module constant, and `plugins/computers/api/routes.py` a third time as a as a module constant, and `plugins/computers/api/routes.py` a third time as a
literal fallback. literal fallback.
@@ -52,7 +55,7 @@ site-specific default is the failure mode this ADR exists to stop: it is
invisible, it looks deliberate, and it is wrong everywhere except here. invisible, it looks deliberate, and it is wrong everywhere except here.
**2. A site-namespaced directory.** Anything that is genuinely one site's and **2. A site-namespaced directory.** Anything that is genuinely one site's and
will never be another's, such as the West Jefferson import loader at will never be another's, such as the reference site's import loader at
`scripts/site_imports/wjf/`. Never reached by another site's build. `scripts/site_imports/wjf/`. Never reached by another site's build.
**3. Seed data, not code.** Vocabularies and catalogs, loaded by `flask seed` **3. Seed data, not code.** Vocabularies and catalogs, loaded by `flask seed`
@@ -63,7 +66,7 @@ are a GE fleet taxonomy, not one site's data, and they already carry a per-site
override through the `pctypemap_*` settings. They are out of scope here. override through the `pctypemap_*` settings. They are out of scope here.
Comments and documentation may name a site where it explains WHY - "part markers Comments and documentation may name a site where it explains WHY - "part markers
share a machine number at West Jefferson" is a fact a maintainer needs. Prefer share a machine number at the reference site" is a fact a maintainer needs. Prefer
phrasing the rule generally and the example specifically. phrasing the rule generally and the example specifically.
## Enforcement ## Enforcement
@@ -72,11 +75,9 @@ phrasing the rule generally and the example specifically.
and `shopdb/` for a site host, a site FQDN or a site network, excluding tests and `shopdb/` for a site host, a site FQDN or a site network, excluding tests
and `site_imports/`. and `site_imports/`.
It ships in REPORT-ONLY mode. It prints findings and does not fail the build, It shipped in REPORT-ONLY mode, because turning a 19-item backlog into a hard
because turning a 19-item backlog into a hard gate on the day it lands blocks gate on the day it lands blocks every unrelated commit until someone works
every unrelated commit until someone works through it. Set through it. See the amendment below: it now fails the build.
`SITE_LITERALS_ENFORCE=1` to make it a gate; do that once the real hardcodes
below are cleared.
## Consequences ## Consequences
@@ -98,7 +99,8 @@ Negative:
## Backlog ## Backlog
Real hardcodes and site-specific defaults, to clear before enforcing: CLEARED - see the amendment below. Kept as the record of what the decision was
answering:
- `plugins/geenforce/seed_display_scope.py` - kiosk base URL, FQDN domain, and - `plugins/geenforce/seed_display_scope.py` - kiosk base URL, FQDN domain, and
the legacy-shortcut sweep pattern the legacy-shortcut sweep pattern
@@ -113,5 +115,34 @@ setting, one default, one read.
Cosmetic mentions in comments and UI placeholder text are acceptable under this Cosmetic mentions in comments and UI placeholder text are acceptable under this
ADR and are not part of the backlog, with one exception worth fixing when ADR and are not part of the backlog, with one exception worth fixing when
convenient: the GE-Enforce settings page shows WJ's real CIDRs as its convenient: the GE-Enforce settings page shows one site's real CIDRs as its
placeholder, which every site sees. placeholder, which every site sees.
## Amendment, 2026-08-14
The backlog above is cleared and the scanner now ENFORCES: it fails the build,
and `SITE_LITERALS_ENFORCE=0` is the escape hatch rather than the default. The
report-only period ended for the reason the Consequences section predicted - the
hit count did not move for weeks, because a rule that only prints is read as no
rule.
Three things changed beyond clearing the list:
- The scan covers PowerShell, the installer, the seeds, generated JSON and the
frontend, case-insensitively, across `plugins/`, `shopdb/`, `scripts/`,
`deploy/`, `frontend/src/` and `tools/`. Its original scope - three file types
under two directories - missed every literal that actually reached a second
site, because those were all in the client scripts and the installer.
- A line may declare itself deliberate with a trailing `ADR-015-OK: <reason>`
marker. That is for an organisation-wide default which is right at every site
and configurable anyway, such as the corporate device domain. It is not a
way to defer work: the marker puts the claim in the diff where a reviewer
sees it.
- Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. The ADR said this was not a
security matter because the values were internal to an air-gapped network;
that was true of the fleet and false of the public mirror, which had been
carrying a production hostname and a share path.
This amendment is appended rather than edited into the text above, because what
the decision looked like when it was taken is the part worth keeping.

View File

@@ -1,8 +1,8 @@
# ADR-016: Credential delivery to the fleet # ADR-016: Credential delivery to the fleet
- Status: ACCEPTED - Status: ACCEPTED (decided; NOT yet implemented - see Implementation status)
- Date: 2026-08-11 - Date: 2026-08-11
- Deciders: cproudlock - Deciders: ShopDB maintainers
- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership), ADR-015 (site-specific configuration) - Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership), ADR-015 (site-specific configuration)
## Context ## Context
@@ -169,3 +169,14 @@ serves many machines, so the payload cannot be encrypted to its readers.
the secret off the share immediately and needs no new endpoint - but it leaves the secret off the share immediately and needs no new endpoint - but it leaves
provisioning per-bay by hand and offers no rotation. Recommended as tier one provisioning per-bay by hand and offers no rotation. Recommended as tier one
regardless, since the client helper is the same either way. regardless, since the client helper is the same either way.
## Implementation status, 2026-08-14
Nothing in this ADR is built yet. The fetch endpoint and the `credentials.*`
permissions it describes do not exist in the code, and a reader searching for
them will not find them.
Recorded here rather than by changing the status, because the decision itself
stands: this is how credential delivery WILL work, and a plugin author designing
against it is designing correctly. What credentials the fleet uses today, and
where they live, is in [FLEET-ARCHITECTURE](../FLEET-ARCHITECTURE.md).

View File

@@ -0,0 +1,143 @@
# ADR-017: Buildings and levels as the map model
- Status: ACCEPTED
- Date: 2026-08-17
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site
instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific
configuration)
- Supersedes: the site-wide `map_blueprint_light` / `map_blueprint_dark` /
`map_width` / `map_height` settings
## Context
A site's floor map is one image. Four settings describe it - two blueprints
(light and dark) and the native pixel width and height - and `assets.mapx` /
`assets.mapy` are absolute pixel coordinates in that image's space.
That model has one floor in one building, which was true when it was written and
is no longer. The reference site is adding a second level, and a second building
is likely within a year or two. The immediate trigger is a new blueprint at
3308x4000 where the old one was 3300x2550.
The obvious cheap answer is to stack levels on one canvas: draw the second level
below the first and keep one image. It works, and it was seriously considered.
It was rejected because it makes the level an inference rather than a fact:
- "Which level is this asset on" becomes `mapy > 2550`. Every per-level filter,
count, report and export has to know that constant, and re-exporting the
drawing at a different height silently changes the answer everywhere. Not
broken - wrong, which is worse.
- Distance between two markers becomes computable and meaningless, so anything
doing nearest-asset or clustering quietly answers nonsense across levels.
- One canvas forces one scale. A mezzanine drawn at a different scale than the
floor below cannot be expressed at all.
- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where
a marker is clickable, roughly half a level is visible, and "fit to level" is
not expressible.
- A third level makes each of these worse, and adds a second threshold.
Timing decided it. Because the level layout changed and machines moved, every
marker is going to be repositioned anyway. Introducing levels now costs one pass
over the positions; stacking now and splitting later costs two, and in between
every position placed encodes the threshold into real data - which a later
migration would then have to un-guess by comparing Y against it.
## Decision
Two tables, and assets reference the level.
```
buildings buildingid, buildingname, sortorder, isactive
maplevels levelid, buildingid, levelname, sortorder,
blueprintlight, blueprintdark, mapwidth, mapheight,
isdefault, isactive
assets levelid (nullable; the default level for existing rows)
```
**1. An asset references the level, never the building.** The building derives
from the level, so the two cannot disagree. Storing both would be a fact
recorded twice with no difference in granularity to justify it.
**2. Blueprints and native dimensions belong to the level.** This is what the
site-wide settings could not express: two levels in one building may be drawn at
different sizes and scales, and two buildings certainly are.
**3. Name and order are separate.** `levelname` is text and `sortorder` is an
integer. Levels are not reliably numbered - basement, ground, mezzanine, roof,
tunnel - and sort order gives adjacency and up/down navigation without
pretending the names are ordinal. It also lets a mezzanine be inserted between
two existing levels without renumbering anything.
**4. `mapx` / `mapy` keep their meaning, scoped to the level.** They stay
absolute pixels in the native coordinate space of the level's blueprint. No
normalisation to fractions: pixels are what the drawing tools produce, what an
operator can read off an image, and what the existing data already holds.
**5. A level is required to render a position, and absence is not a default.**
Where a payload carries a position without a level, the UI renders "level
unknown" rather than falling back to the default level. Falling back draws one
building's ground floor with a marker positioned for another building's
mezzanine: it renders perfectly and points at the wrong place, and nothing about
the result looks wrong. A visible gap is worth more than a confident wrong
answer.
**6. The level list is readable without authentication.** The printer installer
map runs optional-auth, before anyone logs in, and it needs a blueprint. This
follows the precedent already set for printer install-list and the slide feed.
Blueprint paths and level names are not secrets; the positions of assets on them
already render on public kiosk pages.
**7. Levels are not Locations.** Assets carry `locationid` already, and reusing
it is tempting. A Location answers "which operation owns this"; a level answers
"which drawing renders it, at what native size". Overloading Location with
blueprint images and pixel dimensions makes both concepts worse. They coexist:
an asset on level 2 in operation 0613.
## Migration
The existing four settings become one building and one level, marked default,
and every asset with a position points at it. Nothing renders differently on the
day it lands. The settings keys are then retired rather than left as a second
source of truth that can disagree with the rows.
## Consequences
Positive:
- A second building costs a row and a blueprint. So does a third level.
- Per-level export, per-level zoom, and per-level calibration all become
expressible. Calibrating one level against its own landmarks is more correct
than transforming a whole site at once.
- Level becomes queryable - counts and filters per level or per building are
ordinary queries rather than coordinate arithmetic.
Negative:
- Every surface that draws a marker learns there is more than one drawing. That
is two components, seven views, four asset position pickers, the printer
installer map, the PDF export, and eight API payloads that must emit `levelid`
beside `mapx`. A payload that forgets it produces the silent-wrong-map failure
described above, which is why rule 5 exists and why a build gate checks that
the two fields travel together.
- `get_map_overlays` gains level context, so this is a plugin contract change
(0.20.0) and plugins rendering overlays need to declare which level they are
for.
- One more admin surface: levels and buildings have to be managed somewhere, and
the existing floor-map settings page becomes that.
## Alternatives considered
**Stack levels on one canvas.** Rejected above. Zero cost today, and it makes
the level an inference over a magic number.
**Normalise coordinates to fractions of the image.** Would make a rescaled
blueprint self-correcting, which is genuinely attractive. Rejected for now
because it converts every existing integer position through a lossy division,
and because it does not help the actual problem: the levels changed and machines
moved, so the positions need human review regardless. Worth revisiting
independently.
**A `level` string on the asset, with one blueprint per level in settings.**
Rejected: settings keyed by level name is a table with extra steps, and it gives
no place for per-level dimensions or ordering.

File diff suppressed because it is too large Load Diff

View File

@@ -251,7 +251,7 @@
<h3>Base path</h3> <h3>Base path</h3>
<p> <p>
Instances are served under a subpath, so the API is at Instances are served under a subpath, so the API is at
<code>https://tsgwp00525.wjs.geaerospace.net/shopdb/api/...</code> - production on <code>https://shopdb.example.net/shopdb/api/...</code> - production on
this server - or <code>/ops/api/...</code> for the dev instance beside it, not at this server - or <code>/ops/api/...</code> for the dev instance beside it, not at
the domain root. Make the base a variable; do not hardcode <code>/api</code>. the domain root. Make the base a variable; do not hardcode <code>/api</code>.
</p> </p>
@@ -275,7 +275,7 @@
<h2>Minimal example</h2> <h2>Minimal example</h2>
<pre><code>const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'; // no trailing /api <pre><code>const BASE = 'https://shopdb.example.net/shopdb'; // no trailing /api
async function lowSupplies() { async function lowSupplies() {
const response = await fetch(`${BASE}/api/printers/lowsupplies`); const response = await fetch(`${BASE}/api/printers/lowsupplies`);
@@ -296,7 +296,7 @@ lowSupplies().then(data =&gt; {
<h2>Polling, with the failure cases handled</h2> <h2>Polling, with the failure cases handled</h2>
<pre><code>const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'; <pre><code>const BASE = 'https://shopdb.example.net/shopdb';
const POLL_MS = 3 * 60 * 1000; // server caches 5 min; faster buys nothing const POLL_MS = 3 * 60 * 1000; // server caches 5 min; faster buys nothing
async function tick() { async function tick() {
@@ -369,8 +369,8 @@ async function printerIdByIp(ip) {
<div class="demo"> <div class="demo">
<div class="demo-controls"> <div class="demo-controls">
<input id="base" type="text" value="https://tsgwp00525.wjs.geaerospace.net/shopdb" aria-label="ShopDB base URL" <input id="base" type="text" value="https://shopdb.example.net/shopdb" aria-label="ShopDB base URL"
placeholder="https://tsgwp00525.wjs.geaerospace.net/shopdb" /> placeholder="https://shopdb.example.net/shopdb" />
<button id="load">Load</button> <button id="load">Load</button>
</div> </div>
<div id="out"><p class="status-msg">Enter a base URL and press Load.</p></div> <div id="out"><p class="status-msg">Enter a base URL and press Load.</p></div>

View File

@@ -38,7 +38,7 @@ Contents:
## 1. Overview and why ## 1. Overview and why
GE-Enforce v2 delivers desired-state manifests and installer payloads from the GE-Enforce v2 delivers desired-state manifests and installer payloads from the
SFLD SMB share (`\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`). SFLD SMB share (`\\shopdb.example.net\shared\dt\shopfloor\`).
Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads
`<scope>\manifest.json`, and runs the engine `<scope>\manifest.json`, and runs the engine
(`Install-FromManifest.ps1`). That works for the domain fleet but is a hard (`Install-FromManifest.ps1`). That works for the domain fleet but is a hard
@@ -60,15 +60,15 @@ Intune/Entra-joined with no SFLD credentials and no domain trust, so SMB is not
an option at all. The rest of the fleet CAN stay on the share (and currently an option at all. The rest of the fleet CAN stay on the share (and currently
does); for them the API is an opt-in migration, not a forced one. does); for them the API is an opt-in migration, not a forced one.
West Jefferson facts used throughout this doc: the reference site facts used throughout this doc:
| Fact | Value | | Fact | Value |
|------|-------| |------|-------|
| Prod host | `tsgwp00525.wjs.geaerospace.net` | | Prod host | `shopdb.example.net` |
| App mount | `/shopdb` (IIS, app dir `C:\inetpub\wwwroot\shopdb`, pool `shopdbflask-prod`) | | App mount | `/shopdb` (IIS, app dir `C:\inetpub\wwwroot\shopdb`, pool `shopdbflask-prod`) |
| BaseUrl clients use | `https://tsgwp00525.wjs.geaerospace.net/shopdb` | | BaseUrl clients use | `https://shopdb.example.net/shopdb` |
| Prod DB | `shopdb_flask` (MySQL) | | Prod DB | `shopdb_flask` (MySQL) |
| Client allowlist CIDRs | `10.134.48.0/23,10.48.249.0/26` (the WJ corp/AESFMA shopfloor subnets) | | Client allowlist CIDRs | `192.0.2.0/24,198.51.100.0/26` (this site s corp / shopfloor subnets - examples) |
| Dev/staging instance | `/ops` mount, DB `shopdb_flask_dev`, pool `shopdbflask` | | Dev/staging instance | `/ops` mount, DB `shopdb_flask_dev`, pool `shopdbflask` |
--- ---
@@ -140,7 +140,7 @@ IIS does not set `X-Forwarded-For` on its own - the rewrite rule is the only
thing that does. Remove the rule and IIS still *forwards* whatever thing that does. Remove the rule and IIS still *forwards* whatever
`X-Forwarded-For` the caller sent. waitress trusts that header because it arrives `X-Forwarded-For` the caller sent. waitress trusts that header because it arrives
from `127.0.0.1`, which is IIS, and sets `remote_addr` from it. So a caller who from `127.0.0.1`, which is IIS, and sets `remote_addr` from it. So a caller who
sends `X-Forwarded-For: 10.134.48.5` gets `remote_addr = 10.134.48.5`, matches sends `X-Forwarded-For: 192.0.2.5` gets `remote_addr = 192.0.2.5`, matches
the allowlist and fetches manifests token-less from anywhere on the network. the allowlist and fetches manifests token-less from anywhere on the network.
The rule is not a nicety that improves logging. It is the control that makes The rule is not a nicety that improves logging. It is the control that makes
@@ -195,9 +195,16 @@ is the bridge that lets the UNCHANGED engine install share-less:
The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's
`-InstallerRoot` to that same payloads directory, so the engine's `-InstallerRoot` to that same payloads directory, so the engine's
`Join-Path $InstallerRoot <leaf>` resolves to the staged file. `smb` entries in `Join-Path $InstallerRoot <leaf>` resolves to the staged file.
a mixed manifest are left untouched and still resolve against the share (a PC
that has it). **`smb` entries in a mixed manifest DO NOT survive this.** An earlier version of
this doc claimed they were "left untouched and still resolve against the share";
that is false outside shadow mode and was measured wrong on the VM (see the
blockers in section 11 step 1). `Resolve-ShopdbPayloads` does leave them
untouched, but the runner has already pointed `-InstallerRoot` at the payload
cache, so a share-relative path resolves under the cache and is reported
`not found`. A manifest that mixes `smb` with http/inline cannot be served by a
single `InstallerRoot` at all.
--- ---
@@ -237,7 +244,7 @@ never existed on the share. Pattern:
(Dashboard -> `/shopfloor`, Lobby -> `/tv`, 3DPrintRoom -> `/parts-kiosk`), (Dashboard -> `/shopfloor`, Lobby -> `/tv`, 3DPrintRoom -> `/parts-kiosk`),
and writes an all-users Startup shortcut (`ShopDB Kiosk.lnk`) launching Edge and writes an all-users Startup shortcut (`ShopDB Kiosk.lnk`) launching Edge
`--kiosk` fullscreen at `{BaseUrl}{route}`. It does NOT Start-Process Edge `--kiosk` fullscreen at `{BaseUrl}{route}`. It does NOT Start-Process Edge
(see gotchas). Base URL comes from HKLM `BaseUrl`, falling back to the WJ (see gotchas). Base URL comes from HKLM `BaseUrl`, falling back to the reference-site
host. host.
- `seed_display_scope(publish=False)`: `replace_scope_draft`, flush (entries - `seed_display_scope(publish=False)`: `replace_scope_draft`, flush (entries
need entryids), then `service.store_inline_payload(...)` for each script need entryids), then `service.store_inline_payload(...)` for each script
@@ -285,7 +292,7 @@ is checked against the CURRENT published manifest.
| Value | Used by | Notes | | Value | Used by | Notes |
|-------|---------|-------| |-------|---------|-------|
| `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://tsgwp00525.wjs.geaerospace.net/shopdb`. Required. | | `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://shopdb.example.net/shopdb`. Required. |
| `ApiToken` | enforce client | `geenforce.fetch` (+ report) PAT. OPTIONAL - a token-less client relies on the IP allowlist (`Get-ShopdbConfig` treats BaseUrl-only as valid). | | `ApiToken` | enforce client | `geenforce.fetch` (+ report) PAT. OPTIONAL - a token-less client relies on the IP allowlist (`Get-ShopdbConfig` treats BaseUrl-only as valid). |
| `CollectorKey` | `Report-AssetToShopDB.ps1` | `collector.ingest` PAT. REQUIRED for asset reporting (allowlist does not cover the collector). | | `CollectorKey` | `Report-AssetToShopDB.ps1` | `collector.ingest` PAT. REQUIRED for asset reporting (allowlist does not cover the collector). |
@@ -394,7 +401,7 @@ Wow6432Node registry views, every loaded user hive (HKU), Run + RunOnce +
Policies\Explorer\Run, matching by the legacy value names AND by any value Policies\Explorer\Run, matching by the legacy value names AND by any value
pointing at the old URLs; plus every per-user and common Startup folder; then pointing at the old URLs; plus every per-user and common Startup folder; then
kills any running old-URL Edge. A read-only locator, kills any running old-URL Edge. A read-only locator,
`pxe-images/github/find-legacy-kiosk-autostart.ps1`, hunts all these locations `<imaging-share>/github/find-legacy-kiosk-autostart.ps1`, hunts all these locations
(and Edge startup-URL policy, scheduled tasks, Assigned Access) when a straggler (and Edge startup-URL policy, scheduled tasks, Assigned Access) when a straggler
persists. persists.
@@ -416,7 +423,7 @@ the imaging share (`shopdb-migration/kiosk-installer/`)) is hosted at
``` ```
Set-ExecutionPolicy Bypass -Scope Process -Force Set-ExecutionPolicy Bypass -Scope Process -Force
$u = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1' $u = 'https://shopdb.example.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1'
Invoke-RestMethod $u -OutFile "$env:TEMP\Install-ShopdbKiosk.ps1" Invoke-RestMethod $u -OutFile "$env:TEMP\Install-ShopdbKiosk.ps1"
& "$env:TEMP\Install-ShopdbKiosk.ps1" -DisplayType Lobby -CollectorKey 'shopdb_pat_...' & "$env:TEMP\Install-ShopdbKiosk.ps1" -DisplayType Lobby -CollectorKey 'shopdb_pat_...'
# add -ShopdbToken 'shopdb_pat_...' only if the subnet is NOT allowlisted # add -ShopdbToken 'shopdb_pat_...' only if the subnet is NOT allowlisted
@@ -530,7 +537,7 @@ real debugging time. Format: symptom -> cause -> fix.
rule OVERWRITES X-Forwarded-For with REMOTE_ADDR and waitress trusts only rule OVERWRITES X-Forwarded-For with REMOTE_ADDR and waitress trusts only
127.0.0.1 as proxy. The rule is a hard dependency: never remove it, and 127.0.0.1 as proxy. The rule is a hard dependency: never remove it, and
verify the spoof is closed after server changes verify the spoof is closed after server changes
(`curl -H "X-Forwarded-For: 10.134.48.10"` from a non-allowlisted host (`curl -H "X-Forwarded-For: 192.0.2.10"` from a non-allowlisted host
must get 401). must get 401).
- **Kiosk browser never appears though the dispatcher "ran fine"** -> the - **Kiosk browser never appears though the dispatcher "ran fine"** -> the
enforce task runs as SYSTEM in session 0, which has no interactive enforce task runs as SYSTEM in session 0, which has no interactive
@@ -562,7 +569,7 @@ real debugging time. Format: symptom -> cause -> fix.
installs left their own Startup launchers behind, in several flavors -> installs left their own Startup launchers behind, in several flavors ->
the dispatcher's sweep must match ALL of: single- AND double-dash `-kiosk` the dispatcher's sweep must match ALL of: single- AND double-dash `-kiosk`
arguments (the regex `-kiosk` matches both), shortcuts whose args carry arguments (the regex `-kiosk` matches both), shortcuts whose args carry
shopdb URLs (`tsgwp00525`, `/shopdb/`, the dead `shopfloor-dashboard` shopdb URLs (`SHOPDBHOST`, `/shopdb/`, the dead `shopfloor-dashboard`
route), the imaging installers' `GE Aerospace Dashboard*` / `GE Aerospace route), the imaging installers' `GE Aerospace Dashboard*` / `GE Aerospace
Lobby*` shortcut names, and `.url` files pointing at the kiosk routes. Lobby*` shortcut names, and `.url` files pointing at the kiosk routes.
Extend the sweep whenever a new launcher naming appears. Extend the sweep whenever a new launcher naming appears.
@@ -605,13 +612,13 @@ The VM rig:
## 10. Deploy ## 10. Deploy
TWO independent channels. Confusing them is the classic mistake: the client/ TWO independent channels. Confusing them is the classic mistake: the client/
engine/collector/bootstrap are pxe-images SHARE artifacts, NOT deployed by the engine/collector/bootstrap are imaging-share artifacts, NOT deployed by the
git pipeline. git pipeline.
### Channel 1: backend + frontend (the git .cmd pipeline) ### Channel 1: backend + frontend (the git .cmd pipeline)
Prod is air-gapped from dev; code moves via a git bundle on the share Prod is air-gapped from dev; code moves via a git bundle on the share
(`\\172.16.9.9\pxe-images\github\shopdb-flask-pub.bundle`) and the .cmd (`\\<imaging-share>\github\shopdb-flask-pub.bundle`) and the .cmd
scripts in the imaging share (`github/`), run on the work PC: scripts in the imaging share (`github/`), run on the work PC:
1. `pull-shopdb-bundle.cmd` - fetch the bundle into the local clone 1. `pull-shopdb-bundle.cmd` - fetch the bundle into the local clone
@@ -655,7 +662,7 @@ automatically). PCs pick up new bytes by re-running the bootstrap one-liner.
1. Publish the scope(s) - `seed_display_scope(publish=True)` or 1. Publish the scope(s) - `seed_display_scope(publish=True)` or
`flask geenforce publish <scope>`. `flask geenforce publish <scope>`.
2. Seed `geenforce_allowed_cidrs` = `10.134.48.0/23,10.48.249.0/26` 2. Seed `geenforce_allowed_cidrs` = `192.0.2.0/24,198.51.100.0/26`
(Settings rail > GE-Enforce Settings, or SQL upsert into `settings`). (Settings rail > GE-Enforce Settings, or SQL upsert into `settings`).
3. Mint tokens (Settings > API Tokens, Restrict permissions ON): 3. Mint tokens (Settings > API Tokens, Restrict permissions ON):
`collector.ingest` (required, the kiosk `-CollectorKey`) and `collector.ingest` (required, the kiosk `-CollectorKey`) and
@@ -672,12 +679,39 @@ Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`,
`-nocollections`, `common`) over to the API. `-nocollections`, `common`) over to the API.
1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes, 1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes,
the cheap cutover is manifest-over-API + payloads-still-smb (entries stay the intended cheap cutover is manifest-over-API + payloads-still-smb
`smb`, nothing to upload, the engine resolves share paths as today). Only (entries stay `smb`, nothing to upload). Only a genuinely share-less PC
a genuinely share-less PC needs http/inline payload conversion. Note the needs http/inline payload conversion. Note the payload endpoint's 512 MB
payload endpoint's 512 MB default ceiling default ceiling (`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge
(`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge installers over installers over HTTPS.
HTTPS.
**THE CHEAP CUTOVER DOES NOT WORK WITH THE CURRENT RUNNER. Verified on the
win11 VM against lib 2.6, 2026-08-13.** Two blockers, both in delivery, not
in the manifests:
- `Invoke-ShopdbEnforce.ps1` has no `-InstallerRoot` parameter. Outside
shadow mode it hardcodes the payloads cache
(`Join-Path (Split-Path -Parent $manifestToRun) 'payloads'`). That is
right for displays, where every entry is inline, and wrong for every
`smb` entry: the engine resolves `Join-InstallerPath $InstallerRoot
<field>`, so `scripts/Install-AcroReader.cmd` is looked for under the
payload cache and logs `CMD/BAT not found`. Measured on the VM with the
same manifest and engine: InstallerRoot=payloads gave 0 installed /
2 failed, InstallerRoot=<scope dir> gave 2 installed / 0 failed.
- `-IncludeCommon` cannot work for `smb` payloads at all. Payloads live
under their own scope directory (`common/scripts/...` vs
`gea-shopfloor-nocollections/scripts/...`), so a merged manifest needs
two roots and the engine takes one. `Join-InstallerPath` also rejects
rooted values outright, so absolute paths are not a workaround - that
rejection is deliberate, to stop an entry escaping its share root.
This is why the real dispatcher does NOT merge: `common/GE-Enforce.ps1`
builds a target list and calls the engine ONCE PER SCOPE with
`-InstallerRoot $t.Root`. Any fix should follow that shape.
Until it is fixed, a share-attached cohort can still run SHADOW mode
(below), which is unaffected: shadow sets InstallerRoot to the share scope
dir, which is the working path.
2. **Get the scope into shopdb.** Existing share manifest: 2. **Get the scope into shopdb.** Existing share manifest:
`flask geenforce parity` then `flask geenforce import-share --scope `flask geenforce parity` then `flask geenforce import-share --scope
<name>`. New/reworked scope: author in code following <name>`. New/reworked scope: author in code following
@@ -694,7 +728,7 @@ Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`,
`curl "{BaseUrl}/api/geenforce/manifest?pctype=<scope>"` from an `curl "{BaseUrl}/api/geenforce/manifest?pctype=<scope>"` from an
allowlisted host. allowlisted host.
5. **Auth for the PCs.** Subnet already inside 5. **Auth for the PCs.** Subnet already inside
`10.134.48.0/23,10.48.249.0/26` -> token-less, nothing to do. New subnet `192.0.2.0/24,198.51.100.0/26` -> token-less, nothing to do. New subnet
-> add its CIDR to `geenforce_allowed_cidrs` (Settings rail validates). -> add its CIDR to `geenforce_allowed_cidrs` (Settings rail validates).
Not network-trustable -> mint a `geenforce.fetch` token resource-bound to Not network-trustable -> mint a `geenforce.fetch` token resource-bound to
the scope and deliver it to HKLM `ApiToken`. the scope and deliver it to HKLM `ApiToken`.

View File

@@ -12,10 +12,22 @@ specialists, and they ask assistants for help. Two documents are authoritative:
- `docs/OPERATE-WINDOWS.md` - restart, logs, backups, upgrades, troubleshooting. - `docs/OPERATE-WINDOWS.md` - restart, logs, backups, upgrades, troubleshooting.
Both ship in `docs/` inside the install directory on every installed server. Both ship in `docs/` inside the install directory on every installed server.
Do NOT walk someone through `docs/INSTALL-WINDOWS-IIS.md` or `docs/START-HERE.md` routes a reader by what they are here to do;
`docs/DEPLOY-WINDOWS-IIS.md` for a new site. Those are the MANUAL procedure, kept `docs/FLEET-ARCHITECTURE.md` is one page on how the server, GE-Enforce, the
only for hand-built servers that predate the installer; following them produces a asset reporter, the backup collectors and EventSaver relate - worth reading
server the installer then refuses to upgrade. before any of their individual pages, each of which assumes the others.
Another site adopting the shop-floor tools - the asset reporter that feeds the
collector API, and the EventSaver screensaver - should read
`docs/ADOPTING-AT-ANOTHER-SITE.md`. It has worked deployment examples for Intune
(including Machine Configuration/DSC), GE-Enforce and manual installation.
Neither tool is site-specific: the server URL, the API key and the targeting are
inputs, not code.
Do NOT walk someone through `docs/INSTALL-WINDOWS-IIS.md` for a new site. That
is the MANUAL procedure, kept only for hand-built servers that predate the
installer; following it produces a server the installer then refuses to
upgrade.
Day-2 operations all go through `shopdb-admin.ps1` in the install directory Day-2 operations all go through `shopdb-admin.ps1` in the install directory
(default `C:\shopdb-flask`): `status`, `restart`, `logs`, `check`, `verify`, (default `C:\shopdb-flask`): `status`, `restart`, `logs`, `check`, `verify`,
@@ -42,7 +54,8 @@ Do NOT hand-write CSV templates - generate them. User accounts are deliberately
not CSV-importable. not CSV-importable.
## Base URL ## Base URL
Prod (West Jefferson): `https://tsgwp00525.wjs.geaerospace.net/shopdb` Each site runs its own instance (ADR-004), so there is no single production URL:
it is whatever that site serves, e.g. `https://shopdb.example.net/shopdb`.
All API paths are under `/api` (e.g. `<base>/api/assets`). Dev: `http://localhost:5001`. All API paths are under `/api` (e.g. `<base>/api/assets`). Dev: `http://localhost:5001`.
## Auth ## Auth
@@ -63,9 +76,12 @@ Auth level per endpoint is in the OpenAPI `security` field: `bearerAuth`,
## Response envelope ## Response envelope
JSON endpoints return `{ "status": "success", "data": <payload>, "meta": {...} }`. JSON endpoints return `{ "status": "success", "data": <payload>, "meta": {...} }`.
Errors: `{ "status": "error", "message": "...", "code": "..." }` with an HTTP 4xx/5xx. Errors nest under `data`, NOT at the top level:
Lists include `meta.total` / pagination. A few feed endpoints (screensaver, some `{ "status": "error", "data": { "error": { "code": "...", "message": "...", "details": {...} } } }`
installer text formats) return raw text/JSON without the envelope - noted per route. with an HTTP 4xx/5xx. Reading `message` or `code` off the root gives `undefined`.
Lists carry `meta.pagination.{page, perpage, total, pages}` - again nested, not
`meta.total`. A few feed endpoints (screensaver, some installer text formats)
return raw text/JSON without the envelope - noted per route.
## Common recipes ## Common recipes
- Search everything: `GET /api/search?q=<term>` (multi-word = AND across words). - Search everything: `GET /api/search?q=<term>` (multi-word = AND across words).
@@ -89,6 +105,7 @@ installer text formats) return raw text/JSON without the envelope - noted per ro
- Plugin endpoints live under the plugin's prefix (`/api/<plugin>/...`). - Plugin endpoints live under the plugin's prefix (`/api/<plugin>/...`).
## Full reference ## Full reference
- Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1, 362 operations). - Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1). Operation and
plugin counts live in `docs/PROJECT-MAP.md`, which is generated.
- Interactive: `GET /api/docs` (Redoc). - Interactive: `GET /api/docs` (Redoc).
- Human reference: `docs/API-REFERENCE.md`. - Human reference: `docs/API-REFERENCE.md`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
# Proposal: a dashboard that shows the fleet, not the row count
- Status: IMPLEMENTED (a proposal, not a decision record - the
contract it introduced is ADR-010 and contract 0.19.0)
- Date: 2026-08-11
- Author: ShopDB maintainers
- Relates to: ADR-010 (frontend plugin hooks), ADR-013 / ADR-014 (lean per-site builds), ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership)
## The problem
The dashboard reports totals: assets, machines, PCs, network devices, printers,
measuring tools. Those numbers are true every day and actionable on none of
them. Nobody opens the page to learn there are 417 assets.
Meanwhile the system already knows things worth acting on and shows them
nowhere:
- a bay returned 500 to every collector report for a day and a half, and the
only evidence was a log file on the PC
- eleven part markers' backups were overwriting each other for weeks
- a PC can stop reporting entirely and nothing says so
- GE-Enforce records enforcement failures, stale manifests and repeated
self-heals, and none of it surfaces outside a report modal
Every one of those was found by someone going to look. The dashboard should be
where they find you.
## What already exists
Two discoveries shaped this proposal.
**The plugin mechanism is built and dead.** `BasePlugin.get_dashboard_widgets`
exists, `GET /api/dashboard/widgets` aggregates across enabled plugins, skips
disabled ones, isolates a broken plugin in production and sorts by position.
Five plugins - notifications, network, machines, computers, printers - already
declare widgets.
**Nothing consumes it.** The frontend never calls the endpoint, and not one of
the declared components (`PrinterStatusWidget`, `ComputerStatusWidget`,
`MachineStatusWidget`, `NotificationsWidget`, `NetworkStatusWidget`) exists.
The declarations point at components that were never written.
So this is less "build a dashboard" than "finish one", plus a contract fix.
**The data is already collected.** Nothing here needs a new collector field:
| Signal | Source |
|---|---|
| PC reporting / silent | `computers.lastreporteddate` |
| Machine has a controlling PC | `controls` links, `collector:machine` |
| Backed up, stale, never | `backuprevisions` per asset and kind |
| Enforcement failed | `manifestenforcementreports.status`, `failedcount` |
| Which entry failed, and why | `manifestenforcementresults.action`, `exitcode`, `message` |
| Stale manifest | report `appliedversion` versus the scope's published version |
| Drift that will not stick | repeated `selfhealed` results for one entry |
| Enforcer version spread | `manifestenforcementreports.enforcerversion` |
| Installed software and versions | `computerinstalledapps` |
| Toner critical / low | printer supplies, Zabbix-fed |
| Warranty expiring | warranty plugin |
| Mis-numbered bays | the `check-shared-machines` query |
## Proposed change to the widget contract
The existing contract names a **component per widget**. That does not scale and
does not survive a lean build: every widget needs a bespoke Vue component, and a
plugin-provided component has to be staged into the frontend bundle to exist at
all.
ADR-010 already solved this for asset panels: a plugin declares `render: 'list'`
or `'keyvalue'` or `'tabs'` plus a `map` of fields, and a GENERIC core component
renders it. Do the same here. A plugin declares data and shape; core owns the
rendering.
```python
{
'id': 'geenforce-failures',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions', # generic renderer
'severity': 'critical', # critical | warning | info
'permission': 'geenforce.view',
'position': 10,
'empty': 'hide', # hide | line
'map': {
'title': 'hostname',
'detail': 'entryname',
'meta': [{'key': 'message'}, {'key': 'exitcode'}],
'link': '/pcs/{computerid}',
},
}
```
Three renderers cover everything listed above:
- `exceptions` - a list of things that are wrong, each linking to the thing
- `metric` - a single number with a trend or threshold, for the cases where the
count IS the story (toner critical: 3)
- `list` - recent items, for notifications and KB adds
## Principles
**An exception board, not a stat board.** The default view is what needs a
person today. A count appears only where the count is the story.
**Empty cards must shrink.** A card that says "nothing wrong" every day trains
people to stop reading the page. That is exactly how `ntlars-backup.log` reached
3,234 lines with 17 that mattered. `empty: 'hide'` is the default; `'line'` for
the few where absence is itself news.
**Every row links to the thing.** A dashboard that says three printers are low
without linking to them is a worse version of a report.
**Cards respect RBAC.** A card declares a `permission`; a user without it never
sees the card or its endpoint. Toner levels are not sensitive, but "which PCs
expose VNC" is, and the dashboard must not become a way around role gating.
**Thresholds are settings with neutral defaults** (ADR-015): quiet window, toner
percentage, warranty horizon. Every site will disagree with the numbers.
**One endpoint per card, lazily loaded, failing independently.** A hung Zabbix
call must not blank the page.
## Wave one
Cards whose data is already reliable, ordered by how actionable they are:
1. **Enforcement failures** - current reports with `status = failed`, showing
the failing entry and exit code.
2. **PCs not reporting** - `lastreporteddate` beyond the quiet window, and PCs
with no GE-Enforce report at all, which is a different and worse case.
3. **Backups missing or stale** - machines with no revision of a kind their
PC type should produce, and revisions older than the interval.
4. **Toner critical, then low** - links to the printer, then to its admin page.
5. **Warranties expiring** - within the configured horizon.
6. **Mis-numbered bays** - `check-shared-machines`, promoted from a CLI command
nobody will remember to run.
Then, lower and quieter: stale manifest versions, repeated self-heals, enforcer
version spread, active notifications, recent application and KB additions.
## Wave two: desired versus observed
The highest-value card and the most work. The manifest declares what a PC type
should have; `computerinstalledapps` records what it does; `filters.py` already
decides which entries apply to a given PC. Diffing those answers "which bays are
missing something they should have" - which nothing in the system answers today,
and which is the thing GE-Enforce exists to guarantee.
Deferred to wave two because it needs the resolver wired into a query path, and
because wave one needs nothing new.
## Risks
**Aggregate queries on the most-visited page.** These are fleet-wide scans on
every load. Cache 30-60 seconds, and log slow cards - a lazily-loaded card can
be slow for months before anyone mentions it.
**Cards become the new noise.** Six cards that are always empty are as useless
as one number that never changes. If a card is empty for a month, delete it.
**Plugin gating must be real.** A lean site without `printers` must render no
toner card. The existing endpoint already skips disabled plugins; the frontend
must not hardcode a card list alongside it.
## Non-goals
- Per-user dashboard customisation. Later, if asked for.
- Historical charting. This is a "what needs doing now" board; trends belong in
reports.
- Replacing the shopfloor TV dashboard, which is a different audience with
different needs.

View File

@@ -6,7 +6,7 @@ Author: planning session 2026-07-12.
## 1. What this is ## 1. What this is
Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type
`manifest.json` files off an SMB share (`\\tsgwp00525.wjs.geaerospace.net\ `manifest.json` files off an SMB share (`\\shopdb.example.net\
shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts
the share, reads the manifest for the machine's PC type, and installs or the share, reads the manifest for the machine's PC type, and installs or
self-heals apps, files, drivers, registry values, and scripts. A parallel self-heals apps, files, drivers, registry values, and scripts. A parallel
@@ -42,8 +42,8 @@ edited JSON on a file share.
Source of truth for these field names (do not invent others): Source of truth for these field names (do not invent others):
- Schema: `pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_meta/manifest-schema.json` - Schema: `<imaging-share>/SHOPDBHOST-v2/shared/dt/shopfloor/_meta/manifest-schema.json`
- Engine: `pxe-images/common/lib/Install-FromManifest.ps1` - Engine: `<imaging-share>/common/lib/Install-FromManifest.ps1`
- Dispatcher: `.../shopfloor/common/GE-Enforce.ps1` - Dispatcher: `.../shopfloor/common/GE-Enforce.ps1`
- Architecture: `pxe/docs/ge-enforce-v2-architecture.md` - Architecture: `pxe/docs/ge-enforce-v2-architecture.md`

View File

@@ -0,0 +1,331 @@
# Proposal: assign printers to a machine in ShopDB, let the PC install them
Status: ACCEPTED. Server half being built 2026-08-18 (relationship types and
rails, resolution helper, two endpoints, PC form section, `drivername`). The
client script is not built.
Author: planning session 2026-08-18.
## 1. What this is
Today a printer reaches a shop-floor PC because a person walks up to it, opens
the printer installer, finds the printer on a floor plan and clicks it. That is
fine for someone choosing a printer, and wrong for a bay whose printers are a
property of the bay.
This proposal makes the assignment data. The printers belong to the MACHINE, not
to the box currently driving it: tick the printers that belong on the machine,
mark one default, and the assignment reaches whichever PC controls that machine.
The PC converges on its next GE-Enforce cycle - installing what is missing and
setting the default - and keeps converging, so a reimaged bay comes back with
its printers and a bay that drifts is corrected.
The point of putting the assignment on the machine is that a reimaged PC needs
no backup and no restore step. The asset register is the source of truth, and a
replacement PC that inherits the `controls` edge inherits the printers with it.
The map installer stays, for the case it is actually good at: a person at an
unmanaged or office PC picking a printer that nobody assigned.
## 2. Why it is worth doing
- **The assignment becomes a record.** "Which printers does that bay have" is a
question ShopDB can answer, and today it cannot.
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
the machine's record, with nothing saved off the old PC.
- **Swapping the PC keeps the printers.** They were never the PC's.
- **Drift is corrected, not just detected.** A queue deleted by a user comes
back.
- **It removes the walk-up from the common case.** The installer's map remains
for the uncommon one.
## 3. What already exists
Most of the model is in place, which is why this is a small feature rather than
a project.
| piece | state |
|---|---|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
| Per-printer install path | `Printer.installpath` |
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
## 4. The data model
### 4.1 One new relationship type
`usesprinter`, directional, source -> printer, meaning "this printer is
installed here". It is seeded next to `defaultprinter`, which already exists and
means "which of them is the default". Both are seed data, not a migration, which
is how every other relationship type shipped.
### 4.2 Two propagation rails, consumed at READ time
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
are rows in `relationshiptypepropagations`, the same mechanism map positions
use. Inventing a second mechanism for this was the alternative, and it was
rejected.
The rails are inert at write time on purpose. The create-time fan-out
(`propagate_relationship`) skips directional through-types, and `controls` is
directional, so assigning a printer to a machine does not copy rows onto its PC.
The walk happens when something asks, which is what makes the next rule possible.
### 4.3 Resolution order for a PC
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
and those machines' rows instead, tagged as inherited.
Own beats inherited, whole set at a time: a PC with its own assignment is
overriding the bay, not adding to it. An office PC controls no machine and still
works, because step 1 is the normal case for it.
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
by editing the PC has shadowed the machine's record, and the machine will keep
disagreeing until someone clears the PC's own rows.
### 4.4 One default, optional, and never dangling
The unique constraint is `(source, target, type)`, which happily accepts two
different defaults. So the rule is enforced in the API on write:
- Exactly one `defaultprinter` per asset. Setting a default replaces the
existing one.
- A default is OPTIONAL. A bay with three printers and no default is valid.
- The default must be one of the assigned printers. Unassigning the printer that
is currently default clears the default rather than leaving it dangling.
### 4.5 One column on `printerdrivers`
`drivername` - the driver's exact name as the INF declares it, e.g.
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
on `name`, which is ours to choose, and a mismatch is the usual failure.
Deriving it by parsing the INF on hundreds of bays is fragile; a human
confirming it once in ShopDB is not.
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
moved to the printers chain at the ADR-008 cutover.
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
built. See section 8: if production confirms no Brother printers, everything is
`pnputil` and the column has no second value to hold.
## 5. What has to be built
### 5.1 A resolution helper in core
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
core because `RelationshipTypePropagation` is not on the contract surface, and a
plugin may not reach past it (ADR-002).
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
so a site that adds a rail gets the behaviour without a code change.
### 5.2 Two endpoints
```
GET /api/printers/for-host/<hostname>
```
The desired printer set for one PC, resolved per section 4.3, each entry with
what a client needs to install it: queue name, host or IP, port, driver name,
driver location, and which one is default.
Resolved by hostname, not machine number: the collector already upserts PCs by
hostname, and an office PC has no machine number. Matched case-insensitively -
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
An unknown host, a site without the computers plugin, or nothing assigned all
return an empty set. That is the client's designed no-op and it must stay
indistinguishable from "assigned nothing".
```
PUT /api/printers/assignments/for-asset/<asset_id>
```
The whole assignment for one asset - machine or PC - in one call:
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
than inserting: rows that went away are soft-deleted, rows that come back
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
blind insert is an integrity error on assign, unassign, re-assign), new rows are
created, and the single default is replaced.
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
relationships path leave two-default windows and know nothing of the subset rule.
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
goes: no cascade, no side effects, nothing queued for the client to undo.
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
and PC type and sync broadly; putting per-PC rows there would leak every bay's
configuration to every bay and grow without limit. One manifest entry runs one
script that asks the API what THIS host gets - the mirror image of
`Report-AssetToShopDB.ps1`.
### 5.3 UI on the PC form
A printer multi-select plus a default dropdown whose options are only the
currently selected printers, clearing itself when its printer is deselected.
Saved through the reconcile endpoint against the PC's asset.
The computers plugin does not depend on the printers plugin and must not start:
the section hides itself when the printers API is not there.
The machine-side picker is out of scope for now, which means the machine's
assignment is editable only through the generic relationships card. That is the
side the design says is primary, so it is the obvious next piece of UI.
### 5.4 One client script, in two contexts
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
*As SYSTEM, every cycle:*
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
2. For each assigned printer with no queue: trust the driver catalog's cert,
`pnputil /add-driver`, create the port, create the queue
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
4. Ensure the per-user task exists
*In the user's context, at logon and on a repeat:*
5. Read that value, compare with the current default, set it if it differs, and
clear "Let Windows manage my default printer" - otherwise Windows silently
overrides the choice the next time someone prints elsewhere
The default printer is per-user state, which is the only reason this needs two
contexts. Everything else is machine state and belongs to the cycle.
Converge, do not reinstall: when the state matches, the script does nothing.
Nothing here needs the manifest to know when a printer changes, because the
desired state is fetched, not declared.
## 6. Desired state and observed state are not the same thing
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
this feature knows what IS installed on it. The client reads the desired state,
converges toward it, and reports nothing back.
Reporting the observed state is the obvious next feature and is deliberately not
this one. If the collector sent the installed queues per host - name, port,
driver, which is default - then comparing that against the resolved assignment
gives drift detection for free: "this bay is missing the label printer", "this
PC has three queues nobody assigned", "the default is not the assigned one".
Keeping them apart is a rule, not a preference:
- **Observed data never writes `usesprinter` rows.** A register that learns from
what it finds mirrors the drift instead of correcting it, and the fault
becomes the desired state.
- **Observed data belongs on the computer record, timestamped**, like the rest
of the collector payload. It is an observation with an age, not a decision.
- **An empty answer from the API means "nothing assigned", not "nothing
installed"**, which is exactly why section 5.2 refuses to make removal
uninstall anything.
- The two can disagree indefinitely and that is a report to read, not an error
to resolve automatically.
## 7. Decisions to take before writing the client
1. **Never remove a queue by default.** A transient API failure would otherwise
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
user who chose their own default - correct for a locked bay, irritating on an
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
rather than one global answer.
3. **Failure is silent and safe**: unreachable API means change nothing, log,
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
Open on the server side, and each one changes the response contract:
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
exact `modelnumberid`, and the target state is roughly four rows dominated by
HP UPD and Xerox GPD, which match no single model. Either the driver row
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
resolved vendor name. Until this is settled, `for-host` returns no driver for
41 of 44 printers.
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
job it is to apply it - server or script - has to be written down once.
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
the collector and the GE-Enforce fetch use scoped service tokens. This one
discloses per-PC configuration keyed by hostname.
7. **Two inherited defaults.** A PC can legitimately control both bays of a
dual-bay machine, or several machines. The union of assigned printers is
easy; the default needs a deterministic rule, or none when it is ambiguous.
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
predate the type. Either an active default implies assignment on read
(zero-touch, preferred) or a one-time backfill writes the missing rows.
Otherwise existing defaults vanish from `for-host` while still showing in
`pc-default`.
9. **Deletions through the generic relationships card bypass the reconcile
endpoint** and can strand an active default pointing at an unassigned
printer. Either the resolver drops dangling defaults or the core delete path
learns the rule.
## 8. What the fleet data says, and the one prerequisite
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
Xerox Global Print Driver). One driver record each serves every queue of that
make.
- **There are no Brother printers at all**, yet the installer carries 208 files
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
with no Printer-class INF, which is the only reason a second staging method
(DPInst) exists. If production confirms no Brother, that payload and that code
path can both go - and with them the `installmethod` column.
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
a fourth special case - a PostScript device the UPD does not cover.
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
universal driver - the opposite of how a UPD should be used. The table needs
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
payload is restored. Each needs `drivername` copied verbatim from its INF.
Nothing in this proposal works until a printer can resolve to a driver.
## 9. Deployment constraint that shapes the design
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
a share path must run as a manifest entry inside that cycle, never as its own
scheduled task. The failure is silent - the task reports 0 processed, 0
installed, 0 failed - and it has cost a session before.
This is why driver staging belongs in the cycle even though the per-user default
does not, and why "the assignment script schedules a task that installs drivers"
is the wrong shape.
## 10. Upgrading an existing site
Three steps, and the third is the one that gets forgotten:
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
1054 unknown-column error.
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
because empty is also the healthy answer.
Pair the upgrade with a smoke check against a known bay. A site with reversed
legacy `controls` rows (machine -> PC) should run
`flask relationships fix-controls-direction` first, or inheritance resolves for
none of those PCs.
## 11. What this does not change
- The printer installer keeps working, for walk-up and self-service.
- Nothing about how printers are modelled, mapped or reported.
- The collector contract. Section 6 would change it; this feature does not.
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.

View File

@@ -1,6 +1,6 @@
{ {
"name": "shopdb-frontend", "name": "shopdb-frontend",
"version": "0.9.0", "version": "0.11.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -50,6 +50,30 @@ api.interceptors.response.use(
export default api export default api
// The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it,
// so asking for `perpage: 1000` silently returns the first 100 rows and drops
// the rest. A picker built that way looks complete and is not: with 126
// applications on a live site, the 26 sorting last were simply unselectable.
//
// Use this wherever a control needs the WHOLE list (dropdowns, pickers, label
// batches) rather than a page of it. Returns the full array directly, not an
// axios response. Anything that renders a paged table should keep calling
// list() with a real page number instead.
export async function fetchAllPages(path, params = {}) {
const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } })
let items = first.data.data || []
const totalpages = first.data.meta?.pagination?.totalpages || 1
if (totalpages > 1) {
const rest = await Promise.all(
Array.from({ length: totalpages - 1 }, (_, i) =>
api.get(path, { params: { ...params, perpage: 100, page: i + 2 } })
)
)
rest.forEach(response => { items = items.concat(response.data.data || []) })
}
return items
}
// Auth API // Auth API
export const authApi = { export const authApi = {
login(username, password) { login(username, password) {
@@ -77,6 +101,13 @@ export const machinesApi = {
list(params = {}) { list(params = {}) {
return api.get('/machines', { params }) return api.get('/machines', { params })
}, },
// Every machine, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/machines', params)
},
get(id) { get(id) {
return api.get(`/machines/${id}`) return api.get(`/machines/${id}`)
}, },
@@ -120,6 +151,13 @@ export const computersApi = {
list(params = {}) { list(params = {}) {
return api.get('/computers', { params }) return api.get('/computers', { params })
}, },
// Every PC, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/computers', params)
},
displayKiosks() { displayKiosks() {
return api.get('/computers/display-kiosks') return api.get('/computers/display-kiosks')
}, },
@@ -268,6 +306,13 @@ export const printersApi = {
list(params = {}) { list(params = {}) {
return api.get('/printers', { params }) return api.get('/printers', { params })
}, },
// Every printer, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/printers', params)
},
get(id) { get(id) {
return api.get(`/printers/${id}`) return api.get(`/printers/${id}`)
}, },
@@ -306,6 +351,9 @@ export const printersApi = {
getDrivers(id) { getDrivers(id) {
return api.get(`/printers/${id}/drivers`) return api.get(`/printers/${id}/drivers`)
}, },
supplyForecast(days = 90) {
return api.get('/printers/supplies/forecast', { params: { days } })
},
lowSupplies() { lowSupplies() {
return api.get('/printers/lowsupplies') return api.get('/printers/lowsupplies')
}, },
@@ -385,19 +433,8 @@ export const modelsApi = {
// Backend caps perpage at 100, so page through every model. Returns the // Backend caps perpage at 100, so page through every model. Returns the
// full array directly (not an axios response). Use in forms whose model // full array directly (not an axios response). Use in forms whose model
// dropdown must include the editing record's model regardless of page. // dropdown must include the editing record's model regardless of page.
async listAll() { listAll(params = {}) {
const first = await api.get('/models', { params: { perpage: 100, page: 1 } }) return fetchAllPages('/models', params)
let items = first.data.data || []
const totalpages = first.data.meta?.pagination?.totalpages || 1
if (totalpages > 1) {
const rest = await Promise.all(
Array.from({ length: totalpages - 1 }, (_, i) =>
api.get('/models', { params: { perpage: 100, page: i + 2 } })
)
)
rest.forEach(r => { items = items.concat(r.data.data || []) })
}
return items
}, },
get(id) { get(id) {
return api.get(`/models/${id}`) return api.get(`/models/${id}`)
@@ -465,6 +502,12 @@ export const applicationsApi = {
list(params = {}) { list(params = {}) {
return api.get('/applications', { params }) return api.get('/applications', { params })
}, },
// Every application, paged past the backend's 100-row cap. The catalogue is
// already over 100 entries on a live site, so any picker offering "all
// applications" must use this and not list({ perpage: <big number> }).
listAll(params = {}) {
return fetchAllPages('/applications', params)
},
get(id) { get(id) {
return api.get(`/applications/${id}`) return api.get(`/applications/${id}`)
}, },
@@ -477,6 +520,33 @@ export const applicationsApi = {
delete(id) { delete(id) {
return api.delete(`/applications/${id}`) return api.delete(`/applications/${id}`)
}, },
// multipart image upload; backend sets image to the served URL
uploadImage(id, file) {
const form = new FormData()
form.append('file', file)
return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removeImage(id) {
return api.delete(`/applications/${id}/image`)
},
// installer upload; backend sets installpath to the download URL.
// onProgress gets 0-100 - an installer is big enough that a silent wait
// reads as a hang.
uploadPackage(id, file, onProgress) {
const form = new FormData()
form.append('file', file)
return api.post(`/applications/${id}/package`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: event => {
if (onProgress && event.total) {
onProgress(Math.round((event.loaded * 100) / event.total))
}
}
})
},
removePackage(id) {
return api.delete(`/applications/${id}/package`)
},
// Versions // Versions
getVersions(appId) { getVersions(appId) {
return api.get(`/applications/${appId}/versions`) return api.get(`/applications/${appId}/versions`)
@@ -683,6 +753,13 @@ export const usbApi = {
list(params = {}) { list(params = {}) {
return api.get('/usb', { params }) return api.get('/usb', { params })
}, },
// Every USB device, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/usb', params)
},
get(id) { get(id) {
return api.get(`/usb/${id}`) return api.get(`/usb/${id}`)
}, },
@@ -833,6 +910,63 @@ export const setupApi = {
} }
} }
// Buildings and the levels within them (ADR-017). Reads are public - the
// printer installer map draws a blueprint before anyone logs in.
export const mapLevelsApi = {
list() {
return api.get('/maplevels')
},
get(levelid) {
return api.get(`/maplevels/${levelid}`)
},
createBuilding(payload) {
return api.post('/maplevels/buildings', payload)
},
updateBuilding(buildingid, payload) {
return api.patch(`/maplevels/buildings/${buildingid}`, payload)
},
create(payload) {
return api.post('/maplevels', payload)
},
update(levelid, payload) {
return api.patch(`/maplevels/${levelid}`, payload)
},
remove(levelid) {
return api.delete(`/maplevels/${levelid}`)
},
// Returns the image's real pixel size alongside the stored dimensions. On an
// empty level the server adopts them; on a populated one it refuses and says
// so, because changing the coordinate space moves every marker on it.
uploadBlueprint(levelid, theme, file) {
const form = new FormData()
form.append('file', file)
form.append('theme', theme)
return api.post(`/maplevels/${levelid}/blueprint`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
}
// Bulk marker positions: the landmark transform, bulk placement, review state
// and undo. Every write snapshots first.
export const mapPositionsApi = {
setPositions(positions, verified = true) {
return api.post('/mappositions/positions', { positions, verified })
},
transform(payload) {
return api.post('/mappositions/transform', payload)
},
verify(assetids, unverify = false) {
return api.post('/mappositions/verify', { assetids, unverify })
},
snapshots() {
return api.get('/mappositions/snapshots')
},
restore(snapshotid) {
return api.post(`/mappositions/snapshots/${snapshotid}/restore`)
},
}
export const settingsApi = { export const settingsApi = {
list(params = {}) { list(params = {}) {
return api.get('/settings', { params }) return api.get('/settings', { params })
@@ -959,6 +1093,13 @@ export const networkApi = {
list(params = {}) { list(params = {}) {
return api.get('/network', { params }) return api.get('/network', { params })
}, },
// Every network device, paged past the backend's 100-row cap. Batch label
// printing and "pick any record" dropdowns must use this: list() with a
// large perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/network', params)
},
get(id) { get(id) {
return api.get(`/network/${id}`) return api.get(`/network/${id}`)
}, },
@@ -1088,6 +1229,15 @@ export const warrantyApi = {
}, },
report() { report() {
return api.get('/warranty/report') return api.get('/warranty/report')
},
// proof of cover: invoice, certificate, whatever the vendor sent
uploadProof(id, file) {
const form = new FormData()
form.append('file', file)
return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removeProof(id) {
return api.delete(`/warranty/${id}/proof`)
} }
} }
@@ -1096,6 +1246,13 @@ export const measuringtoolsApi = {
list(params = {}) { list(params = {}) {
return api.get('/measuringtools', { params }) return api.get('/measuringtools', { params })
}, },
// Every measuring tool, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so
// the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/measuringtools', params)
},
get(id) { get(id) {
return api.get(`/measuringtools/${id}`) return api.get(`/measuringtools/${id}`)
}, },
@@ -1139,6 +1296,11 @@ export const printedpartsApi = {
list(params = {}) { list(params = {}) {
return api.get('/printedparts/items', { params }) return api.get('/printedparts/items', { params })
}, },
// Every printed item, paged past the backend's 100-row cap. The label sheet
// must print the whole selection, not the first page of it.
listAll(params = {}) {
return fetchAllPages('/printedparts/items', params)
},
get(printeditemid) { get(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}`) return api.get(`/printedparts/items/${printeditemid}`)
}, },

View File

@@ -33,6 +33,10 @@
--sidebar-bg: #00003d; --sidebar-bg: #00003d;
--sidebar-text: #ffffff; --sidebar-text: #ffffff;
--sidebar-width: 250px; --sidebar-width: 250px;
/* Vertical chrome around .main-content. A full-height page subtracts these;
hardcoding a different number is what put a scrollbar on the map. */
--main-pad-top: 20px;
--main-pad-bottom: 70px;
/* Hover variants */ /* Hover variants */
--secondary-dark: #82503f; --secondary-dark: #82503f;
@@ -243,7 +247,7 @@ h1, h2, h3, h4, h5, h6 {
.main-content { .main-content {
flex: 1; flex: 1;
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
padding: 20px 10px 70px 10px; padding: var(--main-pad-top) 10px var(--main-pad-bottom) 10px;
overflow-x: hidden; overflow-x: hidden;
} }

View File

@@ -0,0 +1,271 @@
<template>
<div v-if="visibleCards.length" class="dc-grid">
<section
v-for="card in visibleCards"
:key="card.id"
class="dc-card"
:class="'dc-' + (card.severity || 'info')"
>
<header class="dc-head">
<span class="dc-dot" aria-hidden="true"></span>
<h3 class="dc-title">{{ card.title }}</h3>
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
</header>
<!-- metric: the count IS the story -->
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<!-- exceptions / list: one line per thing, each linking to itself -->
<ul v-else class="dc-rows">
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
<!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</ul>
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
quiet and leaving them to find the list is worse than not saying it.
A card without a viewall still says the count, because the number
itself is information. -->
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
</div>
</template>
<script setup>
// Renders the dashboard cards plugins declare via get_dashboard_widgets
// (GET /api/dashboard/widgets). Core owns the renderers; a plugin declares data
// and shape. See docs/proposals/dashboard-live-fleet.md.
//
// Each card fetches INDEPENDENTLY and a failure is swallowed to null, so one
// slow or broken endpoint - a hung Zabbix call, a plugin mid-upgrade - cannot
// blank the board. A card whose fetch failed is hidden rather than drawn empty,
// because an empty card and a broken card must not look the same.
import { ref, computed, onMounted } from 'vue'
import api from '../api'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import {
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
permittedCards, renderableCards, rows as cardData,
} from './dashboardCards'
const auth = useAuthStore()
const cards = ref([])
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
function countOf(card) {
return cardData(card).length
}
async function load() {
let declared
try {
const response = await api.get('/dashboard/widgets')
declared = response.data.data || []
} catch (err) {
return
}
// Filter BEFORE fetching: no point firing a request that would only 403, and
// the permission gate belongs on both sides regardless.
const wanted = renderableCards(
permittedCards(declared, (name) => auth.hasPermission(name)))
cards.value = await Promise.all(wanted.map(async (card) => {
try {
const response = await api.get(toApiPath(card.endpoint))
return { ...card, _data: response.data.data }
} catch (err) {
return { ...card, _data: null }
}
}))
}
onMounted(load)
defineExpose({ load })
</script>
<style scoped>
.dc-grid {
display: grid;
/* Four across on a wide screen. A card holds a hostname, a machine number
and a state on one line, so the track cannot go much below this without
truncating the rows that matter most - but 28rem only fits three once the
sidebar takes its share at 1920, which is the common case here. auto-fit
rather than auto-fill so two cards fill the width instead of leaving
empty tracks. */
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
max-width: 120rem;
gap: 1rem;
margin-bottom: 1.75rem;
align-items: start;
}
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
alone this reaches five on an ultrawide, and a fifth column buys nothing -
the cards get narrower and the rows they hold start truncating again. */
@media (min-width: 96rem) {
.dc-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.dc-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
/* Nothing escapes the card. A printer row carrying three cartridge readings
plus a location was pushing past the border: a flex child will not shrink
below its content width unless told to, so text-overflow never engaged and
the row simply overflowed. min-width:0 below is what actually enables the
ellipsis; this is the backstop. */
overflow: hidden;
min-width: 0;
}
/* Severity is a small dot beside the title, not a coloured card or a thick
bar. A card painted by severity reads as an alert even when it holds one
minor row, and six of them read as a crisis - which is how a board stops
being read at all. */
.dc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
.dc-critical .dc-dot { background: var(--danger); }
.dc-warning .dc-dot { background: var(--warning); }
.dc-info .dc-dot { background: var(--primary); }
.dc-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border);
}
.dc-title {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-light);
flex: 1;
}
.dc-count {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.dc-metric { margin: 0.5rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
/* One row per line, not a wrapped paragraph. Each row is title / detail /
meta on a single line that truncates, so ten rows are ten scannable lines
rather than a block of text that has to be read. */
.dc-rows { list-style: none; margin: 0; padding: 0; }
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
to make room for text that is then truncated anyway, and neither reads. */
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-title {
font-weight: 600;
color: var(--link);
text-decoration: none;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
.dc-row-detail {
color: var(--text);
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Meta is pushed right and allowed to disappear first: it is the least
important part of the line, and letting it wrap is what made rows look
like paragraphs. */
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
/* Shrinks and truncates before the title or detail do: it is the least
important part of the line, and it was the part running off the edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* One chip per depleted supply. Bordered rather than filled: a row of solid
red pills reads as an emergency even when a cartridge is merely low. */
.dc-chip {
flex: none;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.75rem;
color: var(--text);
white-space: nowrap;
cursor: help;
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
.dc-more {
display: block;
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--text-light);
}
.dc-more-link { color: var(--link); text-decoration: none; }
.dc-more-link:hover { text-decoration: underline; }
</style>

View File

@@ -1,141 +1,148 @@
<template> <template>
<div class="embedded-map" ref="mapContainer"></div> <div class="embedded-map" ref="mapContainer"></div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme' import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel } from '../composables/mapConfig'
const props = defineProps({ const props = defineProps({
left: { type: Number, default: null }, left: { type: Number, default: null },
top: { type: Number, default: null }, top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' }, markerColor: { type: String, default: '#ff0000' },
markerLabel: { type: String, default: '' } markerLabel: { type: String, default: '' },
}) // Which drawing left/top belong to (ADR-017). Without it there is no honest
// blueprint to draw, so the map is not initialised at all.
const mapContainer = ref(null) levelid: { type: Number, default: null },
let map = null })
let marker = null
const mapContainer = ref(null)
// Map dimensions - facility blueprint size, loaded from settings. let map = null
let MAP_WIDTH = mapConfig.width let marker = null
let MAP_HEIGHT = mapConfig.height
// This LEVEL's native size, which is what its marker coordinates mean.
function initMap() { let MAP_WIDTH = 0
if (!mapContainer.value || props.left === null || props.top === null) return let MAP_HEIGHT = 0
MAP_WIDTH = mapConfig.width function initMap() {
MAP_HEIGHT = mapConfig.height if (!mapContainer.value || props.left === null || props.top === null) return
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]] // No level, no drawing. Rendering the default blueprint under these
// coordinates would look right and be wrong; an empty box is honest.
map = L.map(mapContainer.value, { if (!hasLevel(props.levelid)) return
crs: L.CRS.Simple,
minZoom: -3, const dimensions = dimensionsFor(props.levelid)
maxZoom: 2, MAP_WIDTH = dimensions.width
attributionControl: false, MAP_HEIGHT = dimensions.height
zoomControl: true const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
})
map = L.map(mapContainer.value, {
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map) crs: L.CRS.Simple,
minZoom: -3,
// Convert database coordinates to Leaflet (y is inverted) maxZoom: 2,
const leafletY = MAP_HEIGHT - props.top attributionControl: false,
const leafletX = props.left zoomControl: true
})
// Create marker
const icon = L.divIcon({ L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20], // Convert database coordinates to Leaflet (y is inverted)
iconAnchor: [10, 10], const leafletY = MAP_HEIGHT - props.top
className: 'location-marker' const leafletX = props.left
})
// Create marker
marker = L.marker([leafletY, leafletX], { icon }) const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
if (props.markerLabel) { iconSize: [20, 20],
marker.bindTooltip(props.markerLabel, { iconAnchor: [10, 10],
permanent: true, className: 'location-marker'
direction: 'top', })
offset: [0, -10],
className: 'location-label' marker = L.marker([leafletY, leafletX], { icon })
})
} if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
marker.addTo(map) permanent: true,
direction: 'top',
// Center on marker with appropriate zoom offset: [0, -10],
map.setView([leafletY, leafletX], -1) className: 'location-label'
map.setMaxBounds(bounds) })
} }
onMounted(async () => { marker.addTo(map)
await loadMapConfig()
initMap() // Center on marker with appropriate zoom
}) map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
onUnmounted(() => { }
if (map) {
map.remove() onMounted(async () => {
map = null await loadMapConfig()
} initMap()
}) })
watch([() => props.left, () => props.top], () => { onUnmounted(() => {
if (map) { if (map) {
map.remove() map.remove()
map = null map = null
} }
initMap() })
})
</script> watch([() => props.left, () => props.top], () => {
if (map) {
<style scoped> map.remove()
.embedded-map { map = null
width: 100%; }
height: 300px; initMap()
border-radius: 8px; })
overflow: hidden; </script>
background: var(--bg);
} <style scoped>
.embedded-map {
:deep(.location-marker) { width: 100%;
background: transparent !important; height: 300px;
border: none !important; border-radius: 8px;
} overflow: hidden;
background: var(--bg);
:deep(.location-marker-dot) { }
width: 20px;
height: 20px; :deep(.location-marker) {
border-radius: 50%; background: transparent !important;
border: 3px solid #fff; border: none !important;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4); }
animation: pulse 2s infinite;
} :deep(.location-marker-dot) {
width: 20px;
@keyframes pulse { height: 20px;
0%, 100% { border-radius: 50%;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4); border: 3px solid #fff;
} box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
50% { animation: pulse 2s infinite;
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4); }
}
} @keyframes pulse {
0%, 100% {
:deep(.location-label) { box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
background: rgba(0, 0, 0, 0.85); }
color: #fff; 50% {
border: none; box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
border-radius: 4px; }
padding: 4px 8px; }
font-size: 0.875rem;
font-weight: 500; :deep(.location-label) {
box-shadow: 0 2px 8px rgba(0,0,0,0.3); background: rgba(0, 0, 0, 0.85);
} color: #fff;
border: none;
:deep(.location-label::before) { border-radius: 4px;
border-top-color: rgba(0, 0, 0, 0.85); padding: 4px 8px;
} font-size: 0.875rem;
</style> font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>

View File

@@ -1,268 +1,307 @@
<template> <template>
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave"> <div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
<slot></slot> <slot></slot>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="visible && hasPosition" v-if="visible && hasPosition"
class="map-tooltip" class="map-tooltip"
:style="tooltipStyle" :style="tooltipStyle"
ref="tooltipRef" ref="tooltipRef"
@mouseenter="onTooltipEnter" @mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave" @mouseleave="onTooltipLeave"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
<div class="map-tooltip-content"> <div class="map-tooltip-content">
<div class="map-preview" ref="mapPreview"> <div v-if="levelUnknown" class="map-level-unknown">
<div <strong>Level unknown</strong>
class="map-transform" <span>
:style="transformStyle" This asset has a position ({{ props.left }}, {{ props.top }}) but no
> level, so there is no drawing to show it on. Set its level on the
<img asset, or place it in the map editor.
:src="blueprintUrl" </span>
alt="Shop Floor Map" </div>
class="map-image" <div v-else class="map-preview" ref="mapPreview">
@load="onImageLoad" <div
/> class="map-transform"
<!-- Marker dot --> :style="transformStyle"
<div >
class="marker-dot" <img
:style="markerStyle" :src="blueprintUrl"
></div> alt="Shop Floor Map"
</div> class="map-image"
</div> @load="onImageLoad"
<div class="map-tooltip-footer"> />
<span class="coordinates">{{ left }}, {{ top }}</span> <!-- Marker dot -->
<span class="zoom-hint">Scroll to zoom</span> <div
</div> class="marker-dot"
</div> :style="markerStyle"
</div> ></div>
</Teleport> </div>
</div> </div>
</template> <div class="map-tooltip-footer">
<span class="coordinates">{{ left }}, {{ top }}</span>
<script setup> <span class="zoom-hint">Scroll to zoom</span>
import { ref, computed, nextTick, watch } from 'vue' </div>
import { currentTheme } from '../stores/theme' </div>
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' </div>
</Teleport>
// Fetch this facility's blueprint + dimensions once; computeds below react </div>
// when it loads. </template>
loadMapConfig()
<script setup>
const props = defineProps({ import { ref, computed, nextTick, watch } from 'vue'
left: { type: Number, default: null }, import { currentTheme } from '../stores/theme'
top: { type: Number, default: null }, import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
machineName: { type: String, default: '' }
}) // Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
const visible = ref(false) loadMapConfig()
const tooltipRef = ref(null)
const mapPreview = ref(null) const props = defineProps({
const tooltipPosition = ref({ x: 0, y: 0 }) left: { type: Number, default: null },
const isOverTooltip = ref(false) top: { type: Number, default: null },
const zoom = ref(1) machineName: { type: String, default: '' },
const imageLoaded = ref(false) // Which drawing left/top are pixels of (ADR-017). A position without one
// cannot be rendered: the same coordinates land somewhere different on every
const hasPosition = computed(() => { // level, so this shows what is missing instead of guessing the default.
return props.left !== null && props.top !== null levelid: { type: Number, default: null },
}) })
const blueprintUrl = computed(() => { const visible = ref(false)
// Reading currentTheme keeps this reactive to theme changes. const tooltipRef = ref(null)
return blueprintUrlFor(currentTheme.value) const mapPreview = ref(null)
}) const tooltipPosition = ref({ x: 0, y: 0 })
const isOverTooltip = ref(false)
// Calculate marker position as percentage of the facility blueprint size const zoom = ref(1)
const markerX = computed(() => { const imageLoaded = ref(false)
return (props.left / mapConfig.width) * 100
}) const hasPosition = computed(() => {
return props.left !== null && props.top !== null
const markerY = computed(() => { })
return (props.top / mapConfig.height) * 100
}) // A position we cannot place: coordinates but no level, or a level this instance
// does not know. Rendering the default blueprint here would look correct and be
// Marker style with counter-scale to maintain constant size // wrong, so the tooltip says so instead.
const markerStyle = computed(() => ({ const levelUnknown = computed(() => {
left: markerX.value + '%', return hasPosition.value && !hasLevel(props.levelid)
top: markerY.value + '%', })
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
})) const levelLabel = computed(() => levelName(props.levelid))
// Transform style that centers on the marker and zooms toward it const blueprintUrl = computed(() => {
const transformStyle = computed(() => { // Reading currentTheme keeps this reactive to theme changes.
// Calculate translation to center the marker in the preview return blueprintUrlFor(currentTheme.value, props.levelid)
const translateX = 50 - markerX.value })
const translateY = 50 - markerY.value
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
return { // the wrong level's dimensions is precisely how a marker ends up plausibly
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`, // placed and wrong.
transformOrigin: `${markerX.value}% ${markerY.value}%` const markerX = computed(() => {
} return (props.left / dimensionsFor(props.levelid).width) * 100
}) })
const tooltipStyle = computed(() => ({ const markerY = computed(() => {
left: `${tooltipPosition.value.x}px`, return (props.top / dimensionsFor(props.levelid).height) * 100
top: `${tooltipPosition.value.y}px` })
}))
// Marker style with counter-scale to maintain constant size
function onImageLoad() { const markerStyle = computed(() => ({
imageLoaded.value = true left: markerX.value + '%',
} top: markerY.value + '%',
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
function showTooltip(event) { }))
if (!hasPosition.value) return
// Transform style that centers on the marker and zooms toward it
visible.value = true const transformStyle = computed(() => {
zoom.value = 1 // Calculate translation to center the marker in the preview
const translateX = 50 - markerX.value
const rect = event.target.getBoundingClientRect() const translateY = 50 - markerY.value
tooltipPosition.value = {
x: rect.left + rect.width / 2, return {
y: rect.bottom + 10 transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
} transformOrigin: `${markerX.value}% ${markerY.value}%`
}
nextTick(() => { })
adjustPosition()
}) const tooltipStyle = computed(() => ({
} left: `${tooltipPosition.value.x}px`,
top: `${tooltipPosition.value.y}px`
function onWrapperLeave() { }))
// Small delay to allow moving to tooltip
setTimeout(() => { function onImageLoad() {
if (!isOverTooltip.value) { imageLoaded.value = true
hideTooltip() }
}
}, 100) function showTooltip(event) {
} if (!hasPosition.value) return
function onTooltipEnter() { visible.value = true
isOverTooltip.value = true zoom.value = 1
}
const rect = event.target.getBoundingClientRect()
function onTooltipLeave() { tooltipPosition.value = {
isOverTooltip.value = false x: rect.left + rect.width / 2,
hideTooltip() y: rect.bottom + 10
} }
function hideTooltip() { nextTick(() => {
visible.value = false adjustPosition()
zoom.value = 1 })
} }
function onWheel(event) { function onWrapperLeave() {
const delta = event.deltaY > 0 ? -0.3 : 0.3 // Small delay to allow moving to tooltip
const newZoom = Math.max(1, Math.min(8, zoom.value + delta)) setTimeout(() => {
zoom.value = newZoom if (!isOverTooltip.value) {
} hideTooltip()
}
function adjustPosition() { }, 100)
if (!tooltipRef.value) return }
const tooltip = tooltipRef.value function onTooltipEnter() {
const rect = tooltip.getBoundingClientRect() isOverTooltip.value = true
const viewportWidth = window.innerWidth }
const viewportHeight = window.innerHeight
function onTooltipLeave() {
if (rect.right > viewportWidth - 20) { isOverTooltip.value = false
tooltipPosition.value.x -= (rect.right - viewportWidth + 20) hideTooltip()
} }
if (rect.left < 20) {
tooltipPosition.value.x += (20 - rect.left) function hideTooltip() {
} visible.value = false
zoom.value = 1
if (rect.bottom > viewportHeight - 20) { }
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
} function onWheel(event) {
} const delta = event.deltaY > 0 ? -0.3 : 0.3
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
// Reset zoom when tooltip becomes visible zoom.value = newZoom
watch(visible, (newVal) => { }
if (newVal) {
zoom.value = 1 function adjustPosition() {
} if (!tooltipRef.value) return
})
const tooltip = tooltipRef.value
// Reset imageLoaded when theme changes to force reload const rect = tooltip.getBoundingClientRect()
watch(currentTheme, () => { const viewportWidth = window.innerWidth
imageLoaded.value = false const viewportHeight = window.innerHeight
})
</script> if (rect.right > viewportWidth - 20) {
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
<style scoped> }
.location-tooltip-wrapper { if (rect.left < 20) {
display: inline; tooltipPosition.value.x += (20 - rect.left)
cursor: pointer; }
}
if (rect.bottom > viewportHeight - 20) {
.location-tooltip-wrapper:hover { tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
color: var(--primary, #1976d2); }
} }
</style>
// Reset zoom when tooltip becomes visible
<style> watch(visible, (newVal) => {
.map-tooltip { if (newVal) {
position: fixed; zoom.value = 1
z-index: 10000; }
transform: translateX(-50%); })
}
// Reset imageLoaded when theme changes to force reload
.map-tooltip-content { watch(currentTheme, () => {
background: var(--bg-card, #ffffff); imageLoaded.value = false
border-radius: 8px; })
border: 1px solid var(--border, #e0e0e0); </script>
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
overflow: hidden; <style scoped>
} .location-tooltip-wrapper {
display: inline;
.map-preview { cursor: pointer;
position: relative; }
width: 500px;
height: 385px; .location-tooltip-wrapper:hover {
overflow: hidden; color: var(--primary, #1976d2);
background: var(--bg, #f5f5f5); }
}
.map-level-unknown {
.map-transform { display: flex;
position: relative; flex-direction: column;
width: 100%; gap: 0.35rem;
height: 100%; padding: 0.75rem;
transition: transform 0.15s ease-out; max-width: 18rem;
} font-size: 0.8rem;
color: var(--text-light);
.map-image { }
width: 100%;
height: 100%; .map-level-unknown strong {
object-fit: contain; color: var(--warning);
} }
</style>
.marker-dot {
position: absolute; <style>
width: 16px; .map-tooltip {
height: 16px; position: fixed;
background: #ff0000; z-index: 10000;
border: 2px solid #ffffff; transform: translateX(-50%);
border-radius: 50%; }
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
pointer-events: none; .map-tooltip-content {
} background: var(--bg-card, #ffffff);
border-radius: 8px;
.map-tooltip-footer { border: 1px solid var(--border, #e0e0e0);
padding: 0.5rem 0.75rem; box-shadow: 0 4px 20px rgba(0,0,0,0.25);
background: var(--bg, #f5f5f5); overflow: hidden;
border-top: 1px solid var(--border, #e0e0e0); }
display: flex;
justify-content: space-between; .map-preview {
align-items: center; position: relative;
} /* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
covered the row it was launched from. Aspect ratio kept. */
.coordinates { width: 390px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; height: 300px;
font-size: 0.875rem; overflow: hidden;
color: var(--text-light, #666666); background: var(--bg, #f5f5f5);
} }
.zoom-hint { .map-transform {
font-size: 0.75rem; position: relative;
color: var(--text-light, #666666); width: 100%;
} height: 100%;
</style> transition: transform 0.15s ease-out;
}
.map-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.marker-dot {
position: absolute;
width: 16px;
height: 16px;
background: #ff0000;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
pointer-events: none;
}
.map-tooltip-footer {
padding: 0.5rem 0.75rem;
background: var(--bg, #f5f5f5);
border-top: 1px solid var(--border, #e0e0e0);
display: flex;
justify-content: space-between;
align-items: center;
}
.coordinates {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
color: var(--text-light, #666666);
}
.zoom-hint {
font-size: 0.75rem;
color: var(--text-light, #666666);
}
</style>

View File

@@ -45,7 +45,7 @@
</div> </div>
<!-- Legend for asset type mode - shows subtypes when a type is selected --> <!-- Legend for asset type mode - shows subtypes when a type is selected -->
<div class="map-legend" v-if="!pickerMode && assetTypeMode"> <div class="map-legend" v-if="!pickerMode && assetTypeMode && hasLegendEntries">
<!-- Show subtype legend when a specific type is selected --> <!-- Show subtype legend when a specific type is selected -->
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length"> <template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
<span <span
@@ -95,7 +95,7 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel } from '../utils/assetTypes' import { assetTypeLabel } from '../utils/assetTypes'
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors' import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
import api from '../api' import api from '../api'
@@ -111,11 +111,23 @@ const props = defineProps({
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name subtypeNames: { type: Object, default: () => ({}) }, // Map of subtype ID to name
// Which level this map is drawing (ADR-017). Defaults to the current level in
// the shared config, so an existing caller that has not been taught about
// levels still renders the level the user is looking at rather than nothing.
levelid: { type: Number, default: null }
}) })
const emit = defineEmits(['markerClick', 'positionPicked']) const emit = defineEmits(['markerClick', 'positionPicked'])
// An empty legend still drew its bar and border, which looked like a stray input
// box under the toolbar. It renders only when it has something in it.
const hasLegendEntries = computed(() => {
if (props.selectedAssetType && Object.keys(visibleSubtypes.value).length) return true
if (Object.keys(visibleAssetTypes.value).length) return true
return overlayLegend.value.length > 0
})
const mapContainer = ref(null) const mapContainer = ref(null)
let map = null let map = null
let imageOverlay = null let imageOverlay = null
@@ -139,10 +151,15 @@ const filters = ref({
search: '' search: ''
}) })
// Map dimensions - facility blueprint size, loaded from settings before // The drawn level's native size, which is what its marker coordinates mean.
// initMap runs (mutable so the loaded values replace the fallback defaults). // Mutable because the levels load after this module does, and because switching
let MAP_WIDTH = mapConfig.width // level changes them.
let MAP_HEIGHT = mapConfig.height function drawnLevelId() {
return props.levelid ?? mapConfig.currentlevelid
}
let MAP_WIDTH = dimensionsFor(drawnLevelId()).width
let MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
// Asset type colors (for unified map mode) - normalized lookup // Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = { const assetTypeColorsMap = {
@@ -279,7 +296,7 @@ function initMap() {
}) })
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]] const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds) imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme, drawnLevelId()), bounds)
imageOverlay.addTo(map) imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan // Set initial view - zoom out to show full floor plan
@@ -287,17 +304,22 @@ function initMap() {
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom) map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
map.setMaxBounds(bounds) map.setMaxBounds(bounds)
// Picker mode: click to set position // Bound UNCONDITIONALLY, and handleMapClick ignores the click when picker mode
if (props.pickerMode) { // is off. Binding it only when pickerMode was true AT MOUNT meant a map that
map.on('click', handleMapClick) // becomes a picker later never got a click handler at all: the map editor
// opens with nothing selected, so clicking to place a marker did nothing, for
// the whole life of the page. The asset forms only worked because their picker
// is mounted inside a modal that is already in picker mode.
map.on('click', handleMapClick)
// Show initial position if provided if (props.pickerMode && props.initialPosition) {
if (props.initialPosition) { setPickerPosition(props.initialPosition.left, props.initialPosition.top)
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
}
} else {
renderMarkers()
} }
// Existing markers are drawn in picker mode too: placing a marker relative to
// the ones already on the floor is the whole task. The forms pass no markers,
// so this costs them nothing.
renderMarkers()
} }
function handleMapClick(e) { function handleMapClick(e) {
@@ -428,8 +450,18 @@ function renderMarkers() {
overlayLayers.forEach(layer => layer.remove()) overlayLayers.forEach(layer => layer.remove())
overlayLayers = [] overlayLayers = []
// Markers belong to ONE level (ADR-017). This is the drawing being shown, and
// anything positioned against a different one is not drawn on it.
const drawnLevel = drawnLevelId() ?? null
props.machines.forEach(item => { props.machines.forEach(item => {
if (item.mapx == null || item.mapy == null) return if (item.mapx == null || item.mapy == null) return
// A marker from another level, placed on THIS blueprint, looks entirely
// correct and points at the wrong part of the building - so it is omitted
// rather than approximated. A position with no level at all is omitted for
// the same reason: the map editor lists both, badged, so they can be fixed
// rather than silently misplaced here.
if ((item.levelid ?? null) !== drawnLevel) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up) // Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - item.mapy const leafletY = MAP_HEIGHT - item.mapy
@@ -584,18 +616,43 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => { watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) { if (imageOverlay && map) {
imageOverlay.setUrl(blueprintUrlFor(newTheme)) imageOverlay.setUrl(blueprintUrlFor(newTheme, drawnLevelId()))
// Marker rings are keyed on the surface, so they have to be redrawn too. // Marker rings are keyed on the surface, so they have to be redrawn too.
renderMarkers() renderMarkers()
} }
}) })
// Switching level changes the drawing, the coordinate space AND which markers
// belong on it. All three move together: the size is what marker coordinates
// mean, so swapping the image without the bounds would place every marker
// against the wrong scale, and keeping the markers would show the previous
// floor's assets on this floor's plan.
watch(() => drawnLevelId(), (levelid) => {
if (!map || !imageOverlay) return
MAP_WIDTH = dimensionsFor(levelid).width
MAP_HEIGHT = dimensionsFor(levelid).height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
const url = blueprintUrlFor(props.theme, levelid)
// A level with no blueprint is left blank rather than showing the previous
// one, which would be a floor plan labelled as somewhere it is not.
imageOverlay.setUrl(url || '')
imageOverlay.setBounds(bounds)
map.setMaxBounds(bounds)
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], map.getZoom())
renderMarkers()
loadOverlays()
})
onMounted(async () => { onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so // Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults. // bounds and coordinate math use the right size. Falls back to defaults.
await loadMapConfig() await loadMapConfig()
MAP_WIDTH = mapConfig.width MAP_WIDTH = dimensionsFor(drawnLevelId()).width
MAP_HEIGHT = mapConfig.height MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
initMap() initMap()
loadOverlays() loadOverlays()
}) })

View File

@@ -0,0 +1,204 @@
// Pure render helpers for DashboardCards.vue - the fleet dashboard's generic
// renderers. Same shape as pluginAssetPanels.js: the mapping logic that turns a
// plugin's JSON card declaration plus its endpoint data into rendered rows
// lives here so it can be unit tested without mounting a component.
//
// WHY GENERIC RENDERERS AT ALL: the older widget contract named a Vue component
// per widget. That cannot survive a lean build, where a plugin's component may
// never be staged into the frontend bundle - which is exactly why five plugins
// declared widgets pointing at components nobody ever wrote. A plugin declares
// data and shape; core owns the drawing.
export function toApiPath(endpoint) {
// Cards declare absolute endpoints (/api/...); the api instance already
// carries the /api base, so strip it before fetching through the instance.
return String(endpoint).replace(/^\/api(?=\/)/, '')
}
export function rows(card) {
const data = card._data
if (Array.isArray(data)) return data
if (data && Array.isArray(data.rows)) return data.rows
if (data && Array.isArray(data.items)) return data.items
return []
}
export function formatValue(value, spec = {}) {
if (value === null || value === undefined || value === '') return ''
if (spec.format === 'date') {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
export function mapTitle(card, item) {
const key = card.map && card.map.title
return key ? item[key] : ''
}
export function mapDetail(card, item) {
const key = card.map && card.map.detail
return key ? item[key] : ''
}
// The untruncated text behind a shortened detail. Same rule as elsewhere: the
// tooltip explains, it never carries the only copy of something essential.
export function mapDetailTip(card, item) {
const key = card.map && card.map.detailtooltip
const full = key ? (item[key] || '') : ''
return full && full !== mapDetail(card, item) ? full : ''
}
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
.map((spec) => {
const value = formatValue(item[spec.key], spec)
if (value === '') return null
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
// where 'quiet for 3' reads as a truncation.
const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}`
return { text, mono: !!spec.mono }
})
.filter(Boolean)
}
// A link template is '/pcs/{computerid}'. A row whose substitution value is
// missing gets NO link rather than a link to '/pcs/undefined' - the row still
// shows, because the finding is real even when the inventory is behind.
export function mapLink(card, item) {
const template = card.map && card.map.link
if (!template) return null
let missing = false
const href = String(template).replace(/\{(\w+)\}/g, (_m, key) => {
const value = item[key]
if (value === null || value === undefined || value === '') {
missing = true
return ''
}
return value
})
return missing ? null : href
}
// How many rows a card shows before collapsing the rest behind a count. A card
// listing forty PCs is a report someone has to read, not a board someone can
// scan - and it pushes every card below it off the screen. Five is enough to
// see the shape of the problem; the link goes to the full list.
export const MAXROWS = 5
export function visibleRows(card) {
return cardRows(card).slice(0, MAXROWS)
}
export function overflowCount(card) {
return Math.max(0, rows(card).length - MAXROWS)
}
// Chips are the row's own small facts, each carrying a tooltip: a depleted
// cartridge shows "Black 4%" and reveals the part number to order on hover.
// The percentage says something is wrong; the tooltip says what to do about
// it, which otherwise means opening the printer's page to find out.
export function mapChips(card, item) {
const key = card.map && card.map.chips
const list = key ? item[key] : null
if (!Array.isArray(list)) return []
return list
.filter((chip) => chip && chip.text)
.map((chip) => ({ text: chip.text, title: chip.title || '', level: chip.level || '' }))
}
// A tooltip on the row title, for context that would clutter the line - a
// printer's location, say. Never load-bearing: hover is not discoverable and
// does not exist on touch, so nothing essential goes here.
export function mapTitleTip(card, item) {
const key = card.map && card.map.titletooltip
return key ? (item[key] || '') : ''
}
// Floor-plan preview on hover, for rows that carry coordinates. Returns null
// when the card declares none or the row has not been placed on the map - an
// unplaced printer still belongs on the card, it just has nothing to preview.
export function mapHover(card, item) {
const spec = card.map && card.map.maphover
if (!spec) return null
const x = item[spec.x]
const y = item[spec.y]
if (x === null || x === undefined || y === null || y === undefined) return null
// The level travels with the coordinates (ADR-017) - they are pixels of ONE
// drawing. Read `levelid` unless the card names another field, so a card that
// predates levels still previews on the right floor instead of none.
const levelid = item[spec.level || 'levelid']
return {
x, y,
levelid: levelid === undefined ? null : levelid,
label: spec.label ? (item[spec.label] || '') : '',
}
}
export function cardRows(card) {
return rows(card).map((item) => ({
title: mapTitle(card, item),
titletip: mapTitleTip(card, item),
maphover: mapHover(card, item),
detail: mapDetail(card, item),
detailtip: mapDetailTip(card, item),
meta: mapMeta(card, item),
chips: mapChips(card, item),
link: mapLink(card, item),
timestamp: card.map && card.map.timestamp ? item[card.map.timestamp] : null,
}))
}
// A metric card is the count, for the cases where the count IS the story.
export function metricValue(card) {
const data = card._data
if (typeof data === 'number') return data
if (data && typeof data.value === 'number') return data.value
return rows(card).length
}
// EMPTY HANDLING is the part that keeps the board readable. A card reporting
// "nothing wrong" every day teaches people to stop reading the page - the same
// way a fleet log reached 3,234 lines of which 3,217 were one repeated line.
// Default is to disappear; 'line' is for the few where absence is itself news.
export function cardVisible(card) {
if (card._data === null || card._data === undefined) return false // fetch failed
const populated = card.render === 'metric'
? metricValue(card) > 0
: rows(card).length > 0
if (populated) return true
return card.empty === 'line'
}
// Sorted by how much it wants a person: critical, then warning, then info, and
// by declared position within a severity. Ordering by position alone would let
// an info card sit above a failure.
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 }
export function sortCards(cards) {
return [...cards].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity] ?? 3
const sb = SEVERITY_ORDER[b.severity] ?? 3
if (sa !== sb) return sa - sb
return (a.position ?? 99) - (b.position ?? 99)
})
}
// A card declares the permission its endpoint requires. Filtering here keeps
// the dashboard from becoming a way around RBAC, and avoids firing a request
// that would only 403. `hasPermission` comes from the auth store.
export function permittedCards(cards, hasPermission) {
return cards.filter((card) => !card.permission || hasPermission(card.permission))
}
// Only cards using a renderer core actually has. An unknown render mode is
// skipped rather than drawn blank, so a plugin built against a newer core
// degrades instead of leaving a hole on the page.
export const RENDERERS = ['exceptions', 'metric', 'list']
export function renderableCards(cards) {
return cards.filter((card) => RENDERERS.includes(card.render))
}

View File

@@ -0,0 +1,228 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
cardVisible, sortCards, permittedCards, renderableCards,
visibleRows, overflowCount,
} from './dashboardCards'
const failuresCard = {
id: 'geenforce-failures',
render: 'exceptions',
severity: 'critical',
position: 10,
map: {
title: 'hostname',
detail: 'entryname',
meta: [{ key: 'message' }, { key: 'exitcode', label: 'exit' }],
link: '/pcs/{computerid}',
},
}
describe('endpoint paths', () => {
it('strips the api prefix the instance already carries', () => {
expect(toApiPath('/api/geenforce/dashboard/failures'))
.toBe('/geenforce/dashboard/failures')
})
it('leaves a relative endpoint alone', () => {
expect(toApiPath('/geenforce/x')).toBe('/geenforce/x')
})
})
describe('reading rows from whatever shape the endpoint returns', () => {
it('accepts a bare array, {rows} or {items}', () => {
expect(rows({ _data: [1, 2] })).toEqual([1, 2])
expect(rows({ _data: { rows: [1] } })).toEqual([1])
expect(rows({ _data: { items: [1, 2, 3] } })).toEqual([1, 2, 3])
})
it('treats a failed fetch as no rows rather than throwing', () => {
expect(rows({ _data: null })).toEqual([])
})
})
describe('mapping a row', () => {
it('builds title, detail, meta and link from the declaration', () => {
const card = { ...failuresCard, _data: [{
hostname: 'WJSF1234', entryname: 'Install OpenText',
exitcode: 1603, message: 'Fatal error', computerid: 42,
}] }
const [row] = cardRows(card)
expect(row.title).toBe('WJSF1234')
expect(row.detail).toBe('Install OpenText')
expect(row.meta.map((m) => m.text)).toEqual(['Fatal error', 'exit 1603'])
expect(row.link).toBe('/pcs/42')
})
it('supports a trailing unit so the text reads as a sentence', () => {
const card = {
render: 'exceptions',
map: { title: 'hostname', meta: [{ key: 'quietdays', label: 'quiet for', suffix: ' days' }] },
_data: [{ hostname: 'QUIETPC', quietdays: 3 }],
}
expect(cardRows(card)[0].meta[0].text).toBe('quiet for 3 days')
})
it('drops empty meta values instead of rendering a stray label', () => {
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
expect(cardRows(card)[0].meta).toEqual([])
})
it('omits the link when the substitution value is missing, keeping the row', () => {
// A PC shopdb does not know still reports its failure - that is the bay
// most likely to be misconfigured. It must not link to /pcs/undefined.
const card = { ...failuresCard, _data: [{ hostname: 'GHOSTPC', computerid: null }] }
const [row] = cardRows(card)
expect(row.title).toBe('GHOSTPC')
expect(row.link).toBeNull()
})
})
describe('long lists', () => {
const many = (n) => ({
render: 'exceptions',
map: { title: 'hostname' },
_data: Array.from({ length: n }, (_v, i) => ({ hostname: `PC${i}` })),
})
it('shows at most five rows so one card cannot bury the rest', () => {
expect(visibleRows(many(40))).toHaveLength(5)
expect(overflowCount(many(40))).toBe(35)
})
it('does not claim an overflow when everything fits', () => {
expect(visibleRows(many(3))).toHaveLength(3)
expect(overflowCount(many(3))).toBe(0)
})
})
describe('empty handling', () => {
it('hides a card with nothing to report by default', () => {
// The whole point: a card saying "nothing wrong" daily trains people to
// stop reading the page.
expect(cardVisible({ render: 'exceptions', _data: [] })).toBe(false)
})
it('keeps a card whose absence is itself news when it opts in', () => {
expect(cardVisible({ render: 'exceptions', _data: [], empty: 'line' })).toBe(true)
})
it('shows a card that has findings', () => {
expect(cardVisible({ render: 'exceptions', _data: [{ a: 1 }] })).toBe(true)
})
it('hides a card whose fetch failed rather than drawing it empty', () => {
expect(cardVisible({ render: 'exceptions', _data: null })).toBe(false)
})
it('hides a zero metric but shows a non-zero one', () => {
expect(cardVisible({ render: 'metric', _data: { value: 0 } })).toBe(false)
expect(cardVisible({ render: 'metric', _data: { value: 3 } })).toBe(true)
expect(metricValue({ render: 'metric', _data: { value: 3 } })).toBe(3)
})
})
describe('ordering', () => {
it('puts severity before position, so info never sits above a failure', () => {
const ordered = sortCards([
{ id: 'info-recent', severity: 'info', position: 1 },
{ id: 'crit-failures', severity: 'critical', position: 90 },
{ id: 'warn-toner', severity: 'warning', position: 50 },
])
expect(ordered.map((c) => c.id))
.toEqual(['crit-failures', 'warn-toner', 'info-recent'])
})
it('falls back to position within one severity', () => {
const ordered = sortCards([
{ id: 'b', severity: 'critical', position: 20 },
{ id: 'a', severity: 'critical', position: 10 },
])
expect(ordered.map((c) => c.id)).toEqual(['a', 'b'])
})
})
describe('gating', () => {
it('drops a card the user lacks permission for', () => {
// The dashboard must not become a way around RBAC.
const has = (name) => name === 'printers.view'
const kept = permittedCards([
{ id: 'toner', permission: 'printers.view' },
{ id: 'enforce', permission: 'geenforce.manage' },
{ id: 'open', permission: null },
], has)
expect(kept.map((c) => c.id)).toEqual(['toner', 'open'])
})
it('skips a render mode this core does not have', () => {
// A plugin built against a newer core degrades instead of leaving a hole.
const kept = renderableCards([
{ id: 'ok', render: 'exceptions' },
{ id: 'future', render: 'sparkline' },
])
expect(kept.map((c) => c.id)).toEqual(['ok'])
})
})
describe('chips and tooltips', () => {
const printerCard = {
render: 'exceptions',
map: {
title: 'printername',
titletooltip: 'location',
chips: 'supplies',
link: '/printers/{printerid}',
},
_data: [{
printerid: 7,
printername: 'WJ-HP-402',
location: 'Cell B, north wall',
supplies: [
{ text: 'Black 4%', title: 'CF226X (high)', level: 'critical' },
{ text: 'Cyan 9%', title: 'No part number on file for this model', level: 'low' },
],
}],
}
it('builds one chip per depleted supply, each with its reorder tooltip', () => {
const [row] = cardRows(printerCard)
expect(row.chips.map((c) => c.text)).toEqual(['Black 4%', 'Cyan 9%'])
expect(row.chips[0].title).toBe('CF226X (high)')
expect(row.chips[0].level).toBe('critical')
})
it('puts the location on the title as a tooltip, not in the line', () => {
const [row] = cardRows(printerCard)
expect(row.titletip).toBe('Cell B, north wall')
expect(row.detail).toBe('')
})
it('has no chips when a card declares none', () => {
expect(cardRows({ render: 'exceptions', map: { title: 'x' }, _data: [{ x: 'y' }] })[0].chips)
.toEqual([])
})
})
describe('stacked rows', () => {
const card = {
render: 'list',
layout: 'stacked',
map: { title: 'typename', detail: 'message', detailtooltip: 'fullmessage' },
_data: [{ typename: 'Recertification', message: 'Short text', fullmessage: 'Short text' }],
}
it('offers no tooltip when the detail is already whole', () => {
// A tooltip repeating what is on screen is noise, and it makes the cursor
// change for no reason.
expect(cardRows(card)[0].detailtip).toBe('')
})
it('offers the full text when the detail was trimmed', () => {
const trimmed = { ...card, _data: [{
typename: 'General', message: 'Start of a long message...',
fullmessage: 'Start of a long message that continues well past the card',
}] }
expect(cardRows(trimmed)[0].detailtip)
.toBe('Start of a long message that continues well past the card')
})
})

View File

@@ -1,42 +1,59 @@
// Facility floor-map blueprint config, read from the settings table so each // Which drawing renders a marker, and at what native size (ADR-017).
// site instance (ADR-004) renders its own floor plan instead of a hardcoded //
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height. // This used to hold ONE blueprint and ONE pixel size, read from four settings,
// Missing keys fall back to the generic placeholder so a fresh or offline // because a site had one floor map. It now holds every level of every building,
// install still renders; each site uploads its own blueprint in Settings. // because `assets.mapx`/`mapy` are pixels in a specific level's space and the
// same coordinates mean different places on different drawings.
//
// THE RULE THIS FILE ENFORCES: a position without a level is not rendered on the
// default level. `blueprintUrlFor(theme, levelid)` returns null for an unknown
// level, and every caller must show "level unknown" rather than draw something.
// Falling back would put one building's ground floor behind a marker positioned
// for another building's mezzanine - it renders perfectly and points at the
// wrong place, which is worse than rendering nothing.
import { reactive } from 'vue' import { reactive } from 'vue'
import { settingsApi } from '../api'
import { mapLevelsApi } from '../api'
import { withBase } from '../utils/basePath' import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults. // Used until the levels load, and on a fresh install with none configured, so a
const DEFAULTS = { // map still draws something rather than breaking.
blueprintLight: '/static/images/floorplan-placeholder.svg', const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
blueprintDark: '/static/images/floorplan-placeholder.svg', const FALLBACK_WIDTH = 3300
width: 3300, const FALLBACK_HEIGHT = 2550
height: 2550
}
// Shared reactive config. Import as `state` to read width/height/blueprint. export const state = reactive({
export const state = reactive({ ...DEFAULTS, loaded: false }) buildings: [],
// Flat index by levelid, because every hover preview resolves an arbitrary
// asset's level and has no idea which building it is in.
levels: {},
defaultlevelid: null,
currentlevelid: null,
loaded: false,
})
let inflight = null let inflight = null
function applySetting(key, value) { function levelFor(levelid) {
if (value === null || value === undefined || value === '') return if (levelid === null || levelid === undefined) return null
if (key === 'map_blueprint_light') state.blueprintLight = value return state.levels[levelid] || null
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
} }
function fetchConfig() { function fetchLevels() {
inflight = settingsApi.list({ category: 'map' }) inflight = mapLevelsApi.list()
.then(({ data }) => { .then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value)) const payload = data.data || {}
state.buildings = payload.buildings || []
state.levels = {}
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
state.levels[level.levelid] = { ...level, buildingname: building.buildingname }
})
})
state.defaultlevelid = payload.defaultlevelid || null
if (!state.currentlevelid || !state.levels[state.currentlevelid]) {
state.currentlevelid = state.defaultlevelid
}
state.loaded = true state.loaded = true
}) })
.catch(() => { state.loaded = true }) .catch(() => { state.loaded = true })
@@ -44,26 +61,95 @@ function fetchConfig() {
return inflight return inflight
} }
// Fetch the map config once (shared across all map components). Returns a // Fetch once, shared across every map component. Await it before initialising a
// promise that resolves when state is populated, so a caller can await it // Leaflet map, which needs the dimensions to set its bounds.
// before initializing a Leaflet map that needs the dimensions.
export function loadMapConfig() { export function loadMapConfig() {
if (state.loaded) return Promise.resolve() if (state.loaded) return Promise.resolve()
if (inflight) return inflight if (inflight) return inflight
return fetchConfig() return fetchLevels()
} }
// Re-read config from the server after a map setting changes. // Re-read after the levels admin changes something.
export function reloadMapConfig() { export function reloadMapConfig() {
return fetchConfig() return fetchLevels()
} }
// Blueprint image URL for the given theme ('light' | 'dark'). export function setCurrentLevel(levelid) {
export function blueprintUrlFor(theme) { if (state.levels[levelid]) state.currentlevelid = levelid
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark) }
/**
* Blueprint URL for one level in one theme, or null when the level is unknown.
*
* Falls back to the OTHER theme's image before giving up, because a site that
* uploaded only a light blueprint should still render in dark mode - a
* hard-to-read floor plan beats no floor plan.
*/
export function blueprintUrlFor(theme, levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
if (!level) return null
const wanted = theme === 'light' ? level.blueprintlight : level.blueprintdark
const other = theme === 'light' ? level.blueprintdark : level.blueprintlight
const chosen = wanted || other
return withBase(chosen || PLACEHOLDER)
}
/**
* Native pixel size of a level, which is what its marker coordinates mean.
*
* Returns the fallback for an unknown level so arithmetic does not divide by
* undefined, but callers deciding WHETHER to draw must ask `hasLevel` - these
* numbers are a safe default, not evidence the level exists.
*/
export function dimensionsFor(levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
return {
width: level?.mapwidth || FALLBACK_WIDTH,
height: level?.mapheight || FALLBACK_HEIGHT,
}
}
export function hasLevel(levelid) {
return !!levelFor(levelid)
}
export function levelName(levelid) {
const level = levelFor(levelid)
if (!level) return null
// Qualified by building only when there is more than one, so a single-building
// site is not made to read "Main / Ground floor" everywhere.
return state.buildings.length > 1
? `${level.buildingname} / ${level.levelname}`
: level.levelname
}
// Every level flat, in building then level order, for a selector.
export function levelOptions() {
const options = []
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
options.push({
levelid: level.levelid,
levelname: level.levelname,
buildingname: building.buildingname,
label: state.buildings.length > 1
? `${building.buildingname} / ${level.levelname}`
: level.levelname,
})
})
})
return options
} }
export function useMapConfig() { export function useMapConfig() {
loadMapConfig() loadMapConfig()
return { state, blueprintUrlFor } return {
state,
blueprintUrlFor,
dimensionsFor,
hasLevel,
levelName,
levelOptions,
setCurrentLevel,
}
} }

View File

@@ -0,0 +1,144 @@
// The invariant the whole map model rests on: a position without a level is not
// drawn on the default level.
//
// Nothing else catches this. Rewriting the composable left four components
// reading a property that no longer existed, which is `undefined` rather than a
// compile error - Vite built it happily and every marker would have been
// positioned at NaN percent. A build proves the code parses, not that a marker
// lands anywhere.
import { describe, it, expect, vi, beforeEach } from 'vitest'
const list = vi.fn()
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
const {
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
hasLevel, levelName, levelOptions, setCurrentLevel,
} = await import('./mapConfig')
// Two buildings, three levels, sized like the real before and after: the ground
// floor at 3300x2550 and a second floor at 3308x4000.
const PAYLOAD = {
data: {
data: {
buildings: [
{
buildingid: 1, buildingname: 'Main', levels: [
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
mapheight: 2550, blueprintlight: '/x/g-light.png',
blueprintdark: '/x/g-dark.png', isdefault: true },
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
mapheight: 4000, blueprintlight: '/x/2-light.png',
blueprintdark: null, isdefault: false },
],
},
{
buildingid: 2, buildingname: 'Annex', levels: [
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
mapheight: 900, blueprintlight: '/x/a-light.png',
blueprintdark: '/x/a-dark.png', isdefault: false },
],
},
],
defaultlevelid: 1,
},
},
}
beforeEach(async () => {
list.mockReset()
list.mockResolvedValue(PAYLOAD)
state.loaded = false
state.currentlevelid = null
await reloadMapConfig()
})
describe('loading', () => {
it('indexes every level of every building and adopts the default', () => {
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
expect(state.defaultlevelid).toBe(1)
expect(state.currentlevelid).toBe(1)
})
it('fetches once across concurrent callers', async () => {
list.mockClear()
state.loaded = false
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
expect(list).toHaveBeenCalledTimes(1)
})
it('survives an unreachable server without hanging every map on the page', async () => {
list.mockReset()
list.mockRejectedValue(new Error('network'))
state.loaded = false
await reloadMapConfig()
expect(state.loaded).toBe(true)
})
})
describe('dimensions belong to the level, not the site', () => {
it('returns each level its own native size', () => {
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
})
it('does not report one level size for another', () => {
// The bug this guards: a marker on level 2 measured against level 1's
// height renders at 2550/4000 of the way down - plausible, and wrong.
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
})
})
describe('a position without a level is not drawn', () => {
it('has no blueprint for a null level', () => {
expect(blueprintUrlFor('light', null)).toBeNull()
expect(hasLevel(null)).toBe(false)
})
it('has no blueprint for a level this instance does not know', () => {
expect(blueprintUrlFor('light', 99)).toBeNull()
expect(hasLevel(99)).toBe(false)
})
it('never substitutes the default level for a missing one', () => {
const groundfloor = blueprintUrlFor('light', 1)
expect(groundfloor).toContain('g-light.png')
// The failure mode: returning the default blueprint for an unknown level.
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
})
})
describe('blueprints', () => {
it('serves the theme asked for', () => {
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
})
it('falls back to the other theme rather than showing nothing', () => {
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
// plan, and a site that uploaded one image should still work in both themes.
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
})
})
describe('naming and selection', () => {
it('qualifies a level by building only when there is more than one', () => {
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
expect(levelName(1)).toBe('Main / Ground floor')
expect(levelName(3)).toBe('Annex / Ground floor')
})
it('offers every level in building then level order', () => {
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
})
it('refuses to make an unknown level current', () => {
setCurrentLevel(99)
expect(state.currentlevelid).toBe(1)
setCurrentLevel(2)
expect(state.currentlevelid).toBe(2)
})
})

View File

@@ -52,7 +52,9 @@ export const searchDomains = [
{ key: 'network_device', label: 'Network Devices' }, { key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' }, { key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' }, { key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' } { key: 'subnet', label: 'Subnets' },
{ key: 'usb_device', label: 'USB Devices' },
{ key: 'printed_item', label: 'Printed Items' }
] ]
export function useSystemSettings() { export function useSystemSettings() {

202
frontend/src/utils/codes.js Normal file
View File

@@ -0,0 +1,202 @@
// One place that knows how to draw a barcode or a QR code for a label.
//
// Seven views were generating codes - three in core, four in plugins - each
// importing qrcode/jsbarcode directly and each carrying its own answer to the
// same questions: what margin, what width, which error correction, how big a
// module has to be before a scanner can read it. The answers had already
// drifted (margin 0 in one place and 2 in another, width 150 against 160), and
// none of the label pages knew the printer-resolution arithmetic the Tech Tools
// generator worked out.
//
// So the knowledge lives here and the views pass what they need. Nothing about
// a specific label belongs in this file; nothing about how to render a code
// belongs anywhere else.
//
// WHY SVG, MOSTLY. A code renders to an SVG data URI rather than a canvas or a
// PNG for two reasons found the hard way on the label pages: an <img> prints
// reliably where a live canvas or inline SVG does not, and SVG rasterises at
// the printer's resolution with hard module edges. A PNG gets downscaled to the
// label size and smears exactly the edges a scanner reads. The one exception is
// a QR with a logo composited into it, which needs a canvas to composite on.
import QRCode from 'qrcode'
import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings'
// Label stock actually loaded in the printers, with the code size, padding and
// quiet zone that fit each one. Sizes are inches, because that is what the
// stock is sold as and what @page takes.
export const LABEL_PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label
// carries its text separately, so the barcode never renders its own.
export const BARCODE_DEFAULTS = {
format: 'CODE128',
displayValue: false,
margin: 0,
width: 2,
height: 100,
}
export function svgDataUri(svg) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
/**
* Modules across a QR for this content and error correction, quiet zone
* excluded. 0 when the content cannot be encoded at all.
*/
export function qrModuleCount(text, errorCorrection = 'M') {
if (!text) return 0
try {
return QRCode.create(text, { errorCorrectionLevel: errorCorrection }).modules.size
} catch {
return 0
}
}
/**
* Whether a QR of `sizeInches` can actually be printed at `dpi`.
*
* A scanner reads modules, not inches. Below about two printer dots per module
* the edges blur into each other and the code stops scanning reliably - and a
* fractional dots-per-module means some modules are a dot wider than others,
* which is the difference between a label that always works and one that works
* on most printers. `snapped` is the nearest size that divides evenly.
*/
export function qrFit(text, { sizeInches, dpi = 203, errorCorrection = 'M' } = {}) {
const modules = qrModuleCount(text, errorCorrection)
if (!modules || !sizeInches) return null
const dotsPerModule = (sizeInches * dpi) / modules
const whole = Math.floor(dotsPerModule)
return {
modules,
dotsPerModule,
wholeDots: whole,
snapped: whole > 0 ? (modules * whole) / dpi : 0,
moduleMm: whole > 0 ? (whole / dpi) * 25.4 : 0,
// Below two dots per module the code is not reliably scannable, whatever
// it looks like on screen.
tooSmall: whole < 2,
even: whole > 0 && Math.abs((modules * whole) / dpi - sizeInches) < 0.002,
}
}
/**
* A QR as an SVG data URI.
*
* margin 0 by default: on a label the quiet zone is blank label supplied by the
* layout, so spending the code box on white the page cannot then adjust makes
* the printed code smaller for no benefit. A caller rendering to a screen, or
* onto something with no controlled surround, should pass a margin.
*/
export async function qrSvgDataUri(text, { errorCorrection = 'M', margin = 0 } = {}) {
const svg = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorCorrection,
margin,
})
return svgDataUri(svg)
}
/**
* A QR as a PNG data URL. Use only where a raster is genuinely needed - a
* composited logo, or an existing page built around <img src=png>.
*/
export async function qrPngDataUrl(text, { width = 160, margin = 0, errorCorrection = 'M' } = {}) {
return QRCode.toDataURL(text, { width, margin, errorCorrectionLevel: errorCorrection })
}
/**
* Render a barcode into an existing SVG or canvas element.
*
* Kept as a thin pass-through because several pages hold an element ref and
* want JsBarcode to draw straight into it; the value here is the shared
* defaults, not the indirection.
*/
export function barcodeInto(element, text, options = {}) {
JsBarcode(element, text, { ...BARCODE_DEFAULTS, ...options })
return element
}
/** A barcode as an SVG data URI, for pages that print an <img>. */
export function barcodeSvgDataUri(text, options = {}) {
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
barcodeInto(element, text, options)
return svgDataUri(new XMLSerializer().serializeToString(element))
}
// -- QR with a logo composited in the middle ---------------------------------
//
// Moved here from views/print/qrLogo.js, which core label pages and the printer
// QR pages both reached into. The overlay image is a site setting; the monogram
// below is the last-resort fallback when that setting is empty or unreachable,
// so a label still carries a mark rather than a hole.
const FALLBACK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
function loadLogo(url) {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => {
const fallback = new Image()
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
fallback.onerror = () => resolve(null)
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(FALLBACK_LOGO_SVG)
}
img.src = url
})
}
function drawLogoOverlay(canvas, logo) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle behind the mark, so it sits on the code rather than in it.
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
}
}
/**
* A QR with the configured logo composited in the centre, as a PNG data URL.
*
* Error correction H, because up to 30% of the code can be obscured and still
* decode - which is what makes covering the middle of it survivable at all.
* Empty qr_logo setting means no overlay. Returns '' on failure: a label with
* no code is better than a page that throws while printing a batch.
*/
export async function renderQrDataUrl(url, { width = 144, margin = 0 } = {}) {
const qrLogoUrl = await getQrLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width, margin, errorCorrectionLevel: 'H' })
if (qrLogoUrl) {
const logo = await loadLogo(qrLogoUrl)
drawLogoOverlay(canvas, logo)
}
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)
return ''
}
}

View File

@@ -0,0 +1,93 @@
// The arithmetic in codes.js decides whether a printed label scans, and it is
// the part no amount of looking at a screen will verify - a QR that is fine at
// 96 dpi on a monitor can be unreadable at 203 dpi on 0.5in stock.
import { describe, it, expect } from 'vitest'
import {
LABEL_PRESETS, BARCODE_DEFAULTS, qrModuleCount, qrFit, svgDataUri,
} from './codes'
describe('qrModuleCount', () => {
it('grows with the content', () => {
const short = qrModuleCount('AP-01')
const long = qrModuleCount('AP-01/'.repeat(20))
expect(short).toBeGreaterThan(0)
expect(long).toBeGreaterThan(short)
})
it('grows with error correction, because the redundancy is stored in modules', () => {
expect(qrModuleCount('WKSTN0042', 'H')).toBeGreaterThanOrEqual(
qrModuleCount('WKSTN0042', 'L'))
})
it('returns 0 rather than throwing on empty content', () => {
expect(qrModuleCount('')).toBe(0)
expect(qrModuleCount(null)).toBe(0)
})
})
describe('qrFit', () => {
it('calls a code too small when a module gets under two printer dots', () => {
// A long payload squeezed onto the 1.00 x 0.50in stock: the case the Tech
// Tools generator exists to warn about before 300 labels are printed.
const fit = qrFit('https://shopdb.example.net/assets/1234?from=label&sig=abcdef123456', {
sizeInches: 0.4286, dpi: 203, errorCorrection: 'H',
})
expect(fit.tooSmall).toBe(true)
expect(fit.dotsPerModule).toBeLessThan(2)
})
it('reports a whole number of dots per module as even', () => {
const modules = qrModuleCount('WKSTN0042', 'M')
// Choose the size that divides exactly at 4 dots per module.
const fit = qrFit('WKSTN0042', {
sizeInches: (modules * 4) / 203, dpi: 203, errorCorrection: 'M',
})
expect(fit.wholeDots).toBe(4)
expect(fit.even).toBe(true)
expect(fit.snapped).toBeCloseTo(fit.snapped, 5)
})
it('offers the nearest evenly-dividing size when the chosen one is uneven', () => {
const fit = qrFit('WKSTN0042', { sizeInches: 0.9, dpi: 203, errorCorrection: 'M' })
expect(fit.even).toBe(false)
expect(fit.snapped).toBeLessThanOrEqual(0.9)
expect(fit.snapped * 203 / fit.modules).toBeCloseTo(fit.wholeDots, 6)
})
it('is null when there is nothing to measure', () => {
expect(qrFit('', { sizeInches: 1 })).toBeNull()
expect(qrFit('WKSTN0042', {})).toBeNull()
})
})
describe('shared defaults', () => {
it('barcodes are CODE128 with no margin and no printed text', () => {
// The label draws its own caption, and the quiet zone is blank label. Both
// had drifted per-view before this module existed.
expect(BARCODE_DEFAULTS.format).toBe('CODE128')
expect(BARCODE_DEFAULTS.margin).toBe(0)
expect(BARCODE_DEFAULTS.displayValue).toBe(false)
})
it('every label preset is a complete, usable stock definition', () => {
expect(LABEL_PRESETS.length).toBeGreaterThan(0)
for (const preset of LABEL_PRESETS) {
expect(preset.id).toBeTruthy()
expect(preset.labelwidth).toBeGreaterThan(0)
expect(preset.labelheight).toBeGreaterThan(0)
// A code has to fit inside its label with its quiet zone on both sides.
expect(preset.codesize + 2 * preset.quiet).toBeLessThanOrEqual(preset.labelwidth)
}
})
})
describe('svgDataUri', () => {
it('escapes markup so the URI survives being put in a src attribute', () => {
const uri = svgDataUri('<svg><rect fill="#000"/></svg>')
expect(uri.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
expect(uri).not.toContain('<')
expect(uri).not.toContain('"')
})
})

View File

@@ -0,0 +1,48 @@
/**
* Fiscal week, as the classic ASP site computed it.
*
* That site called it "Fiscal Week" and calculated an ISO 8601 week number
* (includes/leftsidebar.asp): week 1 is the week containing 4 January, weeks
* start on Monday, and the year is decided by that week's Thursday. This is a
* faithful port - the label and the arithmetic both come from there.
*
* Worth knowing if the numbers are ever questioned: a true GE fiscal calendar
* need not line up with ISO weeks. Nobody has asked for a different rule, and
* changing it here would silently disagree with the classic site people still
* compare against, so it stays ISO until someone says otherwise.
*/
/**
* ISO 8601 week number for a date (1-53).
*
* Works in local time deliberately: the number people quote is the one on the
* wall where they are, and a UTC-based week rolls over hours early in the
* evening at a US site.
*/
export function fiscalWeek(date = new Date()) {
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
// The Thursday of this week decides which year the week belongs to, which is
// what makes late December and early January land in the right year.
const dayOfWeek = (day.getDay() + 6) % 7 // 0 = Monday
const thursday = new Date(day)
thursday.setDate(day.getDate() + 3 - dayOfWeek)
// Week 1 is the week containing 4 January; find its Monday.
const jan4 = new Date(thursday.getFullYear(), 0, 4)
const jan4DayOfWeek = (jan4.getDay() + 6) % 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek)
const days = Math.round((thursday - week1Monday) / 86400000)
return Math.floor(days / 7) + 1
}
/** The year the week belongs to, which is not always the calendar year. */
export function fiscalWeekYear(date = new Date()) {
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const dayOfWeek = (day.getDay() + 6) % 7
const thursday = new Date(day)
thursday.setDate(day.getDate() + 3 - dayOfWeek)
return thursday.getFullYear()
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { fiscalWeek, fiscalWeekYear } from './fiscalWeek'
/**
* The number has to match what the classic ASP site shows, because people will
* have both open. The awkward cases are all at the year boundary, which is
* exactly where a naive "day of year / 7" gets it wrong.
*/
describe('fiscalWeek', () => {
it('counts the week containing 4 January as week 1', () => {
expect(fiscalWeek(new Date(2026, 0, 4))).toBe(1)
})
it('puts a late-December date in week 1 of the NEXT year', () => {
// 2025-12-29 is a Monday; its Thursday falls in 2026, so it is 2026 week 1.
expect(fiscalWeek(new Date(2025, 11, 29))).toBe(1)
expect(fiscalWeekYear(new Date(2025, 11, 29))).toBe(2026)
})
it('puts an early-January date in the LAST week of the previous year', () => {
// 2027-01-01 is a Friday; its Thursday is 2026-12-31, so it is 2026 week 53.
expect(fiscalWeek(new Date(2027, 0, 1))).toBe(53)
expect(fiscalWeekYear(new Date(2027, 0, 1))).toBe(2026)
})
it('holds the same number all week, Monday through Sunday', () => {
const monday = new Date(2026, 7, 10)
const week = fiscalWeek(monday)
for (let i = 0; i < 7; i++) {
const day = new Date(2026, 7, 10 + i)
expect(fiscalWeek(day)).toBe(week)
}
})
it('increments the following Monday', () => {
expect(fiscalWeek(new Date(2026, 7, 17))).toBe(fiscalWeek(new Date(2026, 7, 10)) + 1)
})
it('gives a 53-week year its 53rd week', () => {
// 2026 starts on a Thursday, so it runs to 53 weeks.
expect(fiscalWeek(new Date(2026, 11, 31))).toBe(53)
})
it('is not fooled by a time of day', () => {
const morning = new Date(2026, 7, 12, 6, 0, 0)
const nearMidnight = new Date(2026, 7, 12, 23, 59, 0)
expect(fiscalWeek(morning)).toBe(fiscalWeek(nearMidnight))
})
})

View File

@@ -119,7 +119,7 @@ export async function getServicenowUrls() {
// Printer hostname template. {ip} is replaced with the dash-separated IP. // Printer hostname template. {ip} is replaced with the dash-separated IP.
export async function getPrinterHostnameTemplate() { export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
} }
// Email domain appended to a contact SSO to build email/Teams links // Email domain appended to a contact SSO to build email/Teams links

View File

@@ -4,6 +4,11 @@
<div class="sidebar-header"> <div class="sidebar-header">
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" /> <img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
<h1>{{ facilityName }}</h1> <h1>{{ facilityName }}</h1>
<!-- Under the site name, where the classic site put it. -->
<div class="fiscal-week">
<span class="fiscal-week-label">Fiscal Week</span>
<span class="fiscal-week-number">{{ currentFiscalWeek }}</span>
</div>
</div> </div>
<div class="sidebar-search"> <div class="sidebar-search">
@@ -98,17 +103,18 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import ToastHost from '../components/ToastHost.vue' import ToastHost from '../components/ToastHost.vue'
import { import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
Box, KeyRound, LogOut Box, KeyRound, LogOut, Wrench
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme' import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api' import { dashboardApi, notificationsApi } from '../api'
import { fiscalWeek } from '@/utils/fiscalWeek'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
import { withBase } from '../utils/basePath' import { withBase } from '../utils/basePath'
@@ -137,6 +143,11 @@ const searchQuery = ref('')
const navItems = ref([]) const navItems = ref([])
const activeNotifications = ref([]) const activeNotifications = ref([])
const facilityName = ref('ShopDB') const facilityName = ref('ShopDB')
// A tab left open overnight would otherwise show last week's number until
// somebody reloaded. Recomputed on a timer rather than only at mount.
const currentFiscalWeek = ref(fiscalWeek())
let fiscalWeekTimer = null
const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const servicenowConfig = ref({ enabled: true, searchUrl: '' }) const servicenowConfig = ref({ enabled: true, searchUrl: '' })
@@ -166,6 +177,7 @@ const iconMap = {
'shield': ShieldCheck, 'shield': ShieldCheck,
'ruler': Ruler, 'ruler': Ruler,
'box': Box, 'box': Box,
'wrench': Wrench,
} }
// Default navigation (used as fallback if API fails) // Default navigation (used as fallback if API fails)
@@ -221,6 +233,10 @@ function buildNavItems(items) {
} }
onMounted(async () => { onMounted(async () => {
// Half-hourly: cheap, and it crosses midnight into the new week without a
// reload on a screen nobody touches.
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
getFacilityName().then(name => { facilityName.value = name }) getFacilityName().then(name => { facilityName.value = name })
getSiteLogo().then(logo => { siteLogo.value = logo }) getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config }) getServicenowUrls().then(config => { servicenowConfig.value = config })
@@ -273,9 +289,32 @@ function onAvatarError(event) {
event.target.src = fallbackAvatar event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback') event.target.classList.add('ge-avatar-fallback')
} }
onUnmounted(() => {
if (fiscalWeekTimer) clearInterval(fiscalWeekTimer)
})
</script> </script>
<style scoped> <style scoped>
/* Fiscal week, under the site name, where the classic site put it. The number
carries the weight; the label is small because it never changes. */
.fiscal-week {
margin-top: 0.35rem;
text-align: center;
line-height: 1.15;
}
.fiscal-week-label {
display: block;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.65;
}
.fiscal-week-number {
display: block;
font-size: 1.2rem;
font-weight: 700;
}
/* Footer user block: identity row on top, compact icon actions below, all /* Footer user block: identity row on top, compact icon actions below, all
kept inside the fixed-width sidebar (no full-width buttons overflowing). */ kept inside the fixed-width sidebar (no full-width buttons overflowing). */
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; } .user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
@@ -304,4 +343,4 @@ function onAvatarError(event) {
text-decoration: none; text-decoration: none;
} }
.icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; } .icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
</style> </style>

View File

@@ -7,24 +7,47 @@
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<!-- Main Stats --> <!-- What needs a person, before the totals that are true every day.
Cards are declared by plugins (get_dashboard_widgets) and rendered
generically; an empty or failed card hides itself. See
docs/proposals/dashboard-live-fleet.md. -->
<DashboardCards />
<!-- Inventory context, below the exceptions.
These used to mix two different things without saying so: two tiles
counted asset TYPES and two counted asset STATUSES, and the status
one was labelled "Active", which reads as "not deleted" when it
actually meant status = In Use across every type. Now every tile
counts one thing and its label says which. -->
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="stat-card"> <div class="stat-card">
<div class="label">Total Machines</div> <div class="label">Machines</div>
<div class="value">{{ stats.totalmachines || 0 }}</div> <div class="value">{{ stats.totalmachines || 0 }}</div>
</div> </div>
<div class="stat-card success">
<div class="label">Active</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">In Repair</div>
<div class="value">{{ stats.inrepair || 0 }}</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="label">PCs</div> <div class="label">PCs</div>
<div class="value">{{ stats.totalpc || 0 }}</div> <div class="value">{{ stats.totalpc || 0 }}</div>
</div> </div>
<div class="stat-card">
<div class="label">Printers</div>
<div class="value">{{ stats.totalprinter || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Network devices</div>
<div class="value">{{ stats.totalnetwork || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">All assets</div>
<div class="value">{{ stats.totalassets || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">All assets in use</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">All assets in repair</div>
<div class="value">{{ stats.inrepair || 0 }}</div>
</div>
</div> </div>
<!-- Printer Stats --> <!-- Printer Stats -->
@@ -92,6 +115,7 @@
</template> </template>
<script setup> <script setup>
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { dashboardApi, assetsApi, printersApi } from '../api' import { dashboardApi, assetsApi, printersApi } from '../api'

View File

@@ -3,6 +3,8 @@
<div class="page-header"> <div class="page-header">
<h2>Map Editor</h2> <h2>Map Editor</h2>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" @click="openTransform">Recalibrate level</button>
<button class="btn btn-secondary" @click="openSnapshots">Undo history</button>
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link> <router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
</div> </div>
</div> </div>
@@ -12,6 +14,13 @@
<div class="asset-panel"> <div class="asset-panel">
<div class="panel-header"> <div class="panel-header">
<h3>Assets</h3> <h3>Assets</h3>
<select v-if="levelOptions().length > 1" v-model.number="editingLevelId"
class="form-control" title="Which drawing you are placing on">
<option v-for="option in levelOptions()" :key="option.levelid"
:value="option.levelid">{{ option.label }}</option>
</select>
<input v-model="search" class="form-control" type="search"
placeholder="Search name or asset number" />
<select v-model="filterType" class="form-control"> <select v-model="filterType" class="form-control">
<option value="">All Types</option> <option value="">All Types</option>
<option value="machine">Machines</option> <option value="machine">Machines</option>
@@ -25,7 +34,15 @@
<div class="asset-filter"> <div class="asset-filter">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" /> <input type="checkbox" v-model="showUnplacedOnly" />
Show unplaced only Unplaced only
</label>
<label class="filter-checkbox" title="Positions a transform moved, which nobody has confirmed against the current drawing yet">
<input type="checkbox" v-model="showUnverifiedOnly" />
Needs review ({{ unverifiedCount }})
</label>
<label class="filter-checkbox" title="Only assets on the level you are editing">
<input type="checkbox" v-model="thisLevelOnly" />
This level only
</label> </label>
</div> </div>
@@ -47,6 +64,15 @@
<div class="asset-meta"> <div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span> <span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span> <span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
<span v-if="asset.levelid && asset.levelid !== editingLevelId"
class="badge badge-sm badge-other-level"
:title="'On ' + levelName(asset.levelid)">{{ levelName(asset.levelid) }}</span>
<span v-if="asset.mapx && !asset.levelid" class="badge badge-sm badge-warning"
title="Has a position but no level, so it cannot be drawn">no level</span>
<button v-if="asset.mapx && asset.levelid && !asset.mapverifiedat"
class="badge badge-sm badge-review" type="button"
title="This position came from a transform. Click to confirm it is right."
@click.stop="markReviewed(asset)">confirm</button>
</div> </div>
</div> </div>
</div> </div>
@@ -96,19 +122,114 @@
:theme="currentTheme" :theme="currentTheme"
:pickerMode="!!selectedAsset" :pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null" :initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
:levelid="editingLevelId"
@positionPicked="handlePositionPicked" @positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick" @markerClick="handleMarkerClick"
/> />
</div> </div>
</div> </div>
<!-- Recalibrate: move every marker on this level by a transform read off two
landmarks. Deriving it from the image dimensions instead would stretch
one axis and be wrong everywhere, so the numbers come from features
visible on both drawings. -->
<div v-if="showTransform" class="modal-overlay">
<div class="modal modal-wide">
<div class="modal-header"><h3>Recalibrate {{ levelName(editingLevelId) }}</h3></div>
<div class="modal-body">
<p class="input-hint">
Pick two features present on both the old and the new drawing. A
building corner plus something central beats two corners: a long
baseline makes the derived scale more forgiving.
</p>
<table class="data-table">
<thead>
<tr><th>Landmark</th><th>Old X</th><th>Old Y</th><th>New X</th><th>New Y</th></tr>
</thead>
<tbody>
<tr v-for="(mark, index) in landmarks" :key="index">
<td>{{ index + 1 }}</td>
<td><input v-model.number="mark.fromx" type="number" class="form-control" /></td>
<td><input v-model.number="mark.fromy" type="number" class="form-control" /></td>
<td><input v-model.number="mark.tox" type="number" class="form-control" /></td>
<td><input v-model.number="mark.toy" type="number" class="form-control" /></td>
</tr>
</tbody>
</table>
<button class="btn btn-small btn-secondary" @click="landmarks.push({})">
Add a third landmark
</button>
<div v-if="preview" class="transform-preview">
<h4>Preview</h4>
<p class="mono">
X: scale {{ preview.transform.scalex.toFixed(4) }}, offset {{ Math.round(preview.transform.offsetx) }}<br />
Y: scale {{ preview.transform.scaley.toFixed(4) }}, offset {{ Math.round(preview.transform.offsety) }}
</p>
<p>
{{ preview.assetcount }} marker(s) would move.
<strong v-if="preview.outofboundscount" class="warn">
{{ preview.outofboundscount }} would land outside the drawing.
</strong>
</p>
<p class="input-hint">
A scale near 1 means the drawing shifted rather than rescaled. A
scale far from 1 on an axis that only gained canvas is the sign of a
bad landmark pair.
</p>
</div>
<div v-if="transformError" class="error-message">{{ transformError }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showTransform = false">Cancel</button>
<button class="btn btn-secondary" @click="runTransform(true)" :disabled="working">Preview</button>
<button class="btn btn-primary" @click="runTransform(false)" :disabled="working || !preview">
Move {{ preview ? preview.assetcount : 0 }} marker(s)
</button>
</div>
</div>
</div>
<!-- Undo. Every bulk write snapshots first, and a restore snapshots too, so a
second attempt is always possible. -->
<div v-if="showSnapshots" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Undo history</h3></div>
<div class="modal-body">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Markers</th><th></th></tr></thead>
<tbody>
<tr v-for="snapshot in snapshots" :key="snapshot.snapshotid">
<td class="mono">{{ (snapshot.createddate || '').slice(0, 16).replace('T', ' ') }}</td>
<td>{{ snapshot.reason }}</td>
<td>{{ snapshot.assetcount }}</td>
<td>
<button class="btn btn-small" @click="restore(snapshot)" :disabled="working">
{{ snapshot.restoredat ? 'Restore again' : 'Restore' }}
</button>
</td>
</tr>
<tr v-if="!snapshots.length">
<td colspan="4" class="empty">Nothing to undo yet.</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showSnapshots = false">Close</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next' import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue' import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api' import { assetsApi, mapPositionsApi } from '../api'
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
state as mapConfig } from '../composables/mapConfig'
import { currentTheme } from '../stores/theme' import { currentTheme } from '../stores/theme'
import { useToast } from '../composables/toast' import { useToast } from '../composables/toast'
const toast = useToast() const toast = useToast()
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
const pickedPosition = ref(null) const pickedPosition = ref(null)
const filterType = ref('') const filterType = ref('')
const showUnplacedOnly = ref(false) const showUnplacedOnly = ref(false)
// A transform is a guess: it moves markers but cannot know which machines
// actually moved, so everything it touches needs confirming against the drawing.
// This is the working queue for that.
const showUnverifiedOnly = ref(false)
const thisLevelOnly = ref(true)
const search = ref('')
// Which drawing is being placed on. Every position saved from here belongs to it.
const editingLevelId = ref(null)
// Recalibration state. Two landmarks is the minimum that determines both axes;
// a third is offered because points picked by eye carry a few pixels of error and
// three let it cancel rather than accumulate.
const showTransform = ref(false)
const landmarks = ref([{}, {}])
const preview = ref(null)
const transformError = ref('')
const showSnapshots = ref(false)
const snapshots = ref([])
const working = ref(false)
const filteredAssets = computed(() => { const filteredAssets = computed(() => {
let result = assets.value let result = assets.value
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
result = result.filter(a => !a.mapx || !a.mapy) result = result.filter(a => !a.mapx || !a.mapy)
} }
if (showUnverifiedOnly.value) {
// Placed, on a level, and never confirmed. An unplaced asset is not
// "unreviewed" - it is simply not on the map yet.
result = result.filter(a => a.mapx && a.levelid && !a.mapverifiedat)
}
if (thisLevelOnly.value && editingLevelId.value) {
// Unplaced assets stay visible whatever the level filter says: they are the
// ones you are here to place, and they belong to no level yet.
result = result.filter(a => !a.mapx || a.levelid === editingLevelId.value)
}
const term = search.value.trim().toLowerCase()
if (term) {
result = result.filter(a =>
(a.name || '').toLowerCase().includes(term) ||
(a.assetnumber || '').toLowerCase().includes(term))
}
return result return result
}) })
// Only markers on the level being drawn. Markers from another level would appear
// at coordinates that mean nothing on this one.
const placedAssets = computed(() => { const placedAssets = computed(() => {
return assets.value.filter(a => a.mapx && a.mapy) return assets.value.filter(a => a.mapx && a.mapy &&
a.levelid === editingLevelId.value)
}) })
const unverifiedCount = computed(() =>
assets.value.filter(a => a.mapx && a.levelid && !a.mapverifiedat).length)
onMounted(async () => { onMounted(async () => {
await loadMapConfig()
editingLevelId.value = mapConfig.defaultlevelid
await loadAssets() await loadAssets()
}) })
watch(editingLevelId, (levelid) => {
if (levelid) setCurrentLevel(levelid)
})
async function loadAssets() { async function loadAssets() {
loading.value = true loading.value = true
try { try {
@@ -154,6 +325,80 @@ async function loadAssets() {
} }
} }
function openTransform() {
preview.value = null
transformError.value = ''
landmarks.value = [{}, {}]
showTransform.value = true
}
async function runTransform(dryrun) {
working.value = true
transformError.value = ''
try {
const { data } = await mapPositionsApi.transform({
levelid: editingLevelId.value,
landmarks: landmarks.value.filter(mark =>
[mark.fromx, mark.fromy, mark.tox, mark.toy].every(
value => value !== undefined && value !== null && value !== '')),
dryrun,
})
if (dryrun) {
preview.value = data.data
} else {
showTransform.value = false
preview.value = null
await loadAssets()
// Straight into the review queue: every moved marker is now unconfirmed,
// and that is the work the transform created.
showUnverifiedOnly.value = true
toast.success(data.message || 'Markers moved')
}
} catch (err) {
transformError.value = err?.response?.data?.data?.error?.message
|| 'The transform could not be applied'
} finally {
working.value = false
}
}
async function openSnapshots() {
showSnapshots.value = true
try {
const { data } = await mapPositionsApi.snapshots()
snapshots.value = data.data || []
} catch (err) {
snapshots.value = []
}
}
async function restore(snapshot) {
if (!confirm(`Restore ${snapshot.assetcount} marker position(s) from ` +
`"${snapshot.reason}"? The current positions are snapshotted first.`)) return
working.value = true
try {
const { data } = await mapPositionsApi.restore(snapshot.snapshotid)
await loadAssets()
await openSnapshots()
toast.success(data.message || 'Positions restored')
} catch (err) {
toast.error('Could not restore that snapshot')
} finally {
working.value = false
}
}
// Confirm a marker is in the right place without moving it - the common case in
// a review pass, and the reason the queue empties.
async function markReviewed(asset) {
try {
await mapPositionsApi.verify([asset.assetid])
asset.mapverifiedat = new Date().toISOString()
} catch (err) {
toast.error('Could not mark that reviewed')
}
}
function getTypeIcon(assettype) { function getTypeIcon(assettype) {
const icons = { const icons = {
'machine': Cog, 'machine': Cog,
@@ -184,16 +429,23 @@ async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return if (!selectedAsset.value || !pickedPosition.value) return
try { try {
await assetsApi.update(selectedAsset.value.assetid, { // Through the bulk endpoint, which requires a level and stamps the review
// state: placing a marker by hand IS the confirmation, and a snapshot is
// taken so the placement can be undone.
await mapPositionsApi.setPositions([{
assetid: selectedAsset.value.assetid,
mapx: Math.round(pickedPosition.value.left), mapx: Math.round(pickedPosition.value.left),
mapy: Math.round(pickedPosition.value.top) mapy: Math.round(pickedPosition.value.top),
}) levelid: editingLevelId.value,
}])
// Update local state // Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid) const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) { if (asset) {
asset.mapx = Math.round(pickedPosition.value.left) asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = Math.round(pickedPosition.value.top) asset.mapy = Math.round(pickedPosition.value.top)
asset.levelid = editingLevelId.value
asset.mapverifiedat = new Date().toISOString()
} }
selectedAsset.value = null selectedAsset.value = null
@@ -240,7 +492,7 @@ function cancelEdit() {
.map-editor { .map-editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 40px); height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
} }
.header-actions { .header-actions {
@@ -266,13 +518,30 @@ function cancelEdit() {
} }
.panel-header { .panel-header {
/* The heading takes its own row and the three controls share the next one(s).
All four on a single row inside a 320px panel squeezed the search box until
its placeholder read "Search na". */
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
gap: 0.5rem;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.panel-header h3 {
flex: 1 1 100%;
}
.panel-header .form-control {
/* One control per row. Two of them side by side in a 318px panel left the
search box 139px for a placeholder needing 216px, so it read "Search name
o" - a control whose own label does not fit is not a narrower control, it is
an unlabelled one. */
flex: 1 1 100%;
min-width: 0;
}
.panel-header h3 { .panel-header h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
@@ -409,4 +678,31 @@ function cancelEdit() {
text-align: center; text-align: center;
color: var(--text-light); color: var(--text-light);
} }
.badge-review {
background: var(--warning);
color: #1a1a1a;
border: none;
cursor: pointer;
}
.badge-other-level {
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.transform-preview {
margin-top: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.transform-preview h4 { margin: 0 0 0.5rem; }
.mono { font-family: monospace; }
.warn { color: var(--warning); }
.modal-wide { min-width: 44rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.8rem; }
</style> </style>

View File

@@ -12,6 +12,17 @@
<template v-else> <template v-else>
<!-- Filter Controls --> <!-- Filter Controls -->
<div class="map-filters"> <div class="map-filters">
<select
v-if="levelChoices.length > 1"
v-model.number="shownLevelId"
@change="onLevelChange"
title="Which floor plan to show"
>
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
{{ level.label }}
</option>
</select>
<select v-model="selectedType" @change="onTypeChange"> <select v-model="selectedType" @change="onTypeChange">
<option value="">All Asset Types</option> <option value="">All Asset Types</option>
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype"> <option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
@@ -60,6 +71,7 @@
</div> </div>
<ShopFloorMap <ShopFloorMap
:levelid="shownLevelId"
:machines="filteredAssets" :machines="filteredAssets"
:machinetypes="[]" :machinetypes="[]"
:businessunits="businessunits" :businessunits="businessunits"
@@ -82,7 +94,8 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api' import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme' import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf' import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes' import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors' import { getSubtypeId } from '../utils/mapColors'
@@ -104,6 +117,41 @@ const selectedSubtype = ref('')
const selectedBusinessUnit = ref('') const selectedBusinessUnit = ref('')
const selectedStatus = ref('') const selectedStatus = ref('')
const searchQuery = ref('') const searchQuery = ref('')
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
// (ADR-017), so this decides both the drawing and which markers belong on it.
const shownLevelId = ref(null)
const levelChoices = computed(() => levelOptions())
function onLevelChange() {
// Keep the shared composable in step, so a map-position picker opened later
// starts on the level being looked at rather than the site default.
setCurrentLevel(shownLevelId.value)
}
// A search whose only matches are on another floor would otherwise show an empty
// map: the assets matched, the filter counted them, and nothing was drawn.
// Follow the results to the level that actually holds them - the level with the
// most matches, so a search matching several floors lands on the best one.
function followSearchAcrossLevels() {
if (!searchQuery.value.trim()) return
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
if (!matches.length) return
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
const tally = new Map()
matches.forEach(asset => {
const levelid = asset.levelid ?? null
if (levelid === null) return
tally.set(levelid, (tally.get(levelid) || 0) + 1)
})
if (!tally.size) return
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
shownLevelId.value = best
setCurrentLevel(best)
}
const exporting = ref(false) const exporting = ref(false)
let searchTimeout = null let searchTimeout = null
@@ -241,13 +289,21 @@ async function exportPdf() {
try { try {
await loadMapConfig() await loadMapConfig()
await exportMapPdf({ await exportMapPdf({
assets: filteredAssets.value, // Only this level's markers. The sheet is one drawing, so a marker
// positioned against another level would be printed on the wrong floor
// plan - the same failure the on-screen map had, in a form nobody can
// correct after it is printed and carried onto the floor.
assets: filteredAssets.value.filter(
asset => (asset.levelid ?? null) === shownLevelId.value),
// blueprintUrlFor applies withBase - the raw setting value is a // blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops. // root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper. // The PDF always uses the light blueprint: it prints on white paper.
blueprintUrl: blueprintUrlFor('light'), blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
mapWidth: mapConfig.width, mapWidth: dimensionsFor(shownLevelId.value).width,
mapHeight: mapConfig.height, mapHeight: dimensionsFor(shownLevelId.value).height,
// Named on the sheet, because a floor plan with no level on it is not
// identifiable once it is printed and carried to the floor.
levelname: levelName(shownLevelId.value),
selectedType: selectedType.value, selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value, subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value, subtypeNames: subtypeNameMap.value,
@@ -262,7 +318,8 @@ async function exportPdf() {
} }
onMounted(async () => { onMounted(async () => {
loadMapConfig() await loadMapConfig()
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
try { try {
const response = await assetsApi.getMap() const response = await assetsApi.getMap()
const data = response.data.data || {} const data = response.data.data || {}
@@ -302,6 +359,7 @@ function updateMapLayers() {
function debouncedSearch() { function debouncedSearch() {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
followSearchAcrossLevels()
updateMapLayers() updateMapLayers()
}, 300) }, 300)
} }
@@ -315,7 +373,7 @@ function handleMarkerClick(asset) {
.map-page { .map-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 2rem); height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
} }
.map-page .page-header { .map-page .page-header {

View File

@@ -1,479 +1,494 @@
<template> <template>
<div class="search-results"> <div class="search-results">
<div class="page-header"> <div class="page-header">
<h2>Search Results</h2> <h2>Search Results</h2>
<span v-if="results.length" class="results-count"> <span v-if="results.length" class="results-count">
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}" {{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
</span> </span>
</div> </div>
<div class="search-box"> <div class="search-box">
<input <input
v-model="searchInput" v-model="searchInput"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search machines, applications, knowledge base, IPs, hostnames..." placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch" @keyup.enter="performSearch"
/> />
<button class="btn btn-primary" @click="performSearch">Search</button> <button class="btn btn-primary" @click="performSearch">Search</button>
</div> </div>
<div v-if="results.length" class="filter-buttons"> <div v-if="results.length" class="filter-buttons">
<button <button
v-for="filter in filterList" v-for="filter in filterList"
:key="filter.key" :key="filter.key"
class="filter-btn" class="filter-btn"
:class="{ active: activeFilter === filter.key }" :class="{ active: activeFilter === filter.key }"
@click="activeFilter = filter.key" @click="activeFilter = filter.key"
> >
{{ filter.label }} {{ filter.label }}
<span class="filter-count">{{ getFilterCount(filter.key) }}</span> <span class="filter-count">{{ getFilterCount(filter.key) }}</span>
</button> </button>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Searching...</div> <div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query"> <template v-else-if="query">
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results"> <div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}" No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button> <button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
</div> </div>
<div v-else-if="results.length === 0" class="no-results"> <div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}" No results found for "{{ query }}"
</div> </div>
<div v-else class="results-list"> <div v-else class="results-list">
<div <div
v-for="result in filteredResults" v-for="result in filteredResults"
:key="`${result.type}-${result.id}`" :key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`" :id="`result-${result.type}-${result.id}`"
class="result-item" class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }" :class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
> >
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span> <span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content"> <div class="result-content">
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title"> <router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
{{ result.title }} {{ result.title }}
</router-link> </router-link>
<a <a
v-else v-else
href="#" href="#"
class="result-title" class="result-title"
@click.prevent="openKBArticle(result)" @click.prevent="openKBArticle(result)"
> >
{{ result.title }} {{ result.title }}
</a> </a>
<div class="result-meta"> <div class="result-meta">
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span> <span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
<span v-if="result.location" class="result-location">{{ result.location }}</span> <span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span> <span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span> <span v-if="result.iscurrent" class="badge badge-success">Active</span>
</div> </div>
</div> </div>
<button <button
class="share-btn" class="share-btn"
@click="shareResult(result)" @click="shareResult(result)"
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'" :title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
> >
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }} {{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<div v-else class="no-results"> <div v-else class="no-results">
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more. Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api' import { searchApi, knowledgebaseApi } from '../api'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const results = ref([]) const results = ref([])
const query = ref('') const query = ref('')
const searchInput = ref('') const searchInput = ref('')
const activeFilter = ref('all') const activeFilter = ref('all')
const typeCounts = ref({}) const typeCounts = ref({})
const totalAll = ref(0) const totalAll = ref(0)
const highlightId = ref(null) const highlightId = ref(null)
const copiedId = ref(null) const copiedId = ref(null)
const typeLabels = { const typeLabels = {
machine: 'Machine', machine: 'Machine',
pc: 'PC', pc: 'PC',
computer: 'PC', computer: 'PC',
application: 'App', application: 'App',
knowledgebase: 'KB', knowledgebase: 'KB',
printer: 'Printer', printer: 'Printer',
network_device: 'Network', network_device: 'Network',
measuring_tool: 'Measuring Tool', measuring_tool: 'Measuring Tool',
employee: 'Employee', employee: 'Employee',
notification: 'Notice', notification: 'Notice',
subnet: 'Subnet' subnet: 'Subnet',
} usb_device: 'USB',
printed_item: 'Printed Part'
const filterTypeMap = { }
all: null,
machines: ['machine'], const filterTypeMap = {
computers: ['computer'], all: null,
printers: ['printer'], machines: ['machine'],
network: ['network_device', 'subnet'], computers: ['computer'],
measuringtools: ['measuring_tool'], printers: ['printer'],
applications: ['application'], network: ['network_device', 'subnet'],
knowledgebase: ['knowledgebase'], measuringtools: ['measuring_tool'],
notifications: ['notification'], applications: ['application'],
employees: ['employee'] knowledgebase: ['knowledgebase'],
} notifications: ['notification'],
employees: ['employee'],
const filterList = [ usb: ['usb_device'],
{ key: 'all', label: 'All' }, printedparts: ['printed_item']
{ key: 'machines', label: 'Machines' }, }
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' }, const filterList = [
{ key: 'network', label: 'Network' }, { key: 'all', label: 'All' },
{ key: 'measuringtools', label: 'Measuring Tools' }, { key: 'machines', label: 'Machines' },
{ key: 'applications', label: 'Apps' }, { key: 'computers', label: 'PCs' },
{ key: 'knowledgebase', label: 'KB' }, { key: 'printers', label: 'Printers' },
{ key: 'notifications', label: 'Notices' }, { key: 'network', label: 'Network' },
{ key: 'employees', label: 'Employees' } { key: 'measuringtools', label: 'Measuring Tools' },
] { key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
function typeLabel(type) { { key: 'notifications', label: 'Notices' },
return typeLabels[type] || type { key: 'employees', label: 'Employees' },
} { key: 'usb', label: 'USB' },
{ key: 'printedparts', label: 'Printed Parts' }
function getFilterCount(filterKey) { ]
if (filterKey === 'all') return totalAll.value || results.value.length
const types = filterTypeMap[filterKey] function typeLabel(type) {
if (!types) return 0 return typeLabels[type] || type
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0) }
}
function getFilterCount(filterKey) {
const filteredResults = computed(() => { if (filterKey === 'all') return totalAll.value || results.value.length
if (activeFilter.value === 'all') return results.value const types = filterTypeMap[filterKey]
const types = filterTypeMap[activeFilter.value] if (!types) return 0
if (!types) return results.value return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
return results.value.filter(r => types.includes(r.type)) }
})
const filteredResults = computed(() => {
async function search(q) { if (activeFilter.value === 'all') return results.value
if (!q || q.length < 2) { const types = filterTypeMap[activeFilter.value]
results.value = [] if (!types) return results.value
return return results.value.filter(r => types.includes(r.type))
} })
loading.value = true async function search(q) {
activeFilter.value = 'all' if (!q || q.length < 2) {
results.value = []
try { return
const response = await searchApi.search(q) }
const data = response.data.data
loading.value = true
// Handle ServiceNOW redirect activeFilter.value = 'all'
if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank') try {
query.value = q const response = await searchApi.search(q)
results.value = [] const data = response.data.data
loading.value = false
return // Handle ServiceNOW redirect
} if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank')
// Smart redirect - auto-navigate to exact match query.value = q
if (data?.redirect) { results.value = []
router.replace(data.redirect.url) loading.value = false
return return
} }
results.value = data?.results || [] // Smart redirect - auto-navigate to exact match
typeCounts.value = data?.counts || {} if (data?.redirect) {
totalAll.value = data?.total_all || results.value.length router.replace(data.redirect.url)
query.value = q return
} catch (error) { }
console.error('Search error:', error)
results.value = [] results.value = data?.results || []
} finally { typeCounts.value = data?.counts || {}
loading.value = false totalAll.value = data?.total_all || results.value.length
} query.value = q
} } catch (error) {
console.error('Search error:', error)
function performSearch() { results.value = []
if (searchInput.value.trim()) { } finally {
router.push({ path: '/search', query: { q: searchInput.value.trim() } }) loading.value = false
} }
} }
async function openKBArticle(result) { function performSearch() {
try { const term = searchInput.value.trim()
await knowledgebaseApi.trackClick(result.id) if (!term) return
if (result.linkurl) { // Searching a term that is ALREADY in the URL is a duplicate navigation: the
window.open(result.linkurl, '_blank') // router aborts it, so the route watcher never fires and the button does
} else { // nothing at all. Re-run the query directly in that case. The sidebar box
router.push(result.url) // never hits this, because it is always navigating from somewhere else -
} // which is why the two search boxes appeared to behave differently.
} catch (error) { if (term === route.query.q) {
console.error('Error tracking click:', error) search(term)
if (result.linkurl) { return
window.open(result.linkurl, '_blank') }
} else { router.push({ path: '/search', query: { q: term } })
router.push(result.url) }
}
} async function openKBArticle(result) {
} try {
await knowledgebaseApi.trackClick(result.id)
function shareResult(result) { if (result.linkurl) {
const url = new URL(window.location.href) window.open(result.linkurl, '_blank')
url.searchParams.set('highlight', `${result.type}-${result.id}`) } else {
navigator.clipboard.writeText(url.toString()).then(() => { router.push(result.url)
copiedId.value = `${result.type}-${result.id}` }
setTimeout(() => { copiedId.value = null }, 2000) } catch (error) {
}) console.error('Error tracking click:', error)
} if (result.linkurl) {
window.open(result.linkurl, '_blank')
function scrollToHighlight() { } else {
if (highlightId.value) { router.push(result.url)
nextTick(() => { }
const el = document.getElementById(`result-${highlightId.value}`) }
if (el) { }
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000) function shareResult(result) {
} const url = new URL(window.location.href)
}) url.searchParams.set('highlight', `${result.type}-${result.id}`)
} navigator.clipboard.writeText(url.toString()).then(() => {
} copiedId.value = `${result.type}-${result.id}`
setTimeout(() => { copiedId.value = null }, 2000)
onMounted(() => { })
const q = route.query.q }
const hl = route.query.highlight
if (hl) highlightId.value = hl function scrollToHighlight() {
if (q) { if (highlightId.value) {
searchInput.value = q nextTick(() => {
search(q) const el = document.getElementById(`result-${highlightId.value}`)
} if (el) {
}) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000)
watch(() => route.query.q, (newQ) => { }
if (newQ) { })
searchInput.value = newQ }
const hl = route.query.highlight }
if (hl) highlightId.value = hl
search(newQ) onMounted(() => {
} const q = route.query.q
}) const hl = route.query.highlight
if (hl) highlightId.value = hl
watch(results, () => { if (q) {
scrollToHighlight() searchInput.value = q
}) search(q)
</script> }
})
<style scoped>
.page-header { watch(() => route.query.q, (newQ) => {
display: flex; if (newQ) {
align-items: center; searchInput.value = newQ
gap: 1rem; const hl = route.query.highlight
margin-bottom: 1.5rem; if (hl) highlightId.value = hl
} search(newQ)
}
.page-header h2 { })
margin: 0;
} watch(results, () => {
scrollToHighlight()
.results-count { })
color: var(--text-light); </script>
font-size: 0.9rem;
} <style scoped>
.page-header {
.search-box { display: flex;
display: flex; align-items: center;
gap: 0.5rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
.search-box input { .page-header h2 {
flex: 1; margin: 0;
} }
.filter-buttons { .results-count {
display: flex; color: var(--text-light);
flex-wrap: wrap; font-size: 0.9rem;
gap: 0.375rem; }
margin-bottom: 1rem;
} .search-box {
display: flex;
.filter-btn { gap: 0.5rem;
padding: 0.3rem 0.6rem; margin-bottom: 1rem;
border: 1px solid var(--border); }
border-radius: 4px;
background: var(--bg); .search-box input {
color: var(--text); flex: 1;
cursor: pointer; }
font-size: 0.8rem;
display: flex; .filter-buttons {
align-items: center; display: flex;
gap: 0.3rem; flex-wrap: wrap;
} gap: 0.375rem;
margin-bottom: 1rem;
.filter-btn.active { }
background: var(--primary);
color: #fff; .filter-btn {
border-color: var(--primary); padding: 0.3rem 0.6rem;
} border: 1px solid var(--border);
border-radius: 4px;
.filter-btn:hover:not(.active) { background: var(--bg);
border-color: var(--primary); color: var(--text);
} cursor: pointer;
font-size: 0.8rem;
.filter-count { display: flex;
background: rgba(128, 128, 128, 0.15); align-items: center;
padding: 0.1rem 0.35rem; gap: 0.3rem;
border-radius: 8px; }
font-size: 0.75rem;
min-width: 1.25rem; .filter-btn.active {
text-align: center; background: var(--primary);
} color: #fff;
border-color: var(--primary);
.filter-btn.active .filter-count { }
background: rgba(255, 255, 255, 0.25);
} .filter-btn:hover:not(.active) {
border-color: var(--primary);
.no-results { }
text-align: center;
color: var(--text-light); .filter-count {
padding: 2rem; background: rgba(128, 128, 128, 0.15);
} padding: 0.1rem 0.35rem;
border-radius: 8px;
.results-list { font-size: 0.75rem;
display: flex; min-width: 1.25rem;
flex-direction: column; text-align: center;
} }
.result-item { .filter-btn.active .filter-count {
display: flex; background: rgba(255, 255, 255, 0.25);
align-items: flex-start; }
gap: 1rem;
padding: 0.75rem; .no-results {
border-bottom: 1px solid var(--border); text-align: center;
transition: background 0.3s ease; color: var(--text-light);
} padding: 2rem;
}
.result-item:last-child {
border-bottom: none; .results-list {
} display: flex;
flex-direction: column;
.result-item.highlighted { }
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary); .result-item {
} display: flex;
align-items: flex-start;
.result-type { gap: 1rem;
font-size: 0.7rem; padding: 0.75rem;
font-weight: 600; border-bottom: 1px solid var(--border);
text-transform: uppercase; transition: background 0.3s ease;
padding: 0.2rem 0.45rem; }
border-radius: 4px;
min-width: 65px; .result-item:last-child {
text-align: center; border-bottom: none;
flex-shrink: 0; }
}
.result-item.highlighted {
/* Per-domain badge palette. Values live in CSS variables on the container so background: rgba(65, 129, 255, 0.08);
the dark theme overrides them in one place (below) instead of restating border-left: 3px solid var(--primary);
every selector. Each badge rule just references its pair. */ }
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0; .result-type {
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32; font-size: 0.7rem;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100; font-weight: 600;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2; text-transform: uppercase;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b; padding: 0.2rem 0.45rem;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17; border-radius: 4px;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f; min-width: 65px;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c; text-align: center;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593; flex-shrink: 0;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c; }
}
/* Per-domain badge palette. Values live in CSS variables on the container so
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); } the dark theme overrides them in one place (below) instead of restating
.result-type.pc, every selector. Each badge rule just references its pair. */
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); } .search-results {
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); } --rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); } --rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); } --rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); } --rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); } --rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); } --rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); } --rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); } --rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
.result-content { --rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
flex: 1; }
min-width: 0;
} .result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-title { .result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
color: var(--link); .result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
text-decoration: none; .result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
font-weight: 500; .result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
} .result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-title:hover { .result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
text-decoration: underline; .result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
} .result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-meta { .result-content {
display: flex; flex: 1;
flex-wrap: wrap; min-width: 0;
gap: 0.75rem; }
margin-top: 0.25rem;
font-size: 0.8rem; .result-title {
color: var(--text-light); color: var(--link);
} text-decoration: none;
font-weight: 500;
.result-subtitle { }
color: var(--text-light);
} .result-title:hover {
text-decoration: underline;
.result-ticket { }
font-family: monospace;
font-size: 0.75rem; .result-meta {
} display: flex;
flex-wrap: wrap;
.share-btn { gap: 0.75rem;
background: var(--bg); margin-top: 0.25rem;
border: 1px solid var(--border); font-size: 0.8rem;
border-radius: 4px; color: var(--text-light);
cursor: pointer; }
color: var(--text-light);
font-size: 0.7rem; .result-subtitle {
padding: 0.2rem 0.4rem; color: var(--text-light);
flex-shrink: 0; }
}
.result-ticket {
.share-btn:hover { font-family: monospace;
color: var(--primary); font-size: 0.75rem;
border-color: var(--primary); }
}
.share-btn {
@media (prefers-color-scheme: dark) { background: var(--bg);
.search-results { border: 1px solid var(--border);
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6; border-radius: 4px;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784; cursor: pointer;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d; color: var(--text-light);
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8; font-size: 0.7rem;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1; padding: 0.2rem 0.4rem;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f; flex-shrink: 0;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea; }
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da; .share-btn:hover {
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91; color: var(--primary);
} border-color: var(--primary);
} }
</style>
@media (prefers-color-scheme: dark) {
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>

View File

@@ -19,7 +19,7 @@
<p class="hint">Used for the dashboard header and QR / absolute links.</p> <p class="hint">Used for the dashboard header and QR / absolute links.</p>
<div class="form-group"> <div class="form-group">
<label>Facility name</label> <label>Facility name</label>
<input v-model="form.facility_name" type="text" class="form-control" placeholder="West Jefferson" /> <input v-model="form.facility_name" type="text" class="form-control" placeholder="Main Plant" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label> <label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>

View File

@@ -9,6 +9,9 @@
<div class="header-center"> <div class="header-center">
<div class="location-title">{{ facilityName }}</div> <div class="location-title">{{ facilityName }}</div>
<h1>Shopfloor Dashboard</h1> <h1>Shopfloor Dashboard</h1>
<!-- Same number the sidebar and the classic site show, since the board
is what people read it off from across the floor. -->
<div class="fiscal-week">Fiscal Week {{ currentFiscalWeek }}</div>
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -241,9 +244,12 @@ import { notificationsApi, businessUnitsApi, dashboardDefaultsApi, settingsApi }
import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime' import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings' import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import { fiscalWeek } from '@/utils/fiscalWeek'
const loading = ref(true) const loading = ref(true)
const facilityName = ref('ShopDB') const facilityName = ref('ShopDB')
// A wall display runs for weeks; the clock tick refreshes this too.
const currentFiscalWeek = ref(fiscalWeek())
const siteLogo = ref(withBase('/ge-aerospace-logo.svg')) const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
// Employee photo placeholder: the shipped GE monogram (square avatar). Kept // Employee photo placeholder: the shipped GE monogram (square avatar). Kept
// separate from siteLogo so a blank/broken site_logo setting never leaves an // separate from siteLogo so a blank/broken site_logo setting never leaves an
@@ -716,6 +722,15 @@ function handlePhotoError(e) {
text-align: center; text-align: center;
} }
.fiscal-week {
font-size: 18px;
font-weight: 600;
letter-spacing: 2px;
color: #b8c4d8;
margin-top: 4px;
text-transform: uppercase;
}
.location-title { .location-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;

View File

@@ -128,8 +128,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode' import { renderQrDataUrl, barcodeInto } from '@/utils/codes'
import { renderQrDataUrl } from './qrLogo'
import { getSetting } from '@/utils/siteSettings' import { getSetting } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import { import {
@@ -208,9 +207,7 @@ async function renderCode() {
await nextTick() await nextTick()
if (!barcodeEl.value) return if (!barcodeEl.value) return
try { try {
JsBarcode(barcodeEl.value, text, { barcodeInto(barcodeEl.value, text, { height: 70 })
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
})
} catch (err) { } catch (err) {
console.error('Barcode error:', err) console.error('Barcode error:', err)
} }

View File

@@ -140,8 +140,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode' import { renderQrDataUrl, barcodeInto } from '@/utils/codes'
import { renderQrDataUrl } from './qrLogo'
import { getSetting } from '@/utils/siteSettings' import { getSetting } from '@/utils/siteSettings'
import { import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes, TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
@@ -273,10 +272,7 @@ function renderBarcodes() {
const text = codeMap.value[asset.assetid] const text = codeMap.value[asset.assetid]
if (!el || !text) continue if (!el || !text) continue
try { try {
JsBarcode(el, text, { barcodeInto(el, text, { width, height, background: 'transparent' })
format: 'CODE128', displayValue: false, width, height, margin: 0,
background: 'transparent',
})
} catch (err) { } catch (err) {
console.error('Barcode error:', asset.assetnumber, err) console.error('Barcode error:', asset.assetnumber, err)
} }
@@ -299,8 +295,10 @@ onMounted(async () => {
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr' codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
encodes.value = await resolveDefaultEncodes(assettype) encodes.value = await resolveDefaultEncodes(assettype)
try { try {
const response = await config.api.list({ perpage: 500 }) // listAll, not list: perpage is clamped to 100 server-side, so a batch
assets.value = response.data.data || [] // sheet built from list() silently omitted every asset past the first
// 100 and printed a short run that looked complete.
assets.value = await config.api.listAll()
} catch (err) { } catch (err) {
console.error('Error loading assets:', err) console.error('Error loading assets:', err)
} finally { } finally {

View File

@@ -31,7 +31,7 @@ import { useRoute } from 'vue-router'
import { machinesApi } from '../../api' import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings' import { getBadgeLogo } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import JsBarcode from 'jsbarcode' import { barcodeInto } from '@/utils/codes'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
@@ -75,12 +75,10 @@ onMounted(async () => {
function generateBarcode() { function generateBarcode() {
if (!barcodeEl.value || !machine.value) return if (!barcodeEl.value || !machine.value) return
try { try {
JsBarcode(barcodeEl.value, machine.value.assetnumber, { // CODE39, not the CODE128 default: the badge readers predate the
format: 'CODE39', // shop-floor scanners and only decode CODE39.
displayValue: false, barcodeInto(barcodeEl.value, machine.value.assetnumber, {
width: 2, format: 'CODE39', height: 70,
height: 70,
margin: 0
}) })
} catch (e) { } catch (e) {
console.error('Barcode generation error:', e) console.error('Barcode generation error:', e)

View File

@@ -1,62 +0,0 @@
// Shared GE monogram + QR-with-logo rendering for the printer QR label pages.
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
// (canvases print unreliably) with the GE monogram composited in the center.
import QRCode from 'qrcode'
import { getQrLogo } from '@/utils/siteSettings'
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
// Load the configured QR overlay image. On any load failure, fall back to the
// built-in GE monogram (last-resort constant above).
function loadLogo(url) {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => {
const fallback = new Image()
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
fallback.onerror = () => resolve(null)
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
}
img.src = url
})
}
function drawLogoOverlay(canvas, logo) {
const canvasContext = canvas.getContext('2d')
const size = canvas.width
const logoSize = Math.round(size * 0.22)
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// White circle background behind the monogram
canvasContext.beginPath()
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
canvasContext.fillStyle = '#fff'
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
}
}
// Render a QR for `url` to a PNG data URL with the configured logo composited
// in the center. Empty qr_logo setting = no overlay. Returns '' on failure.
export async function renderQrDataUrl(url) {
const qrLogoUrl = await getQrLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
if (qrLogoUrl) {
const logo = await loadLogo(qrLogoUrl)
drawLogoOverlay(canvas, logo)
}
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)
return ''
}
}

View File

@@ -270,8 +270,10 @@ async function loadFilterOptions(fields) {
filterOptions.value.locations = response.data.data || [] filterOptions.value.locations = response.data.data || []
} }
if (fields.includes('application') && !filterOptions.value.applications.length) { if (fields.includes('application') && !filterOptions.value.applications.length) {
const response = await applicationsApi.list({ perpage: 100 }) // listAll: 100 is the server-side cap, not a generous limit, and the
filterOptions.value.applications = response.data.data || [] // catalogue is past it - a report filtered by a late-alphabet
// application could not be built.
filterOptions.value.applications = await applicationsApi.listAll()
} }
} catch (error) { } catch (error) {
console.error('Error loading filter options:', error) console.error('Error loading filter options:', error)

View File

@@ -77,10 +77,10 @@
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option> <option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select> </select>
</div> </div>
<div class="form-group" v-if="form.displayrole === 'dashboard'"> <div class="form-group" v-if="form.displayrole === 'Dashboard'">
<label for="businessunitid">Location *</label> <label for="businessunitid">Location *</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control" <select id="businessunitid" v-model="form.businessunitid" class="form-control"
:required="form.displayrole === 'dashboard'"> :required="form.displayrole === 'Dashboard'">
<option value="">Select location...</option> <option value="">Select location...</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid"> <option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }} {{ bu.businessunit }}
@@ -127,13 +127,33 @@ import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError' import { apiError } from '../../utils/apiError'
const toast = useToast() const toast = useToast()
// Values are the kiosk's own vocabulary - the literal contents of
// C:\Enrollment\display-type.txt - so what is picked here is what a display
// reports. Must stay in step with DISPLAY_ROLE_PATHS in
// shopdb/core/models/dashboarddefault.py.
const roleOptions = [ const roleOptions = [
{ value: 'dashboard', label: 'Shopfloor Dashboard' }, { value: 'Dashboard', label: 'Shopfloor Dashboard' },
{ value: 'lobby', label: 'Lobby Slideshow' }, { value: 'Lobby', label: 'Lobby Slideshow' },
{ value: 'partskiosk', label: '3D Parts Kiosk' }, { value: '3DPrintRoom', label: '3D Print Room Kiosk' },
] ]
// Rows written before the vocabularies merged still carry the old spelling, so
// match case-insensitively and fall back to the retired name.
const legacyRoleLabels = { partskiosk: '3D Print Room Kiosk' }
const legacyRoleValues = { partskiosk: '3DPrintRoom' }
// Canonical value for any stored spelling. Without this, opening a row saved
// before the merge puts a value in the select that matches no option, and the
// dropdown renders blank as if the row had no role.
function canonicalRole(value) {
const wanted = (value || '').toLowerCase()
return roleOptions.find(r => r.value.toLowerCase() === wanted)?.value
|| legacyRoleValues[wanted]
|| 'Dashboard'
}
function roleLabel(value) { function roleLabel(value) {
return roleOptions.find(r => r.value === value)?.label || value const wanted = (value || '').toLowerCase()
return roleOptions.find(r => r.value.toLowerCase() === wanted)?.label
|| legacyRoleLabels[wanted]
|| value
} }
const items = ref([]) const items = ref([])
@@ -149,7 +169,7 @@ const error = ref('')
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const toDelete = ref(null) const toDelete = ref(null)
const form = ref({ fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' }) const form = ref({ fqdn: '', ipaddress: '', displayrole: 'Dashboard', businessunitid: '', description: '' })
onMounted(async () => { onMounted(async () => {
try { try {
@@ -184,10 +204,10 @@ function openModal(item = null) {
form.value = item ? { form.value = item ? {
fqdn: item.fqdn || '', fqdn: item.fqdn || '',
ipaddress: item.ipaddress || '', ipaddress: item.ipaddress || '',
displayrole: item.displayrole || 'dashboard', displayrole: canonicalRole(item.displayrole),
businessunitid: item.businessunitid || '', businessunitid: item.businessunitid || '',
description: item.description || '' description: item.description || ''
} : { fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' } } : { fqdn: '', ipaddress: '', displayrole: 'Dashboard', businessunitid: '', description: '' }
error.value = '' error.value = ''
showModal.value = true showModal.value = true
} }

View File

@@ -143,7 +143,7 @@
<input <input
type="text" type="text"
v-model="settings.site_base_url" v-model="settings.site_base_url"
placeholder="https://tsgwp00525.wjs.geaerospace.net/ops" placeholder="https://shopdb.example.net/ops"
@blur="saveSetting('site_base_url', settings.site_base_url)" @blur="saveSetting('site_base_url', settings.site_base_url)"
:disabled="saving" :disabled="saving"
> >

View File

@@ -1,101 +1,459 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Floor Map</h2> <h2>Floor Maps</h2>
<button class="btn btn-secondary" @click="showAddBuilding = true">Add building</button>
</div> </div>
<div class="section-card"> <p class="setting-description">
<div class="setting-group"> Each level is one drawing with its own blueprint and its own pixel size.
<h3>Facility Blueprint</h3> Marker positions are pixels in the level's own space, so the level id below
<p class="setting-description"> is what a position belongs to - it is worth knowing when you run a
The floor-plan image and its pixel dimensions for this facility. Map transform or ask about a marker that is in the wrong place.
markers are positioned against these dimensions, so the width and </p>
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<div class="setting-row"> <div v-if="loading" class="empty">Loading...</div>
<label>
<span>Blueprint image (light theme)</span> <div v-for="building in buildings" :key="building.buildingid" class="section-card">
<input <div class="building-header">
type="text" <input
v-model="settings.map_blueprint_light" v-model="building.buildingname"
placeholder="/static/images/sitemap2025-light.png" class="building-name"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)" @blur="renameBuilding(building)"
:disabled="saving" :disabled="saving"
> />
<div class="map-upload-row"> <span class="muted">{{ building.levels.length }} level(s)</span>
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" /> <button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" /> </div>
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small> <table class="data-table">
</label> <thead>
<tr>
<th title="What a marker position on this level refers to">Level id</th>
<th>Name</th>
<th>Order</th>
<th>Native size</th>
<th>Markers</th>
<th>Light</th>
<th>Dark</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="level in building.levels" :key="level.levelid">
<td><code class="levelid">{{ level.levelid }}</code></td>
<td>
<input
v-model="level.levelname"
class="form-control"
@blur="renameLevel(level)"
:disabled="saving"
/>
</td>
<td>
<input
v-model.number="level.sortorder"
type="number"
class="form-control order-input"
@blur="saveLevel(level, { sortorder: level.sortorder })"
:disabled="saving"
title="Lower sorts first. Ground 0, first floor 1, mezzanine 5 between them later."
/>
</td>
<td>
<div class="size-fields">
<input
v-model.number="level.mapwidth"
type="number"
min="1"
class="form-control size-input"
aria-label="Blueprint width in pixels"
@blur="saveSize(level)"
:disabled="saving"
/>
<span class="size-x">x</span>
<input
v-model.number="level.mapheight"
type="number"
min="1"
class="form-control size-input"
aria-label="Blueprint height in pixels"
@blur="saveSize(level)"
:disabled="saving"
/>
</div>
<small v-if="level.assetcount" class="input-hint">
Taken from the image while a level is empty. Changing it now
re-scales where all {{ level.assetcount }} marker(s) sit relative
to the drawing - set the new size, then Recalibrate from
landmarks in the map editor.
</small>
<small v-else class="input-hint">
Set from the blueprint you upload.
</small>
</td>
<td>
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
</td>
<td>
<img v-if="level.blueprintlight" :src="withBase(level.blueprintlight)"
class="map-thumb" alt="light blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'light', $event)" :disabled="uploading" />
</td>
<td>
<img v-if="level.blueprintdark" :src="withBase(level.blueprintdark)"
class="map-thumb map-thumb-dark" alt="dark blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'dark', $event)" :disabled="uploading" />
</td>
<td>
<input type="radio" :checked="level.isdefault" name="defaultlevel"
@change="saveLevel(level, { isdefault: true })"
title="Where an asset with no level lands, and what the map opens on" />
</td>
<td class="actions">
<button class="btn btn-small btn-danger" @click="remove(level)"
:disabled="saving">Remove</button>
</td>
</tr>
<tr v-if="!building.levels.length">
<td colspan="9" class="empty">No levels yet. Add one, then upload its blueprint.</td>
</tr>
</tbody>
</table>
</div>
<!-- Add building -->
<div v-if="showAddBuilding" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add building</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newBuilding" class="form-control" placeholder="Annex"
@keyup.enter="addBuilding" />
</div>
</div> </div>
<div class="modal-footer">
<div class="setting-row"> <button class="btn btn-secondary" @click="showAddBuilding = false">Cancel</button>
<label> <button class="btn btn-primary" @click="addBuilding" :disabled="!newBuilding.trim()">Add</button>
<span>Blueprint image (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
</div> </div>
</div>
</div>
<div class="setting-row"> <!-- Add level -->
<label> <div v-if="addLevelFor" class="modal-overlay">
<span>Blueprint width (pixels)</span> <div class="modal">
<input <div class="modal-header"><h3>Add level to {{ addLevelFor.buildingname }}</h3></div>
type="number" <div class="modal-body">
v-model="settings.map_width" <div class="form-group">
min="1" <label>Name</label>
placeholder="3300" <input v-model="newLevel.levelname" class="form-control"
@blur="saveSetting('map_width', settings.map_width)" placeholder="Second floor" @keyup.enter="addLevel" />
:disabled="saving" <small class="input-hint">
> Whatever the building calls it. Basement, Ground, Mezzanine, Roof.
<small class="input-hint">Native pixel width of the blueprint image</small> </small>
</label> </div>
<div class="form-group">
<label>Sort order</label>
<input v-model.number="newLevel.sortorder" type="number" class="form-control" />
<small class="input-hint">
Lower sorts first, and gaps are fine - leaving room lets a mezzanine
slot in later without renumbering anything.
</small>
</div>
<p class="input-hint">
Upload the blueprint after creating it. An empty level takes its pixel
size from the image, so there is nothing to measure by hand.
</p>
</div> </div>
<div class="modal-footer">
<div class="setting-row"> <button class="btn btn-secondary" @click="addLevelFor = null">Cancel</button>
<label> <button class="btn btn-primary" @click="addLevel"
<span>Blueprint height (pixels)</span> :disabled="!newLevel.levelname.trim()">Add</button>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
</div> </div>
</div> </div>
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div> <div v-if="notice" class="settings-success">{{ notice }}</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { withBase } from '../../utils/basePath' // Buildings and levels admin (ADR-017). This page replaced four site-wide
import { onMounted } from 'vue' // settings that described a single blueprint, which could not express a second
import { useSystemSettings } from '../../composables/systemSettings' // level drawn at a different size, let alone a second building.
//
// The level id is deliberately on screen. It is what `assets.levelid` points at,
// what a landmark transform takes as an argument, and the first thing worth
// knowing when a marker draws on the wrong drawing.
import { ref, onMounted } from 'vue'
const { import { mapLevelsApi } from '@/api'
settings, saving, mapUploading, error, success, import { withBase } from '@/utils/basePath'
loadSettings, saveSetting, uploadBlueprint, import { reloadMapConfig } from '@/composables/mapConfig'
} = useSystemSettings()
onMounted(loadSettings) const buildings = ref([])
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const error = ref('')
const notice = ref('')
const showAddBuilding = ref(false)
const newBuilding = ref('')
const addLevelFor = ref(null)
const newLevel = ref({ levelname: '', sortorder: 0 })
onMounted(load)
async function load() {
loading.value = true
try {
const { data } = await mapLevelsApi.list()
buildings.value = data.data.buildings || []
} catch (err) {
error.value = message(err, 'Could not load the levels')
} finally {
loading.value = false
}
}
function message(err, fallback) {
return err?.response?.data?.data?.error?.message || fallback
}
function report(text) {
notice.value = text
error.value = ''
// Long enough to read a sentence about what did not happen.
setTimeout(() => { notice.value = '' }, 8000)
}
async function addBuilding() {
const name = newBuilding.value.trim()
if (!name) return
saving.value = true
try {
await mapLevelsApi.createBuilding({
buildingname: name,
sortorder: buildings.value.length,
})
showAddBuilding.value = false
newBuilding.value = ''
await load()
report(`Building "${name}" added. Add its levels next.`)
} catch (err) {
error.value = message(err, 'Could not add the building')
} finally {
saving.value = false
}
}
function startAddLevel(building) {
addLevelFor.value = building
// Default to one past the last, so the common case needs no thought and the
// uncommon one is still editable.
newLevel.value = {
levelname: '',
sortorder: (building.levels.at(-1)?.sortorder ?? -1) + 1,
}
}
async function addLevel() {
const name = newLevel.value.levelname.trim()
if (!name || !addLevelFor.value) return
saving.value = true
try {
const { data } = await mapLevelsApi.create({
buildingid: addLevelFor.value.buildingid,
levelname: name,
sortorder: newLevel.value.sortorder,
})
addLevelFor.value = null
await load()
report(`"${name}" created as level ${data.data.levelid}. Upload its blueprint to set its size.`)
} catch (err) {
error.value = message(err, 'Could not add the level')
} finally {
saving.value = false
}
}
async function renameBuilding(building) {
const name = (building.buildingname || '').trim()
if (!name) {
await load()
return
}
saving.value = true
try {
await mapLevelsApi.updateBuilding(building.buildingid, { buildingname: name })
report(`Building renamed to "${name}".`)
} catch (err) {
error.value = message(err, 'Could not rename the building')
await load()
} finally {
saving.value = false
}
}
async function renameLevel(level) {
const name = (level.levelname || '').trim()
if (!name) {
await load()
return
}
await saveLevel(level, { levelname: name })
}
async function saveLevel(level, payload) {
saving.value = true
try {
const { data } = await mapLevelsApi.update(level.levelid, payload)
// The server reports when a change leaves existing positions in an old
// coordinate space. Surfacing that verbatim matters more than a tidy
// message: it names the markers that are now wrong.
const warnings = data.data?.warnings
await load()
await reloadMapConfig()
report(warnings?.length ? warnings.join(' ') : 'Saved.')
} catch (err) {
error.value = message(err, 'Could not save the level')
await load()
} finally {
saving.value = false
}
}
async function saveSize(level) {
if (!(level.mapwidth > 0) || !(level.mapheight > 0)) {
error.value = 'Width and height must both be positive.'
await load()
return
}
// A level with markers keeps its coordinates when the size changes, so every
// marker moves relative to the drawing. That is sometimes exactly right (a
// re-export of the same plan at a new resolution) and sometimes the start of
// a recalibration, but it is never something to do by accident.
if (level.assetcount) {
const ok = window.confirm(
`${level.levelname} has ${level.assetcount} marker(s) placed against ` +
`${level.mapwidth} x ${level.mapheight}. Changing the size moves all of ` +
`them relative to the drawing. Recalibrate from landmarks afterwards. Continue?`)
if (!ok) {
await load()
return
}
}
await saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })
}
async function upload(level, theme, event) {
const file = event.target.files?.[0]
if (!file) return
uploading.value = true
try {
const { data } = await mapLevelsApi.uploadBlueprint(level.levelid, theme, file)
await load()
await reloadMapConfig()
// sizenote is the interesting case: the image disagrees with the stored
// dimensions AND markers are already placed, so the server refused to
// change the coordinate space out from under them.
report(data.data.sizenote
|| `Blueprint uploaded (${data.data.detectedwidth} x ${data.data.detectedheight}).`)
} catch (err) {
error.value = message(err, 'Could not upload the blueprint')
} finally {
uploading.value = false
event.target.value = ''
}
}
async function remove(level) {
const placed = level.assetcount
? ` It has ${level.assetcount} marker(s) on it, which the server will refuse.`
: ''
if (!confirm(`Remove "${level.levelname}" (level ${level.levelid})?${placed}`)) return
saving.value = true
try {
await mapLevelsApi.remove(level.levelid)
await load()
report(`Level ${level.levelid} removed.`)
} catch (err) {
error.value = message(err, 'Could not remove the level')
} finally {
saving.value = false
}
}
</script> </script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.setting-description {
color: var(--text-light);
max-width: 60rem;
margin-bottom: 1rem;
}
.building-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.building-name {
font-size: 1.05rem;
font-weight: 600;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.25rem 0.4rem;
color: var(--text);
}
.building-name:hover,
.building-name:focus {
border-color: var(--border);
background: var(--bg);
}
.levelid {
font-family: monospace;
font-size: 0.95rem;
padding: 0.1rem 0.4rem;
background: var(--bg);
border-radius: 3px;
}
.mono { font-family: monospace; }
.order-input { width: 4.5rem; }
.size-input { width: 6rem; }
.size-fields { display: flex; align-items: center; gap: 0.35rem; }
.size-x { color: var(--text-light); }
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
.map-thumb {
max-width: 5rem;
max-height: 3rem;
border: 1px solid var(--border);
display: block;
}
.map-thumb-dark { background: #222; }
.muted { color: var(--text-light); }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.75rem; }
</style>

View File

@@ -113,7 +113,7 @@ const HELP = {
usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).', usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).',
setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.', setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.',
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.', employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.', contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.', dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.' site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'

View File

@@ -147,6 +147,21 @@ def run_migrations_online():
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# COMMIT THE RUN. SQLAlchemy 2.0 connections do not autocommit, and on
# MySQL alembic reports "non-transactional DDL" so begin_transaction()
# above is a no-op - nothing here commits on its own.
#
# It looked like it worked because MySQL implicitly commits on DDL: each
# ALTER/CREATE flushed everything queued before it, including the
# PREVIOUS migration's version stamp. The LAST migration of every run
# has no DDL after it, so its stamp was rolled back at close. The column
# changes survived (already committed by their own DDL) while
# alembic_version stayed one revision behind, so `flask db upgrade`
# exited 0 having silently re-run the final migration, and re-ran it
# again on the next deploy. A migration that is not idempotent would
# apply twice.
connection.commit()
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()

View File

@@ -0,0 +1,67 @@
"""Move dashboarddefaults.displayrole onto the kiosk's own role vocabulary
Core named the roles dashboard / lobby / partskiosk. The kiosks name them
Dashboard / Lobby / 3DPrintRoom - the literal values a person types into
C:\\Enrollment\\display-type.txt, which the GE-Enforce dispatcher reads to pick
a target. Two vocabularies meant a display could report a role core could not
store, and core could store 'partskiosk', a value no kiosk would ever match.
The machine's own file wins, so the stored values move to it. 'partskiosk'
becomes '3DPrintRoom'; the other two are a case change only. Anything else is
left alone - an unrecognised value is somebody's data, not ours to guess at.
Idempotent and reversible: matching is case-insensitive, so a re-run is a no-op
rather than a second rewrite, and downgrade puts the old spellings back.
Revision ID: 7d32_displayrole_kiosk_vocabulary
Revises: 7d31_dashboarddefault_fqdn
Create Date: 2026-08-13
"""
from alembic import op
import sqlalchemy as sa
revision = '7d32_displayrole_kiosk_vocabulary'
down_revision = '7d31_dashboarddefault_fqdn'
branch_labels = None
depends_on = None
# old spelling -> new spelling
FORWARD = {
'dashboard': 'Dashboard',
'lobby': 'Lobby',
'partskiosk': '3DPrintRoom',
}
BACKWARD = {new: old for old, new in FORWARD.items()}
def _rewrite(mapping, newdefault):
connection = op.get_bind()
inspector = sa.inspect(connection)
if 'dashboarddefaults' not in inspector.get_table_names():
return
columns = {col['name'] for col in inspector.get_columns('dashboarddefaults')}
if 'displayrole' not in columns:
return
for source, target in mapping.items():
connection.execute(
sa.text('UPDATE dashboarddefaults SET displayrole = :target'
' WHERE LOWER(displayrole) = :source'),
{'target': target, 'source': source.lower()})
# 7d28 created the column with server_default='dashboard'; keep the default
# spelled the same way as the values.
op.alter_column('dashboarddefaults', 'displayrole',
existing_type=sa.String(length=20),
existing_nullable=False,
server_default=newdefault)
def upgrade():
_rewrite(FORWARD, 'Dashboard')
def downgrade():
_rewrite(BACKWARD, 'dashboard')

View File

@@ -0,0 +1,213 @@
"""Buildings and levels: a map is a drawing per level, not one image per site.
See ADR-017. `map_blueprint_light`, `map_blueprint_dark`, `map_width` and
`map_height` described one image for the whole site, and `assets.mapx`/`mapy`
were pixels in it. A second level and a likely second building make that a
table.
This migration is written so nothing renders differently the day it lands: the
four settings become one building and one level, marked default, and every asset
that has a position points at it. The settings rows are left in place here and
retired separately, so a rollback does not lose the blueprint paths.
`levelid` is nullable because an asset with no position needs no level. A
position WITHOUT a level is the case the UI refuses to guess about, and after
this migration no such row exists.
Revision ID: 7d33_buildings_and_levels
Revises: 7d32_displayrole_kiosk_vocabulary
"""
from alembic import op
import sqlalchemy as sa
revision = '7d33_buildings_and_levels'
down_revision = '7d32_displayrole_kiosk_vocabulary'
branch_labels = None
depends_on = None
# What the settings said before this table existed. Read at upgrade time; these
# are only the fallbacks for a site that never set them.
DEFAULT_WIDTH = 3300
DEFAULT_HEIGHT = 2550
PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
def _existing(insp, name):
return name in insp.get_table_names()
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# Guarded like every other table-creating migration in this project: on a
# FRESH database the tables are built from the SQLAlchemy models, which
# already declare them, so an unconditional create fails.
if not _existing(insp, 'buildings'):
op.create_table(
'buildings',
sa.Column('buildingid', sa.Integer, primary_key=True),
sa.Column('buildingname', sa.String(100), nullable=False,
unique=True),
sa.Column('sortorder', sa.Integer, nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
)
if not _existing(insp, 'maplevels'):
op.create_table(
'maplevels',
sa.Column('levelid', sa.Integer, primary_key=True),
sa.Column('buildingid', sa.Integer,
sa.ForeignKey('buildings.buildingid'), nullable=False,
index=True),
sa.Column('levelname', sa.String(100), nullable=False),
sa.Column('sortorder', sa.Integer, nullable=False,
server_default='0'),
sa.Column('blueprintlight', sa.String(255), nullable=True),
sa.Column('blueprintdark', sa.String(255), nullable=True),
sa.Column('mapwidth', sa.Integer, nullable=False,
server_default=str(DEFAULT_WIDTH)),
sa.Column('mapheight', sa.Integer, nullable=False,
server_default=str(DEFAULT_HEIGHT)),
sa.Column('isdefault', sa.Boolean, nullable=False,
server_default=sa.false()),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
sa.UniqueConstraint('buildingid', 'levelname',
name='uq_maplevel_building_name'),
)
# Positions have no history, and a bulk transform rewrites hundreds of them
# at once. A snapshot table is what makes that reversible - without it, the
# honest advice would be "back up the database first", which nobody does
# before a UI action.
if not _existing(insp, 'mappositionsnapshots'):
op.create_table(
'mappositionsnapshots',
sa.Column('snapshotid', sa.Integer, primary_key=True),
sa.Column('levelid', sa.Integer, nullable=True),
sa.Column('reason', sa.String(255), nullable=True),
sa.Column('assetcount', sa.Integer, nullable=False,
server_default='0'),
# The positions themselves, as JSON: assetid, mapx, mapy, levelid,
# mapverifiedat per row. Deliberately not a child table - a snapshot
# is read back whole or not at all, and one row per snapshot keeps
# restore a single statement.
sa.Column('positionsjson', sa.Text, nullable=False),
sa.Column('restoredat', sa.DateTime, nullable=True),
sa.Column('createdby', sa.String(100), nullable=True),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
)
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
if 'levelid' not in assetcolumns:
op.add_column('assets', sa.Column('levelid', sa.Integer, nullable=True))
op.create_index('idx_assets_levelid', 'assets', ['levelid'])
# The FK is added separately from the column so a site whose assets
# table is large is not rewritten twice.
op.create_foreign_key('fk_assets_levelid', 'assets', 'maplevels',
['levelid'], ['levelid'])
if 'mapverifiedat' not in assetcolumns:
op.add_column('assets',
sa.Column('mapverifiedat', sa.DateTime, nullable=True))
# Locations carry map coordinates too - they are the default position for
# assets at that location - so they need a level for exactly the same
# reason. Missed on the first pass and caught by the payload gate.
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
if 'levelid' not in locationcolumns:
op.add_column('locations', sa.Column('levelid', sa.Integer, nullable=True))
op.create_index('idx_locations_levelid', 'locations', ['levelid'])
op.create_foreign_key('fk_locations_levelid', 'locations', 'maplevels',
['levelid'], ['levelid'])
# --- carry the settings forward -------------------------------------
# Only when there is nothing here yet: re-running must not create a second
# default level, and a site that has already set its levels up must not have
# them joined by a stale one built from retired settings.
existinglevels = bind.execute(
sa.text('SELECT COUNT(*) FROM maplevels')).scalar() or 0
if existinglevels:
return
settings = dict(bind.execute(sa.text(
"SELECT `key`, value FROM settings WHERE `key` IN "
"('map_blueprint_light','map_blueprint_dark','map_width','map_height')"
)).fetchall())
def _int(value, fallback):
try:
number = int(str(value).strip())
return number if number > 0 else fallback
except (TypeError, ValueError):
return fallback
bind.execute(sa.text(
'INSERT INTO buildings (buildingname, sortorder, createddate, '
'modifieddate, isactive) VALUES (:name, 0, :now, :now, :active)'),
{'name': 'Main', 'now': sa.func.now(), 'active': True})
buildingid = bind.execute(sa.text(
'SELECT buildingid FROM buildings WHERE buildingname = :name'),
{'name': 'Main'}).scalar()
bind.execute(sa.text(
'INSERT INTO maplevels (buildingid, levelname, sortorder, '
'blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, '
'createddate, modifieddate, isactive) VALUES (:building, :name, 0, '
':light, :dark, :width, :height, :isdefault, :now, :now, :active)'),
{'building': buildingid,
'name': 'Ground floor',
'light': settings.get('map_blueprint_light') or PLACEHOLDER,
'dark': settings.get('map_blueprint_dark') or PLACEHOLDER,
'width': _int(settings.get('map_width'), DEFAULT_WIDTH),
'height': _int(settings.get('map_height'), DEFAULT_HEIGHT),
'isdefault': True, 'now': sa.func.now(), 'active': True})
levelid = bind.execute(sa.text(
'SELECT levelid FROM maplevels WHERE isdefault = :flag'),
{'flag': True}).scalar()
# Every asset that already has a position had it in this one drawing's
# coordinate space, so it belongs to this level. An asset with no position
# is left null: it needs no level until somebody places it.
bind.execute(sa.text(
'UPDATE assets SET levelid = :levelid '
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
{'levelid': levelid})
bind.execute(sa.text(
'UPDATE locations SET levelid = :levelid '
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
{'levelid': levelid})
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
if 'levelid' in assetcolumns:
op.drop_constraint('fk_assets_levelid', 'assets', type_='foreignkey')
op.drop_index('idx_assets_levelid', table_name='assets')
op.drop_column('assets', 'levelid')
if 'mapverifiedat' in assetcolumns:
op.drop_column('assets', 'mapverifiedat')
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
if 'levelid' in locationcolumns:
op.drop_constraint('fk_locations_levelid', 'locations', type_='foreignkey')
op.drop_index('idx_locations_levelid', table_name='locations')
op.drop_column('locations', 'levelid')
if _existing(insp, 'mappositionsnapshots'):
op.drop_table('mappositionsnapshots')
if _existing(insp, 'maplevels'):
op.drop_table('maplevels')
if _existing(insp, 'buildings'):
op.drop_table('buildings')

View File

@@ -25,3 +25,8 @@ export default [
meta: { requiresAuth: true } meta: { requiresAuth: true }
} }
] ]
// No full-screen routes: every view in this plugin renders inside
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
// without Rollup warning about a missing export.
export const toplevel = []

View File

@@ -1,441 +1,452 @@
<template> <template>
<div class="detail-page"> <div class="detail-page">
<div class="page-header"> <div class="page-header">
<h2>Application Details</h2> <h2>Application Details</h2>
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link> <router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link> <router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
</div> </div>
</div> </div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="app"> <template v-else-if="app">
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-card"> <div class="hero-card">
<div class="hero-image" v-if="app.image"> <div class="hero-image" v-if="app.image">
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" /> <img :src="imageSrc" :alt="app.appname" @error="handleImageError" />
</div> </div>
<div class="hero-image placeholder" v-else> <div class="hero-image placeholder" v-else>
<span class="placeholder-icon">&#x1F4E6;</span> <span class="placeholder-icon">&#x1F4E6;</span>
</div> </div>
<div class="hero-content"> <div class="hero-content">
<div class="hero-title"> <div class="hero-title">
<h1>{{ app.appname }}</h1> <h1>{{ app.appname }}</h1>
</div> </div>
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p> <p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
<div class="hero-meta"> <div class="hero-meta">
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span> <span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span> <span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span> <span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span> <span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
</div> </div>
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath"> <div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
<a v-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" class="hero-link"> <a v-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" class="hero-link">
<span class="link-icon">&#x1F517;</span> Launch Application <span class="link-icon">&#x1F517;</span> Launch Application
</a> </a>
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link"> <a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
<span class="link-icon">&#x2B07;</span> Download Files <span class="link-icon">&#x2B07;</span> Download Files
</a> </a>
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link"> <a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link">
<span class="link-icon">&#x1F4C4;</span> Documentation <span class="link-icon">&#x1F4C4;</span> Documentation
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content Grid --> <!-- Main Content Grid -->
<div class="content-grid"> <div class="content-grid">
<!-- Left Column --> <!-- Left Column -->
<div class="content-column"> <div class="content-column">
<!-- Support --> <!-- Support -->
<div class="section-card"> <div class="section-card">
<h3 class="section-title">Support</h3> <h3 class="section-title">Support</h3>
<div class="info-list"> <div class="info-list">
<div class="info-row"> <div class="info-row">
<span class="info-label">Support Team</span> <span class="info-label">Support Team</span>
<span class="info-value"> <span class="info-value">
<a v-if="app.teamurl" :href="app.teamurl" target="_blank"> <a v-if="app.teamurl" :href="app.teamurl" target="_blank">
{{ app.supportteamname || '-' }} {{ app.supportteamname || '-' }}
</a> </a>
<span v-else>{{ app.supportteamname || '-' }}</span> <span v-else>{{ app.supportteamname || '-' }}</span>
</span> </span>
</div> </div>
<div class="info-row" v-if="app.contacts && app.contacts.length"> <div class="info-row" v-if="app.contacts && app.contacts.length">
<span class="info-label">Contacts</span> <span class="info-label">Contacts</span>
<span class="info-value"> <span class="info-value">
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line"> <span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span> {{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
<template v-if="contact.sso && contactEmailDomain"> <template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a> <a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a> <a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template> </template>
</span> </span>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- Application Notes --> <!-- Application Notes -->
<div class="section-card" v-if="app.applicationnotes"> <div class="section-card" v-if="app.applicationnotes">
<h3 class="section-title">Application Notes</h3> <h3 class="section-title">Application Notes</h3>
<!-- Notes are authored as HTML (form says "HTML supported"); <!-- Notes are authored as HTML (form says "HTML supported");
render sanitized so scripts/handlers cannot slip in. --> render sanitized so scripts/handlers cannot slip in. -->
<div class="notes-text" v-html="sanitizedNotes"></div> <div class="notes-text" v-html="sanitizedNotes"></div>
</div> </div>
<!-- Versions --> <!-- Versions -->
<div class="section-card" v-if="versions.length > 0"> <div class="section-card" v-if="versions.length > 0">
<h3 class="section-title">Available Versions</h3> <h3 class="section-title">Available Versions</h3>
<div class="version-list"> <div class="version-list">
<div v-for="ver in versions" :key="ver.appversionid" class="version-item"> <div v-for="ver in versions" :key="ver.appversionid" class="version-item">
<span class="version-number">v{{ ver.version }}</span> <span class="version-number">v{{ ver.version }}</span>
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span> <span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span> <span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Related Knowledge Base --> <!-- Related Knowledge Base -->
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length"> <div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3> <h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
<div class="kb-list"> <div class="kb-list">
<a <a
v-for="kb in app.knowledgebase" v-for="kb in app.knowledgebase"
:key="kb.linkid" :key="kb.linkid"
:href="kb.linkurl" :href="kb.linkurl"
target="_blank" target="_blank"
class="kb-item" class="kb-item"
> >
<span class="kb-title">{{ kb.shortdescription }}</span> <span class="kb-title">{{ kb.shortdescription }}</span>
<span class="kb-keywords" v-if="kb.keywords"> <span class="kb-keywords" v-if="kb.keywords">
<span <span
v-for="(kw, i) in kbKeywords(kb.keywords)" v-for="(kw, i) in kbKeywords(kb.keywords)"
:key="i" :key="i"
class="kb-tag" class="kb-tag"
>{{ kw }}</span> >{{ kw }}</span>
</span> </span>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<!-- Right Column --> <!-- Right Column -->
<div class="content-column"> <div class="content-column">
<!-- Installed On PCs --> <!-- Installed On PCs -->
<div class="section-card"> <div class="section-card">
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3> <h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
<div v-if="installedOn.length > 0" class="pc-list"> <div v-if="installedOn.length > 0" class="pc-list">
<router-link <router-link
v-for="install in installedOn" v-for="install in installedOn"
:key="install.id" :key="install.id"
:to="`/pcs/${install.computerid}`" :to="`/pcs/${install.computerid}`"
class="pc-item" class="pc-item"
> >
<div class="pc-info"> <div class="pc-info">
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span> <span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span> <span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
</div> </div>
<div class="pc-version" v-if="install.version"> <div class="pc-version" v-if="install.version">
v{{ install.version }} v{{ install.version }}
</div> </div>
</router-link> </router-link>
</div> </div>
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>Not installed on any PCs</p> <p>Not installed on any PCs</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Audit Footer --> <!-- Audit Footer -->
<div class="audit-footer"> <div class="audit-footer">
<span>Created {{ formatDate(app.createddate) }}</span> <span>Created {{ formatDate(app.createddate) }}</span>
<span>Modified {{ formatDate(app.modifieddate) }}</span> <span>Modified {{ formatDate(app.modifieddate) }}</span>
</div> </div>
</template> </template>
<div v-else class="card"> <div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Application not found</p> <p style="text-align: center; color: var(--text-light);">Application not found</p>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { applicationsApi } from '@/api' import { applicationsApi } from '@/api'
import { getContactEmailDomain } from '@/utils/siteSettings' import { getContactEmailDomain } from '@/utils/siteSettings'
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml' import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath' import { fileHref } from '@/utils/basePath'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
const app = ref(null) const app = ref(null)
const versions = ref([])
const installedOn = ref([]) // An uploaded image is stored as a served URL; entries from the classic site
const contactEmailDomain = ref('') // are a bare filename that still lives under the frontend's own directory.
// Both have to render, so the shape of the value decides the source.
// Application notes are HTML; sanitize before binding with v-html. const imageSrc = computed(() => {
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes)) const value = app.value?.image
if (!value) return ''
// Build sso@domain for a contact. Assumes contact.sso and domain are set. return value.startsWith('/api/') || value.startsWith('http')
function contactEmail(contact) { ? value
return `${contact.sso}@${contactEmailDomain.value}` : `/images/applications/${value}`
} })
const versions = ref([])
onMounted(async () => { const installedOn = ref([])
try { const contactEmailDomain = ref('')
contactEmailDomain.value = await getContactEmailDomain()
// Load application details // Application notes are HTML; sanitize before binding with v-html.
const response = await applicationsApi.get(route.params.id) const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
app.value = response.data.data
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
// Load versions function contactEmail(contact) {
try { return `${contact.sso}@${contactEmailDomain.value}`
const versionsRes = await applicationsApi.getVersions(route.params.id) }
versions.value = versionsRes.data.data || []
} catch (e) { onMounted(async () => {
} try {
contactEmailDomain.value = await getContactEmailDomain()
// Load installed on which PCs // Load application details
try { const response = await applicationsApi.get(route.params.id)
const installedRes = await applicationsApi.getInstalledOn(route.params.id) app.value = response.data.data
installedOn.value = installedRes.data.data || []
} catch (e) { // Load versions
} try {
} catch (error) { const versionsRes = await applicationsApi.getVersions(route.params.id)
console.error('Error loading application:', error) versions.value = versionsRes.data.data || []
} finally { } catch (e) {
loading.value = false }
}
}) // Load installed on which PCs
try {
function formatDate(dateStr) { const installedRes = await applicationsApi.getInstalledOn(route.params.id)
if (!dateStr) return '-' installedOn.value = installedRes.data.data || []
return new Date(dateStr).toLocaleDateString() } catch (e) {
} }
} catch (error) {
function handleImageError(e) { console.error('Error loading application:', error)
e.target.style.display = 'none' } finally {
} loading.value = false
}
// Keywords are stored space-separated; split into chips for the KB card. })
function kbKeywords(keywords) {
return (keywords || '').split(/\s+/).filter(Boolean) function formatDate(dateStr) {
} if (!dateStr) return '-'
</script> return new Date(dateStr).toLocaleDateString()
}
<style scoped>
/* Application-specific styles - shared styles are in global style.css */ function handleImageError(e) {
e.target.style.display = 'none'
/* Knowledge Base card: each entry is a bordered block, not a run-on line. }
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
small chips below instead of a second wall of text. */ // Keywords are stored space-separated; split into chips for the KB card.
.kb-list { function kbKeywords(keywords) {
display: flex; return (keywords || '').split(/\s+/).filter(Boolean)
flex-direction: column; }
gap: 0.5rem; </script>
}
.kb-item { <style scoped>
display: flex; /* Application-specific styles - shared styles are in global style.css */
flex-direction: column;
gap: 0.4rem; /* Knowledge Base card: each entry is a bordered block, not a run-on line.
padding: 0.6rem 0.75rem; shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
border: 1px solid var(--border); small chips below instead of a second wall of text. */
border-radius: 6px; .kb-list {
background: var(--bg); display: flex;
text-decoration: none; flex-direction: column;
transition: border-color 0.15s, background 0.15s; gap: 0.5rem;
} }
.kb-item:hover { .kb-item {
border-color: var(--primary); display: flex;
background: var(--bg-card); flex-direction: column;
} gap: 0.4rem;
.kb-title { padding: 0.6rem 0.75rem;
color: var(--link); border: 1px solid var(--border);
font-weight: 600; border-radius: 6px;
line-height: 1.35; background: var(--bg);
display: -webkit-box; text-decoration: none;
-webkit-line-clamp: 2; transition: border-color 0.15s, background 0.15s;
-webkit-box-orient: vertical; }
overflow: hidden; .kb-item:hover {
} border-color: var(--primary);
.kb-keywords { background: var(--bg-card);
display: flex; }
flex-wrap: wrap; .kb-title {
gap: 0.3rem; color: var(--link);
} font-weight: 600;
.kb-tag { line-height: 1.35;
font-size: 0.72rem; display: -webkit-box;
color: var(--text-light); -webkit-line-clamp: 2;
background: var(--bg-card); -webkit-box-orient: vertical;
border: 1px solid var(--border); overflow: hidden;
padding: 0.1rem 0.45rem; }
border-radius: 10px; .kb-keywords {
white-space: nowrap; display: flex;
} flex-wrap: wrap;
gap: 0.3rem;
/* Hero description (app-specific) */ }
.hero-description { .kb-tag {
color: var(--text-light); font-size: 0.72rem;
margin: 0; color: var(--text-light);
font-size: 1.125rem; background: var(--bg-card);
line-height: 1.5; border: 1px solid var(--border);
} padding: 0.1rem 0.45rem;
border-radius: 10px;
/* Hero links (app-specific) */ white-space: nowrap;
.hero-links { }
display: flex;
gap: 1rem; /* Hero description (app-specific) */
margin-top: auto; .hero-description {
flex-wrap: wrap; color: var(--text-light);
} margin: 0;
font-size: 1.125rem;
.hero-link { line-height: 1.5;
display: flex; }
align-items: center;
gap: 0.5rem; /* Hero links (app-specific) */
padding: 0.75rem 1.25rem; .hero-links {
background: var(--bg); display: flex;
border-radius: 8px; gap: 1rem;
text-decoration: none; margin-top: auto;
color: var(--text); flex-wrap: wrap;
font-weight: 500; }
font-size: 1.125rem;
transition: background 0.15s; .hero-link {
} display: flex;
align-items: center;
.hero-link:hover { gap: 0.5rem;
background: var(--border); padding: 0.75rem 1.25rem;
} background: var(--bg);
border-radius: 8px;
.link-icon { text-decoration: none;
font-size: 1.25rem; color: var(--text);
} font-weight: 500;
font-size: 1.125rem;
/* Placeholder image */ transition: background 0.15s;
.hero-image.placeholder { }
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
} .hero-link:hover {
background: var(--border);
.placeholder-icon { }
font-size: 5rem;
opacity: 0.5; .link-icon {
} font-size: 1.25rem;
}
/* Version List */
.version-list { /* Placeholder image */
display: flex; .hero-image.placeholder {
flex-direction: column; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
gap: 0.75rem; }
}
.placeholder-icon {
.version-item { font-size: 5rem;
display: flex; opacity: 0.5;
align-items: center; }
gap: 1rem;
padding: 0.75rem 1rem; /* Version List */
background: var(--bg); .version-list {
border-radius: 6px; display: flex;
} flex-direction: column;
gap: 0.75rem;
.version-number { }
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-weight: 600; .version-item {
font-size: 1.125rem; display: flex;
color: var(--link); align-items: center;
} gap: 1rem;
padding: 0.75rem 1rem;
.version-date { background: var(--bg);
font-size: 1rem; border-radius: 6px;
color: var(--text-light); }
}
.version-number {
.version-notes { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 1rem; font-weight: 600;
color: var(--text-light); font-size: 1.125rem;
margin-left: auto; color: var(--link);
} }
/* PC List */ .version-date {
.pc-list { font-size: 1rem;
display: flex; color: var(--text-light);
flex-direction: column; }
gap: 0.75rem;
max-height: 400px; .version-notes {
overflow-y: auto; font-size: 1rem;
} color: var(--text-light);
margin-left: auto;
.pc-item { }
display: flex;
align-items: center; /* PC List */
justify-content: space-between; .pc-list {
padding: 1rem; display: flex;
background: var(--bg); flex-direction: column;
border-radius: 8px; gap: 0.75rem;
text-decoration: none; max-height: 400px;
color: inherit; overflow-y: auto;
transition: background 0.15s; }
}
.pc-item {
.pc-item:hover { display: flex;
background: var(--border); align-items: center;
} justify-content: space-between;
padding: 1rem;
.pc-info { background: var(--bg);
display: flex; border-radius: 8px;
flex-direction: column; text-decoration: none;
} color: inherit;
transition: background 0.15s;
.pc-name { }
font-weight: 500;
font-size: 1.125rem; .pc-item:hover {
color: var(--text); background: var(--border);
} }
.pc-alias { .pc-info {
font-size: 1rem; display: flex;
color: var(--text-light); flex-direction: column;
} }
.pc-version { .pc-name {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-weight: 500;
font-size: 1rem; font-size: 1.125rem;
color: var(--primary); color: var(--text);
background: var(--bg-card); }
padding: 0.375rem 0.75rem;
border-radius: 6px; .pc-alias {
border: 1px solid var(--border); font-size: 1rem;
} color: var(--text-light);
}
/* Empty state */
.empty-state { .pc-version {
text-align: center; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
padding: 2.5rem; font-size: 1rem;
color: var(--text-light); color: var(--primary);
font-size: 1.125rem; background: var(--bg-card);
} padding: 0.375rem 0.75rem;
border-radius: 6px;
/* Support contacts stack one per line */ border: 1px solid var(--border);
.contact-line { }
display: block;
} /* Empty state */
.empty-state {
/* Notes styling - rendered as escaped plain text, preserve author line breaks */ text-align: center;
.notes-text { padding: 2.5rem;
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */ color: var(--text-light);
white-space: normal; font-size: 1.125rem;
word-break: break-word; }
line-height: 1.5;
} /* Support contacts stack one per line */
.notes-text :first-child { margin-top: 0; } .contact-line {
.notes-text :last-child { margin-bottom: 0; } display: block;
.notes-text p { margin: 0 0 0.6rem; } }
.notes-text ul,
.notes-text ol { margin: 0 0 0.6rem 1.4rem; } /* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text a { color: var(--link); } .notes-text {
.notes-text code, /* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; } white-space: normal;
</style> word-break: break-word;
line-height: 1.5;
}
.notes-text :first-child { margin-top: 0; }
.notes-text :last-child { margin-bottom: 0; }
.notes-text p { margin: 0 0 0.6rem; }
.notes-text ul,
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
.notes-text a { color: var(--link); }
.notes-text code,
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
</style>

View File

@@ -111,18 +111,59 @@
class="form-control" class="form-control"
placeholder="Network path or URL to install files" placeholder="Network path or URL to install files"
/> />
<small class="form-hint">
A share path or URL, or upload the installer below and this fills
itself in.
</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="image">Image Filename</label> <label for="packagefile">Installer</label>
<div v-if="uploadedPackage" class="upload-current">
<span class="upload-name">{{ uploadedPackage }}</span>
<button type="button" class="btn btn-sm btn-secondary" @click="removePackage"
:disabled="busyPackage">Remove installer</button>
</div>
<input id="packagefile" type="file" class="form-control"
:accept="PACKAGE_ACCEPT" @change="onPackagePicked" />
<div v-if="packageProgress !== null" class="upload-progress">
<div class="upload-bar"><span :style="{ width: packageProgress + '%' }"></span></div>
<span>{{ packageProgress }}%</span>
</div>
<small class="form-hint">
Up to 500MB. Anything larger belongs on the share - put its path in
Install Path instead. Downloads require a login.
</small>
</div>
<div class="form-group">
<label for="imagefile">Image</label>
<div v-if="imagePreview" class="upload-current">
<img :src="imagePreview" alt="" class="upload-thumb" />
<button type="button" class="btn btn-sm btn-secondary" @click="removeImage"
:disabled="busyImage">Remove image</button>
</div>
<input id="imagefile" type="file" class="form-control"
accept="image/*" @change="onImagePicked" />
<small class="form-hint">
PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now.
</small>
</div>
<div class="form-group">
<label for="image">Image path</label>
<input <input
id="image" id="image"
v-model="form.image" v-model="form.image"
type="text" type="text"
class="form-control" class="form-control"
placeholder="e.g., myapp.png" placeholder="filled in by the upload above"
/> />
<small class="form-hint">Image should be placed in /images/applications/</small> <small class="form-hint">
Set by the upload. Older entries hold a bare filename served from
/images/applications/, and those still work - leave them alone
unless you are replacing the image.
</small>
</div> </div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4> <h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
@@ -184,6 +225,76 @@ const form = ref({
const supportTeams = ref([]) const supportTeams = ref([])
// Files are chosen before the application exists, so they are held here and
// uploaded once there is an id to attach them to (see saveApplication).
const pendingImage = ref(null)
const pendingPackage = ref(null)
const imagePreview = ref('')
const uploadedPackage = ref('')
const packageProgress = ref(null)
const busyImage = ref(false)
const busyPackage = ref(false)
const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf'
const MAX_PACKAGE_BYTES = 500 * 1024 * 1024
function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
pendingImage.value = file
// Show the chosen file immediately rather than after a round trip.
imagePreview.value = URL.createObjectURL(file)
}
function onPackagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
if (file.size > MAX_PACKAGE_BYTES) {
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 500MB. `
+ 'Put it on the share and use Install Path instead.'
event.target.value = ''
return
}
error.value = ''
pendingPackage.value = file
uploadedPackage.value = file.name
}
async function removeImage() {
pendingImage.value = null
imagePreview.value = ''
form.value.image = ''
if (isEdit.value) {
busyImage.value = true
try { await applicationsApi.removeImage(route.params.id) }
catch (err) { error.value = apiError(err, 'Failed to remove the image') }
finally { busyImage.value = false }
}
}
async function removePackage() {
pendingPackage.value = null
uploadedPackage.value = ''
if (isEdit.value) {
busyPackage.value = true
try {
const response = await applicationsApi.removePackage(route.params.id)
form.value.installpath = response.data.data.installpath || ''
} catch (err) {
error.value = apiError(err, 'Failed to remove the installer')
} finally { busyPackage.value = false }
}
}
// The image field holds either a served URL (new) or a bare filename from the
// classic site, which is still rendered out of /images/applications/.
function imageSrcFor(value) {
if (!value) return ''
return value.startsWith('/api/') || value.startsWith('http')
? value
: `/images/applications/${value}`
}
onMounted(async () => { onMounted(async () => {
try { try {
// Load support teams // Load support teams
@@ -210,6 +321,10 @@ onMounted(async () => {
image: app.image || '', image: app.image || '',
applicationnotes: app.applicationnotes || '' applicationnotes: app.applicationnotes || ''
} }
imagePreview.value = imageSrcFor(app.image)
if ((app.installpath || '').startsWith('/api/applications/package/')) {
uploadedPackage.value = app.installpath.split('/').pop()
}
} }
} catch (err) { } catch (err) {
console.error('Error loading data:', err) console.error('Error loading data:', err)
@@ -240,16 +355,34 @@ async function saveApplication() {
applicationnotes: form.value.applicationnotes || null applicationnotes: form.value.applicationnotes || null
} }
let appid = route.params.id
if (isEdit.value) { if (isEdit.value) {
await applicationsApi.update(route.params.id, appData) await applicationsApi.update(appid, appData)
} else { } else {
await applicationsApi.create(appData) const created = await applicationsApi.create(appData)
appid = created.data.data.appid
}
// Uploads come after the save: a new application has no id until it exists,
// and both endpoints key on it. A failed upload must not lose the record
// that saved fine, so it reports and stays on the form.
if (pendingImage.value) {
await applicationsApi.uploadImage(appid, pendingImage.value)
pendingImage.value = null
}
if (pendingPackage.value) {
packageProgress.value = 0
await applicationsApi.uploadPackage(appid, pendingPackage.value,
percent => { packageProgress.value = percent })
pendingPackage.value = null
packageProgress.value = null
} }
router.push('/applications') router.push('/applications')
} catch (err) { } catch (err) {
console.error('Error saving application:', err) console.error('Error saving application:', err)
error.value = apiError(err, 'Failed to save application') error.value = apiError(err, 'Failed to save application')
packageProgress.value = null
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -257,6 +390,46 @@ async function saveApplication() {
</script> </script>
<style scoped> <style scoped>
.upload-current {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.upload-thumb {
width: 3rem;
height: 3rem;
object-fit: contain;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg);
}
.upload-name {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
word-break: break-all;
}
.upload-progress {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.upload-bar {
flex: 1;
height: 0.5rem;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.upload-bar span {
display: block;
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
}
.checkbox-group { .checkbox-group {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;

View File

@@ -40,7 +40,7 @@ def _sitezone():
Same lookup the notifications plugin uses. A stored timestamp is UTC, so Same lookup the notifications plugin uses. A stored timestamp is UTC, so
anything rendered server-side has to be converted or it shows the wrong anything rendered server-side has to be converted or it shows the wrong
wall clock for the site - four hours out at West Jefferson. wall clock for the site - four hours out at the reference site.
""" """
from shopdb.api import Setting from shopdb.api import Setting
row = Setting.query.filter_by(key='site_timezone').first() row = Setting.query.filter_by(key='site_timezone').first()
@@ -344,3 +344,19 @@ def _diffprojections(old, new):
'aftertype': None if after is None else after[0], 'aftertype': None if after is None else after[0],
}) })
return changes return changes
@backups_bp.route('/dashboard/stale', methods=['GET'])
@jwt_required()
@require_permission('backups.view')
def dashboard_stale():
"""Chains whose backup has stopped running.
Thin: the rule lives in services/staleness.py, where it is testable without
an auth layer in the way. Keyed on the last CONFIRMED check, never on the
last change - dedup means an unchanged config writes no revision, so a card
keyed on revision age would flag most of a healthy fleet.
"""
from ..services.staleness import stalechains
return success_response(stalechains())

View File

@@ -19,3 +19,8 @@ export default [
meta: { requiresAuth: true, plugin: 'backups' } meta: { requiresAuth: true, plugin: 'backups' }
} }
] ]
// No full-screen routes: every view in this plugin renders inside
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
// without Rollup warning about a missing export.
export const toplevel = []

Some files were not shown because too many files have changed in this diff Show More