Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b3904f71 | ||
|
|
8cedf674fb | ||
|
|
0dc0ac13c8 | ||
|
|
03d0754fdc | ||
|
|
2083029ff2 | ||
|
|
96f127f8c8 | ||
|
|
f34b9ca710 | ||
|
|
8bde89c47e | ||
|
|
afd3dce493 | ||
|
|
89248407e7 | ||
|
|
aa6db94179 | ||
|
|
dd503be4ba | ||
|
|
5f5ed7b1d4 | ||
|
|
62f4a42210 | ||
|
|
9c1c6c5729 | ||
|
|
741dda5be7 | ||
|
|
24647aa8e8 | ||
|
|
aede5ac621 | ||
|
|
3324dbd91e | ||
|
|
7d9a54ca0f | ||
|
|
a895bde6fe | ||
|
|
e7b8933588 | ||
|
|
1d8c983d04 | ||
|
|
96df19702e | ||
|
|
928a50c16e | ||
|
|
62c5454f00 | ||
|
|
ad335cfc9e | ||
|
|
36b0265668 | ||
|
|
05be4c4489 | ||
|
|
d1ba3a1a02 | ||
|
|
4995456136 | ||
|
|
c829d96128 | ||
|
|
ce6d44e69f | ||
|
|
c648bdf560 | ||
|
|
b37c08eb5b | ||
|
|
8f9805d964 | ||
|
|
035419fa51 | ||
|
|
4d6ab741cc | ||
|
|
838932a72d | ||
|
|
2df5028883 | ||
|
|
c7dffce81e | ||
|
|
d830dd49a9 | ||
|
|
38deefe619 | ||
|
|
0c574e0f49 | ||
|
|
ab301df9ac | ||
|
|
1078ac03df | ||
|
|
dfbc12a172 | ||
|
|
4bded210e4 | ||
|
|
1d7191c2d3 | ||
|
|
6dc363411d | ||
|
|
3d83806135 | ||
|
|
e67fe47fe2 | ||
|
|
6cdbea449a | ||
|
|
7d66551622 | ||
|
|
1e884dc02a | ||
|
|
962979d483 | ||
|
|
20a95013ad | ||
|
|
9336577abe | ||
|
|
5de3594425 | ||
|
|
84bf5d04ed | ||
|
|
d572c913e5 | ||
|
|
f1f573862d | ||
|
|
52eb10f5ca | ||
|
|
598c2c98bc | ||
|
|
523e3e4ecc | ||
|
|
9e34fafce5 | ||
|
|
787f475208 | ||
|
|
457349d258 | ||
|
|
a64796f060 | ||
|
|
ea35a134fe | ||
|
|
d109314123 | ||
|
|
2fce81f33f | ||
|
|
c28b02e45b | ||
|
|
2693eb28d6 | ||
|
|
27f76ee964 | ||
|
|
f66763e6df | ||
|
|
4d807ccb4b | ||
|
|
94d8d6c9b6 | ||
|
|
221bbb226e | ||
|
|
42c050a9f4 | ||
|
|
8623db3ee2 | ||
|
|
c34815b87e | ||
|
|
294ddbb38e | ||
|
|
e0e4cce8bd | ||
|
|
5eb84873e8 | ||
|
|
7151b68bdd | ||
|
|
6c975a107c | ||
|
|
1ca8a9b8e8 | ||
|
|
05c150c663 | ||
|
|
8b50e6fe2a | ||
|
|
02d7619b1a |
476
CHANGELOG.md
476
CHANGELOG.md
@@ -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
234
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"printedparts",
|
"printedparts",
|
||||||
"printers",
|
"printers",
|
||||||
"slides",
|
"slides",
|
||||||
|
"tools",
|
||||||
"usb",
|
"usb",
|
||||||
"warranty"
|
"warranty"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
464
docs/ADOPTING-AT-ANOTHER-SITE.md
Normal file
464
docs/ADOPTING-AT-ANOTHER-SITE.md
Normal 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".
|
||||||
@@ -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). |
|
||||||
|
|||||||
@@ -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
125
docs/BACKUP-KINDS.md
Normal 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
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
135
docs/EVENTSAVER.md
Normal 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
118
docs/FLEET-ARCHITECTURE.md
Normal 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
135
docs/FLOOR-MAP.md
Normal 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).
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
90
docs/PROJECT-MAP.md
Normal 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`)
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
67
docs/START-HERE.md
Normal 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).
|
||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
143
docs/adr/ADR-017-buildings-and-levels.md
Normal file
143
docs/adr/ADR-017-buildings-and-levels.md
Normal 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
@@ -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 => {
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
14293
docs/openapi.json
14293
docs/openapi.json
File diff suppressed because it is too large
Load Diff
171
docs/proposals/dashboard-live-fleet.md
Normal file
171
docs/proposals/dashboard-live-fleet.md
Normal 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.
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
331
docs/proposals/printer-assignment.md
Normal file
331
docs/proposals/printer-assignment.md
Normal 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.
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
271
frontend/src/components/DashboardCards.vue
Normal file
271
frontend/src/components/DashboardCards.vue
Normal 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>
|
||||||
@@ -7,28 +7,35 @@ 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.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
let map = null
|
let map = null
|
||||||
let marker = null
|
let marker = null
|
||||||
|
|
||||||
// Map dimensions - facility blueprint size, loaded from settings.
|
// This LEVEL's native size, which is what its marker coordinates mean.
|
||||||
let MAP_WIDTH = mapConfig.width
|
let MAP_WIDTH = 0
|
||||||
let MAP_HEIGHT = mapConfig.height
|
let MAP_HEIGHT = 0
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!mapContainer.value || props.left === null || props.top === null) return
|
if (!mapContainer.value || props.left === null || props.top === null) return
|
||||||
|
// No level, no drawing. Rendering the default blueprint under these
|
||||||
|
// coordinates would look right and be wrong; an empty box is honest.
|
||||||
|
if (!hasLevel(props.levelid)) return
|
||||||
|
|
||||||
MAP_WIDTH = mapConfig.width
|
const dimensions = dimensionsFor(props.levelid)
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_WIDTH = dimensions.width
|
||||||
|
MAP_HEIGHT = dimensions.height
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
map = L.map(mapContainer.value, {
|
map = L.map(mapContainer.value, {
|
||||||
@@ -39,7 +46,7 @@ function initMap() {
|
|||||||
zoomControl: true
|
zoomControl: true
|
||||||
})
|
})
|
||||||
|
|
||||||
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
|
||||||
|
|
||||||
// Convert database coordinates to Leaflet (y is inverted)
|
// Convert database coordinates to Leaflet (y is inverted)
|
||||||
const leafletY = MAP_HEIGHT - props.top
|
const leafletY = MAP_HEIGHT - props.top
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
@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">
|
||||||
|
<strong>Level unknown</strong>
|
||||||
|
<span>
|
||||||
|
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
|
||||||
|
asset, or place it in the map editor.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="map-preview" ref="mapPreview">
|
||||||
<div
|
<div
|
||||||
class="map-transform"
|
class="map-transform"
|
||||||
:style="transformStyle"
|
:style="transformStyle"
|
||||||
@@ -43,7 +51,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
|
||||||
|
|
||||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||||
// when it loads.
|
// when it loads.
|
||||||
@@ -52,7 +60,11 @@ loadMapConfig()
|
|||||||
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 },
|
||||||
machineName: { type: String, default: '' }
|
machineName: { type: String, default: '' },
|
||||||
|
// Which drawing left/top are pixels of (ADR-017). A position without one
|
||||||
|
// cannot be rendered: the same coordinates land somewhere different on every
|
||||||
|
// level, so this shows what is missing instead of guessing the default.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -67,18 +79,29 @@ const hasPosition = computed(() => {
|
|||||||
return props.left !== null && props.top !== null
|
return props.left !== null && props.top !== null
|
||||||
})
|
})
|
||||||
|
|
||||||
const blueprintUrl = computed(() => {
|
// A position we cannot place: coordinates but no level, or a level this instance
|
||||||
// Reading currentTheme keeps this reactive to theme changes.
|
// does not know. Rendering the default blueprint here would look correct and be
|
||||||
return blueprintUrlFor(currentTheme.value)
|
// wrong, so the tooltip says so instead.
|
||||||
|
const levelUnknown = computed(() => {
|
||||||
|
return hasPosition.value && !hasLevel(props.levelid)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate marker position as percentage of the facility blueprint size
|
const levelLabel = computed(() => levelName(props.levelid))
|
||||||
|
|
||||||
|
const blueprintUrl = computed(() => {
|
||||||
|
// Reading currentTheme keeps this reactive to theme changes.
|
||||||
|
return blueprintUrlFor(currentTheme.value, props.levelid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
|
||||||
|
// the wrong level's dimensions is precisely how a marker ends up plausibly
|
||||||
|
// placed and wrong.
|
||||||
const markerX = computed(() => {
|
const markerX = computed(() => {
|
||||||
return (props.left / mapConfig.width) * 100
|
return (props.left / dimensionsFor(props.levelid).width) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const markerY = computed(() => {
|
const markerY = computed(() => {
|
||||||
return (props.top / mapConfig.height) * 100
|
return (props.top / dimensionsFor(props.levelid).height) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
// Marker style with counter-scale to maintain constant size
|
// Marker style with counter-scale to maintain constant size
|
||||||
@@ -197,6 +220,20 @@ watch(currentTheme, () => {
|
|||||||
.location-tooltip-wrapper:hover {
|
.location-tooltip-wrapper:hover {
|
||||||
color: var(--primary, #1976d2);
|
color: var(--primary, #1976d2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-level-unknown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-level-unknown strong {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -216,8 +253,10 @@ watch(currentTheme, () => {
|
|||||||
|
|
||||||
.map-preview {
|
.map-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 500px;
|
/* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
|
||||||
height: 385px;
|
covered the row it was launched from. Aspect ratio kept. */
|
||||||
|
width: 390px;
|
||||||
|
height: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg, #f5f5f5);
|
background: var(--bg, #f5f5f5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
204
frontend/src/components/dashboardCards.js
Normal file
204
frontend/src/components/dashboardCards.js
Normal 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))
|
||||||
|
}
|
||||||
228
frontend/src/components/dashboardCards.spec.js
Normal file
228
frontend/src/components/dashboardCards.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
frontend/src/composables/mapConfig.spec.js
Normal file
144
frontend/src/composables/mapConfig.spec.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
202
frontend/src/utils/codes.js
Normal 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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
93
frontend/src/utils/codes.spec.js
Normal file
93
frontend/src/utils/codes.spec.js
Normal 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('"')
|
||||||
|
})
|
||||||
|
})
|
||||||
48
frontend/src/utils/fiscalWeek.js
Normal file
48
frontend/src/utils/fiscalWeek.js
Normal 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()
|
||||||
|
}
|
||||||
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
49
frontend/src/utils/fiscalWeek.spec.js
Normal 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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ const typeLabels = {
|
|||||||
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 = {
|
const filterTypeMap = {
|
||||||
@@ -132,7 +134,9 @@ const filterTypeMap = {
|
|||||||
applications: ['application'],
|
applications: ['application'],
|
||||||
knowledgebase: ['knowledgebase'],
|
knowledgebase: ['knowledgebase'],
|
||||||
notifications: ['notification'],
|
notifications: ['notification'],
|
||||||
employees: ['employee']
|
employees: ['employee'],
|
||||||
|
usb: ['usb_device'],
|
||||||
|
printedparts: ['printed_item']
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterList = [
|
const filterList = [
|
||||||
@@ -145,7 +149,9 @@ const filterList = [
|
|||||||
{ key: 'applications', label: 'Apps' },
|
{ key: 'applications', label: 'Apps' },
|
||||||
{ key: 'knowledgebase', label: 'KB' },
|
{ key: 'knowledgebase', label: 'KB' },
|
||||||
{ key: 'notifications', label: 'Notices' },
|
{ key: 'notifications', label: 'Notices' },
|
||||||
{ key: 'employees', label: 'Employees' }
|
{ key: 'employees', label: 'Employees' },
|
||||||
|
{ key: 'usb', label: 'USB' },
|
||||||
|
{ key: 'printedparts', label: 'Printed Parts' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
@@ -207,9 +213,18 @@ async function search(q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performSearch() {
|
function performSearch() {
|
||||||
if (searchInput.value.trim()) {
|
const term = searchInput.value.trim()
|
||||||
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
if (!term) return
|
||||||
|
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
|
||||||
|
// router aborts it, so the route watcher never fires and the button does
|
||||||
|
// nothing at all. Re-run the query directly in that case. The sidebar box
|
||||||
|
// never hits this, because it is always navigating from somewhere else -
|
||||||
|
// which is why the two search boxes appeared to behave differently.
|
||||||
|
if (term === route.query.q) {
|
||||||
|
search(term)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
router.push({ path: '/search', query: { q: term } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openKBArticle(result) {
|
async function openKBArticle(result) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
67
migrations/versions/7d32_displayrole_kiosk_vocabulary.py
Normal file
67
migrations/versions/7d32_displayrole_kiosk_vocabulary.py
Normal 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')
|
||||||
213
migrations/versions/7d33_buildings_and_levels.py
Normal file
213
migrations/versions/7d33_buildings_and_levels.py
Normal 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')
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- 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">📦</span>
|
<span class="placeholder-icon">📦</span>
|
||||||
@@ -173,6 +173,17 @@ const route = useRoute()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const app = ref(null)
|
const app = ref(null)
|
||||||
|
|
||||||
|
// An uploaded image is stored as a served URL; entries from the classic site
|
||||||
|
// 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.
|
||||||
|
const imageSrc = computed(() => {
|
||||||
|
const value = app.value?.image
|
||||||
|
if (!value) return ''
|
||||||
|
return value.startsWith('/api/') || value.startsWith('http')
|
||||||
|
? value
|
||||||
|
: `/images/applications/${value}`
|
||||||
|
})
|
||||||
const versions = ref([])
|
const versions = ref([])
|
||||||
const installedOn = ref([])
|
const installedOn = ref([])
|
||||||
const contactEmailDomain = ref('')
|
const contactEmailDomain = ref('')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user