96 Commits

Author SHA1 Message Date
cproudlock
427eb0de8c printedparts stage 12: admin settings page + settings-rail card
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
PrintedPartsSettings edits the four plugin settings (code prefix,
default threshold, kiosk badge policy, alert recipients) through the
core settings API; the route rides the plugin's router file and the
settings shell nests it into the rail; get_settings_cards contributes
the catalog card while the plugin is enabled.
2026-07-17 08:25:33 -04:00
cproudlock
df918ed38f printedparts stage 11: low-stock email alerts on threshold crossing
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.12.0: send_email/send_alert join the plugin surface (the
mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest
pins the new floor. The alert fires inside _ledger_write only when a
decrement CROSSES the item's threshold - one alert per depletion,
rearmed by restocking above - and is best-effort after the commit so
mail trouble can never fail a take. Recipients come from
printedparts_alert_email, falling back to the site alert_recipients.
on_enable re-seeds settings idempotently so existing installs pick up
new keys. Crossing/rearm semantics proven by test.
2026-07-17 08:15:50 -04:00
cproudlock
fc0d48a6a7 printedparts stage 10: closeout - lab guide rewritten from the real build
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The lab is now a build-along mirroring what actually happened: ten
stages, each with the goal, the divergences, a see-it-work check, and
the errors genuinely hit while building (empty Migration error from a
broken model import, the migration-guard KeyError, the missing Lucide
icon, nested-app-context test writes, Decimal sums, and the authz
sweep catching the deliberately open kiosk take). That last one gets
its explicit EXEMPT_ENDPOINTS entry with a pointer to the decision
record - the net stays, the exception is reviewable. Full suite: 993
backend tests, 49 vitest, frontend build, naming hook, all green.
2026-07-17 08:11:36 -04:00
cproudlock
b68e927ef6 printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
2026-07-17 08:04:09 -04:00
cproudlock
6439d1ccd9 printedparts stage 8: 1x0.5in bin labels
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
New public print view at /print/printedparts-labels following the
plugin-owned USB label precedent: multi-select with per-item copies,
CODE128 of the item code via JsBarcode (a QR at this size is at the
edge of scanner tolerance), one label per page on 1in x 0.5in roll
stock via a new @page size. The Detail page's Bin Label button
preselects its item through ?item=<id>; the list header gains a batch
Print Labels button.
2026-07-17 08:00:35 -04:00
cproudlock
6ed3da1b64 printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take
Some checks failed
CI / backend (push) Failing after 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Two open endpoints: an item lookup by scanned code and the take POST -
the product's first unauthenticated write, held to the decision
record's bar (decrement-only, badge-attributed server-side, bounded,
physically rate-limited; justification in the plugin README). The
/parts-kiosk route is a full-screen no-auth view beside /shopfloor: a
hidden always-focused input consumes keyboard-wedge scans for
whichever step is active, TouchKeypad (net-new 3x4 grid) takes the
quantity, and a success screen resets after a few seconds. Manual
type-in fallbacks cover damaged labels. Kiosk test proves open access,
the over-take guard, the badge policy, and cache==ledger afterward.
2026-07-17 07:49:13 -04:00
cproudlock
d6a78a72ff printedparts stage 6: RBAC - declared permissions gate every mutation
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
get_permissions declares view/create/edit/delete/restock (seeded on
install/enable and by flask seed permissions); every write route adds
require_permission on top of jwt_required. New test proves
authentication alone is not authorization: a role-less member gets
403 where an admin succeeds.
2026-07-17 07:42:07 -04:00
cproudlock
6dfc8906c4 printedparts stage 5: the ledger - restock/adjust with badge attribution
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Badge resolver copied from the USB contract (SSO digits, 0<digits>BZ
PayNo wrap) with names from the employees directory and the
unknown-badge policy setting; deliberately copied rather than
cross-imported so the contract test stays green. Restock and adjust
write the ledger row and move the cached quantity in one commit -
the single-commit invariant every write path must use. Adjust
requires a reason and refuses to drive stock below zero. Detail page
gains Restock/Adjust modals. Seven tests cover minting, the
cache==ledger invariant, badge shapes, policy toggle, and auth.
2026-07-17 07:41:18 -04:00
cproudlock
cb367a38f9 printedparts stage 4: catalog mutations, item photos, detail + form
Some checks failed
CI / backend (push) Failing after 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
POST/PUT/DELETE for items: create mints the itemcode from the
configured prefix plus the flushed row id, update refuses
quantityonhand (ledger-managed - restock/adjust arrive next stage),
delete soft-retires. The image upload/serve/delete trio replicates the
models.py pattern into instance/printedpartsimages/ with a public GET.
PrintedItemDetail follows the unified detail skeleton (hero photo,
info list, transaction history table); PrintedItemForm covers
create/edit plus photo management on edit.
2026-07-17 07:36:54 -04:00
cproudlock
d1c844d533 printedparts stage 3: read API + list page (first visible win)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
GET /items (paginated, search across code/name/description/bin,
lowstock filter) and GET /items/<id> with recent transactions, both
open reads. printedpartsApi client, router file repointed at the
renamed views, PrintedItemsList with image thumbs and a red/green
quantity badge against the per-item threshold. Nav entry '3D Parts'
with a new 'box' Lucide icon mapping (the sidebar renders nothing for
unknown icon names - lab gotcha).
2026-07-16 17:10:42 -04:00
cproudlock
f5cfac33b4 printedparts stage 2: models, real 0001 baseline, tables live
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
PrintedItem (catalog: code, name, image, cached quantityonhand,
per-item threshold, bin) and PrintedItemTransaction (the ledger:
signed quantity change attributed to a badge-resolved employee).
Both registered in PLUGIN_TABLE_OWNERS; 0001 is a post-cutover real
baseline. The migration-guard test learns the new expected head.
Routes are a placeholder ping until the next stage - the scaffold's
list route imported the deleted scaffold model, which surfaces as an
empty 'Migration error' because the alembic env imports the models
package.
2026-07-16 16:57:21 -04:00
cproudlock
8dd1fadeca printedparts stage 1: scaffold, no AssetType, manifest per spec
Some checks failed
CI / backend (push) Failing after 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 8s
flask plugin new output, minus the scaffold's AssetType seeding:
printed parts are quantity-based consumables, not ADR-001 assets.
on_install seeds the three plugin settings instead. Manifest pins
core >=0.11.0, depends on employees (badge name resolution), ships
disabled until a site opts in.
2026-07-16 16:44:54 -04:00
cproudlock
a0b92f7b5e printedparts lab: set expectations up front
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
State that this is a bundled plugin whose frontend and three small core
edits land in this repo, list the three deliberate divergences from the
scaffold before the learner hits them, suggest a per-milestone solution
branch for instructors, and point out the earliest visible win (wire
the bare list page as soon as the GET endpoint works).
2026-07-16 16:37:28 -04:00
cproudlock
6362cef699 printedparts docs: record the open-write kiosk decision; defer the dashboard widget
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The kiosk take endpoint is the product's first unauthenticated
mutation; spell out the acceptance criteria (decrement-only, badge
attributed, bounded, physically rate-limited) so future open-write
endpoints meet the same bar. The dashboard-widget milestone is marked
optional: get_dashboard_widgets predates the ADR-010 data-only
renderers and needs a core component to render.
2026-07-16 16:35:17 -04:00
cproudlock
d99de002bf printedparts plugin: design proposal + hands-on development lab
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Design for a 3D-printed-parts storefront: item catalog with images and
quantity on hand, a transaction ledger attributing every take/restock/
adjust to a badge-scanned employee, an unauthenticated touch kiosk
(scan bin barcode, scan badge, keypad quantity), 1x0.5in CODE128 bin
labels, and stock/consumption/by-person reports.

The lab guide walks a developer through building it in seven
checkpointed milestones, reusing the USB badge contract, the
measuringtools migration baseline, the models-image upload trio, and
the open kiosk-endpoint precedents.
2026-07-16 16:33:19 -04:00
cproudlock
eed947b207 Forward real client IPs through waitress trusted-proxy flags
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The X-Forwarded-For rewrite rule alone is not enough: waitress 2+
strips forwarded headers from untrusted proxies by default, so the app
still saw 127.0.0.1 with the rule active. Trust the loopback proxy and
consume x-forwarded-for on the waitress command line; waitress then
rewrites remote_addr to the real client. Runbook gains the
allowedServerVariables unlock (500.52) and both troubleshooting rows.
2026-07-16 15:27:45 -04:00
cproudlock
f5f67172aa Scope dark-mode form and notification styles to the explicit theme
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The form-control and notification-tint blocks applied on the OS
prefers-color-scheme alone, so a machine in OS dark mode leaked dark
widget styling into the app's explicit light theme - dropdown options
rendered near-black on black. The theme store always stamps data-theme
at startup, so scope these rules to [data-theme=dark]. Dropdown options
in dark mode use the solid card background instead of the text color.
2026-07-16 15:11:35 -04:00
cproudlock
97f7dfb0de Export script: emit bundle into the pxe-images github transfer folder
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
2026-07-16 14:14:44 -04:00
cproudlock
3ba09ac9f3 Fix bit(1) import coercion and subpath login redirect; add GitHub export script
Some checks failed
CI / backend (push) Successful in 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Loader: bool() on pymysql bit(1) bytes is always true - isinstallable
and isshopfloor imported as 1 for every row; route through _truthy_bit.
The employee source DB is now optional (shopdb-only imports).

Frontend: under a subpath mount the 401 interceptor stored the browser
path (mount base included) as the login redirect and the router applied
its base again (/ops/ops). New stripBase() keeps redirects base-free.

tools/export-github.sh automates the publication flow: prune + scrub +
commit into ~/projects/shopdb-flask-pub and emit a transfer bundle.
2026-07-16 14:08:43 -04:00
cproudlock
f16d2289ff Remove stray import_apps.py (superseded by the import API + wjf loader)
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
2026-07-13 19:53:51 -04:00
cproudlock
99cac87d9a README: refresh to current product surface; retire direct-DB migration guide
Some checks failed
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Overview and plugin list cover all twelve plugins (geenforce, network
subnets, measuring tools, warranty, USB); naming examples use living
tables/columns instead of retired pctypes/isvnc; API params match the
implementation (perpage, dir, assettype); import section points at the
IMPORT-API surface and the wjf reference loader. DATA_MIGRATION_GUIDE
is now a pointer stub (its direct-DB approach is superseded).
2026-07-13 19:49:34 -04:00
cproudlock
7d7862f4b5 Neutral wording in import docs; portable tool output paths
Some checks failed
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Import-surface docs and docstrings describe the automation as a
migration script; status-doc references in CHANGELOG/ADR-009/ROADMAP
point at repo files. Screenshot/verify tools write to /tmp/shopdb-shots
(created on import) instead of a machine-specific directory.
2026-07-13 16:23:24 -04:00
cproudlock
6010f01de1 Support subpath IIS deployment as a second install method
Some checks failed
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The app can run as an IIS Application under an existing site
(e.g. https://host/ops/) instead of its own site + port:

- frontend: vite base via VITE_BASE_PATH; router history, axios
  baseURL, and root-absolute asset/route paths resolve through
  utils/basePath.js withBase()
- backend: MOUNT_PATH (env or .env) wraps the app in a WSGI
  middleware that shifts the prefix into SCRIPT_NAME, so one knob
  serves API + SPA under the mount
- docs: INSTALL-WINDOWS-IIS.md section 7b runbook + troubleshooting
  rows; DEPLOY-WINDOWS-IIS.md pointer; commented examples in
  deploy/windows/web.config and .env.example

Root deployment unchanged (MOUNT_PATH unset, base '/'). Also folds
two stray root-absolute callers into the shared plumbing
(MachineForm relationship-types fetch, reports CSV window.open).
2026-07-13 16:11:12 -04:00
cproudlock
69dd6d0abe Warranty form: make Vendor a strict dropdown of the vendor catalog
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Replace the free-text/datalist vendor field with a plain select populated from
/api/vendors, so a warranty vendor is always one of the site's known vendors. An
existing warranty's vendor is kept selectable even if it is absent from the
catalog, so editing never blanks it.
2026-07-13 15:02:59 -04:00
cproudlock
fba2fa05b4 Warranty form: vendor combobox + clearer "Lookup source" label
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Has been cancelled
- Vendor is now a datalist combobox seeded from the site vendor catalog
  (/api/vendors), so users pick a known vendor instead of retyping it, while
  still allowing free text. Kept as a string, not an FK, to keep the warranty
  plugin decoupled from core.
- Renamed the "Provider" select to "Lookup source" with helper text. It was
  confusingly synonymous with Vendor; it actually means where coverage data
  comes from (manual entry vs a maker's warranty API that supports Refresh).
2026-07-13 15:00:53 -04:00
cproudlock
9e2544a687 wjf loader: import active machines only (isactive=1)
Classic ASP keeps retired machines in the machines table as history (isactive=0);
every other stage already filters isactive=1, but the assets hub, metrology,
locations and the verify count read machines unfiltered, so ~240 retired units
(incl. all G-prefix hostnames and 61 dead metrology PCs that each synthesized a
phantom measuring tool) landed as live assets. Add the isactive=1 filter to
those four queries. Downstream stages resolve via the id crosswalk, so warranties
/comms/relationships/installs for retired machines now drop automatically.
2026-07-13 15:00:45 -04:00
cproudlock
760b00f4d1 Warranties: fix N+1 slowness, filter alignment, and Covers hover
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
- Perf: list_warranties did a db.session.get(Asset) per link per warranty
  (~1.8s for the full list). Eager-load the links and batch-fetch every linked
  asset in one query -> ~0.19s.
- Filters: the "Status" label wrapped its select onto a second line, so the
  dropdown sat above the search box; keep the label inline so they align.
- Covers: each asset chip now shows the asset name (often the hostname/alias) on
  hover, keeping the machine number as the label.
2026-07-13 14:49:36 -04:00
cproudlock
4a74a1a405 WJ import loader: mark setup_complete (a full import is the setup)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
A freshly-imported DB left setup_complete unset, so the admin was bounced into
the first-run wizard even though the instance is fully populated. The harness now
sets setup_complete=true (it already mints the admin), so an imported instance
goes straight to the app.
2026-07-13 14:41:23 -04:00
cproudlock
9c909af66d WJ import loader: metrology PCs are computers that control a synthesized tool
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Routing by pctype wrongly swept ~105 metrology PCs (CMM/Genspect/Keyence/Wax)
into measuringtools - but a PC that drives an instrument is still a computer;
the physical CMM/gauge is the tool. Dropped the pctype override: those PCs now
import as computers (measuringtools drops to the 48 real instrument rows).

Classic has no separate tool row for a metrology PC, so they'd be orphaned. New
metrology stage synthesizes a measuring-tool asset per metrology PC (typed by
its pctype: CMM / Form Tracer / Vision System / Genspect) and a Controls
relationship PC -> tool, mirroring what the runtime collector does.

Result: computers 663->751, measuringtools = 48 real + 88 synthesized (each
linked to its controlling PC), 88 new Controls relationships, no orphans.
2026-07-13 14:38:00 -04:00
cproudlock
c9641a3c62 CLAUDE.md: refresh Current State (tests, plugins, migration head, import docs)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
808->966 tests (+ the migrations-mysql CI job), 11->12 bundled plugins (add
geenforce), Alembic head 7d24->7d25 (32 migrations; env.py sql_mode note for
strict MySQL 8), date to 2026-07-13, and a legacy-import pointer
(IMPORT-API/ADOPTION/PILOT-DEPLOY + the WJ reference loader).
2026-07-13 14:30:00 -04:00
cproudlock
a736ed541e Fix Actions column border: keep td.actions as a table-cell
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
.actions sets display:inline-flex (correct for a button container), but it was
applied directly to <td class="actions">, pulling the cell out of the table row
box so its bottom border rendered ~1px off from the other columns. Override
td.actions back to display:table-cell and space multiple buttons with a margin
instead of the flex gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:23:28 -04:00
cproudlock
58af5afda2 List rows click through to the item; drop the redundant VLANs tab
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Has been cancelled
Row-click: the whole table row now navigates to the item detail (machines, PCs,
printers, network devices, measuring tools, applications), matching the Networks
list. The actions cell is @click.stop so View/Edit/Delete still work
independently; a shared .clickable-row style gives the cursor + hover.

Network hub: drop the VLANs tab - a subnet belongs to a VLAN (each Networks row
already shows its VLAN) so a sibling tab was redundant; VLAN naming stays in
Settings. Hub is now Devices | Networks.

Verified: row-click navigates on machines/pcs/network; hub shows two tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:21:34 -04:00
cproudlock
8528617037 Network: consolidate into one tabbed hub; subnet devices span all asset types
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Replaces the two flat "Network Devices" + "View Networks" nav entries with a
single "Network" entry opening a tabbed hub: Devices | Networks | VLANs
(NetworkHub renders the existing device list, the subnet browse, and the VLAN
list; VLANs is now reachable outside Settings). /network -> hub; /networks
redirects to the Networks tab; subnet detail stays at /networks/:id.

Subnet "Devices on this network" now matches ANY asset whose primary IP falls in
the CIDR (PCs, printers, machines, measuring tools - not just network devices),
computed on the core Communication + Asset tables; each row links to its typed
detail (extension id resolved lazily/guarded per plugin). Fixes the empty list -
printers and PCs carry IPs and now appear (e.g. 35 devices on 10.80.92.0/24).

Also: subnet-browse search uses the standard form-control styling; dropped the
redundant per-tab page header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:15:51 -04:00
cproudlock
dd541fba0a Add "View Networks": front-facing subnet browse + detail with attached devices
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Subnets previously lived only under Settings, easily confused with the Network
Devices asset list. Add a front-facing browse + detail:

- Nav: rename "Network" -> "Network Devices"; add "View Networks" (subnets), both
  under Assets (network plugin get_navigation_items; frontend fallback matched).
- /networks (SubnetsBrowse): all subnets with name / CIDR / type / VLAN / notes,
  searchable, row-click to detail.
- /networks/:id (SubnetDetail): the subnet (CIDR, network address, type, VLAN,
  gateway, notes) plus the network devices whose primary IP falls inside its
  CIDR - get_subnet now computes that membership in Python (a device's IP lives
  in a Communication row, so it is not a plain SQL join).

Verified on the import DB: 37 networks list (real WJ subnets), detail renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:06:47 -04:00
cproudlock
5f51aa2383 Detail pages: give the relationships card bottom spacing (was merging with Notes)
.relationships-section had card styling but lacked the margin-bottom +
break-inside:avoid that .section-card has, so it sat flush against the Notes
card below it and looked like one merged card (and could split across a multicol
break). Add both to match section-card. Affects every asset detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:53:37 -04:00
cproudlock
a6ebec5118 Employee profile: cap USB checkout history with a show-more toggle
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
USB Checkout History rendered every record unbounded (long table for heavy
users), inconsistent with the recognitions list right above it. Add the same
limit (10) + "Show N more" / "Show less" toggle recognitions use. Client-side
only; no API change (per-user history is small at current scale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:49:33 -04:00
cproudlock
6d843e46e1 Applications: show related knowledge-base articles on the app detail
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Classic ShopDB listed an application's related KB articles on its page; the flask
app detail didn't, even though KB articles carry an appid FK. get_application now
returns a knowledgebase list (KB rows linked by appid; lazy + plugin-guarded so
core stays decoupled), and ApplicationDetail.vue renders a Knowledge Base section
linking each article.

Verified: an app returns its linked KB (e.g. 77 articles) and the section renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:44:23 -04:00
cproudlock
49d311b56e WJ import loader: map application link + documentation path
The applications stage dropped applicationlink and documentationpath, so the app
detail's "Launch Application" and "Documentation" links were always empty. Map
both from the classic applications table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:44:22 -04:00
cproudlock
b2dea82ac9 Global search: find employees in selfhosted mode, not just external HR DB
_search_employees always queried the external HR database (employee_connection),
so a site running the selfhosted employee directory (the app-owned
directoryemployees table) got zero employee results - searching an SSO or name
found nothing, and there was no way to reach the employee profile. Made it
mode-aware via the employee_directory_mode setting: selfhosted -> query the
DirectoryEmployee table (lazy, plugin-guarded); external -> the HR DB as before.

Verified: SSO 210009518 -> Jeff Pierce -> /employees/210009518; name "Pierce"
-> Pierce Cox, Andy Pierce, Jeff Pierce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:39:18 -04:00
cproudlock
2229db4a70 Notifications: summarize multi-person names in the calendar event title
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
A recognition/training notification for many people prefixed the calendar title
with the entire roster ("Name1, Name2, ... , Name20: description"), burying the
description. to_calendar_event now shows "First Person +N" when more than one
person is listed; single-person titles are unchanged and the detail popup still
shows the full employeename. Fixes the cluttered month grid, especially after
the loader began resolving employee SSOs to names.

31 notification/calendar tests pass; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:32:39 -04:00
cproudlock
c4690da262 WJ import loader: resolve notification employee SSOs to names
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Notifications imported with only employeesso, leaving employeename null - the
model displays "employeename or employeesso", so recognition/training cards
showed a bare SSO instead of a name. Build an SSO -> "First Last" map from the
employee source and populate employeename (comma-separated SSOs -> joined
names). SSOs not in the directory (former/non-WJF) stay null and fall back to
the SSO, as before.

Verified on the import DB: 261 notifications re-imported, names resolved
(Brandon Saltz, Jon Kolkmann, ...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:25:12 -04:00
cproudlock
35690bd169 Migrations: relax session sql_mode so the chain runs on strict MySQL 8
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 8s
Found by test-deploying on a Windows + MySQL 8.0 VM: migration 7a01 seeds the
canonical relationship types with a raw INSERT that omits the NOT-NULL
createddate/modifieddate columns (the ORM supplies those via Python defaults at
runtime, but a raw migration INSERT does not). MySQL 5.x's lax default sql_mode
accepted it; strict MySQL 8 rejects it with 1364 "Field 'createddate' doesn't
have a default value", so a fresh `flask db upgrade` died at 7a01. Dev runs
MySQL 5.6, so this never surfaced locally.

migrations/env.py now sets the migration session sql_mode to
NO_ENGINE_SUBSTITUTION (dropping STRICT_TRANS_TABLES) for the migration run
only - the app's own runtime connections keep their mode. Makes the whole chain
portable across MySQL versions. Guarded for non-MySQL (sqlite tests).

68 migration/smoke tests pass; fresh upgrade to head verified on MySQL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:06:07 -04:00
cproudlock
83a4867d1b Add production pilot runbook (stand up + import + verify + cutover)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
docs/PILOT-DEPLOY.md ties the generic per-site deploy (DEPLOY.md) to the legacy
import: pre-flight, stand up an empty instance, enable all plugins (incl usb),
load the three classic dumps into scratch DBs, run the WJ loader against the
pilot DB, verify (row-count audit + UI spot-check checklist), a parallel-run
window, cutover, rollback, and post-cutover (backups, photos, GE-Enforce).
Includes the expected import magnitudes from the dev run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:21:40 -04:00
cproudlock
009ac9f9ef Import docs: adoption playbook + superseded-mappers note + loader status
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Follow-up to the mapper retirement (the new docs missed the prior commit's
staging). Adds docs/IMPORT-ADOPTION.md (two-layer import story + stage/crosswalk
guidance), scripts/migration/README.md (dir superseded, points at the API +
loader), and updates the WJ loader README to complete status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:18:56 -04:00
cproudlock
474f245ae7 Retire stale legacy-import mappers; add adoption playbook
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Cleanup after the reference loader (scripts/site_imports/wjf/) proved out.

Removed the drifted direct-SQL migrators - migrate_assets/communications/
notifications/usb.py, run_migration.py, verify_migration.py, and
scripts/import_from_mysql.py. They targeted a nonexistent equipment table, the
retired Machine model, and columns that no longer exist; nothing imported them.
scripts/migration/README.md now points at the import API + the site loader.
Kept the one-time SQL fixups (fix_legacy_schema.sql, one-offs/).

Added docs/IMPORT-ADOPTION.md: the two-layer import story (stable IMPORT-API
contract + per-site loader), stage-ordering + crosswalk guidance, the
agent-assisted mapping path, and what the WJ loader demonstrates. Updated the
loader README to complete status (all 15 stages, final counts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:18:14 -04:00
cproudlock
af9afc15c5 WJ import loader: printers stage + Controlled-By relationship reversal
printers: printers come from the printers TABLE (not the machines hub), so a
dedicated stage - assetnumber synthesized PRN-{printerid}, IP folded via the
create route, host machineid resolved to a location when it is a LocationOnly
row. Skips inactive. 50 of 56 imported.

relationships: a "Controlled By" edge now flips to the forward Controls
direction (source/target swapped, mapped to the Controls type) instead of
importing a redundant inverse type.

Final fresh full run: 983 assets (933 machines-hub + 50 printers), zero endpoint
errors, all 15 stages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:15:41 -04:00
cproudlock
99ad6c0ebb WJ import loader: tail stages (locations, relationships, subnets, usb, verify)
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Completes the loader end to end. Verified on a fresh scratch target, zero
endpoint errors:

- locations: the 24 islocationonly rows -> core Locations (crosswalk machineid
  -> locationid).
- relationships: 93 active edges imported (206 of 299 dropped because an
  endpoint became a Location / was skipped / dedup-lost); types folded onto the
  seeded canonical set; dedup on (source,target,type).
- subnets: 37 (full CIDR reconstructed as INET_NTOA(ipstart)+suffix; VLANs
  lookup-or-create by number; 3 duplicate CIDRs first-wins-skipped).
- usb: 18 cmmc devices + 232 check-in/out events, paired with per-device open
  state so unpaired log rows do not 400. Needs the usb plugin enabled + usb
  directory mode selfhosted.
- verify: source-vs-target row-count audit (assets 1167->933 by the skip rules,
  applications 121=121, employees 415=415, KB 342->341).

Full pipeline default runs all 14 stages in order. NOTE: the import target needs
every bundled plugin enabled (usb ships disabled in this dev registry - enable
it before importing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:05:33 -04:00
cproudlock
40a89b360a WJ import loader: route LocationOnly by the islocationonly bit, not machinetypeid
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 8s
Of the 158 machinetypeid=1 rows, only 24 carry the islocationonly bit (real
named areas: DT Office, IT Closet, Materials, ...). The other 134 are active,
modelled shop machines just left untyped - routing all 158 to Locations dropped
those 134 real assets. Route on the bit instead; the 134 untyped rows import as
machines with a null subtype (machinetypeid=1 is not a real machine subtype, so
catalog skips seeding one).

Also process asset routes in richness order (computer > measuringtool > network
> machine) so on a duplicate machinenumber the PC - which carries installs + IP
a bare untyped machine does not - wins first-come.

Result on the scratch target: 933 assets (computer 663, machine 76, network 58,
measuringtool 136), 24 locations (was mis-routing 158), installs 850 (was 653 -
PCs no longer lose their numbers to bare machines), warranties 464, comms 461.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:55:25 -04:00
cproudlock
6d79e469fa WJ import loader: dependent-entity stages (comms, apps/installs, warranty, notif, KB)
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
All consume the machineid->assetid crosswalk from the assets hub. Verified
against the scratch target, zero endpoint errors:

- communications: 435 primary IPs folded onto assets. No bulk endpoint exists,
  so this is the plan's documented direct-ORM gap (reads the source
  communications table where comstypeid=1 AND isprimary=1, not machines.ipaddress1
  which is empty).
- applications: supportteams 45, applications 121 (colliding names dedup via the
  unique-appname 409-resolve), appversions 47, installs 653 (machineid ->
  assetid -> computerid; only computer assets take installs).
- warranties: 424 linked, vendor hardcoded Dell (source has none).
- notifications: types 6, notifications 261 (2099 sentinel endtime clamped).
- knowledgebase: 341 (appid resolved through the applications name map).

Inactive rows skipped everywhere per the decisions. Remaining loader stages:
locations (the 158 LocationOnly rows), relationships (301 active edges),
subnets/VLANs, usb (cmmc pairing), verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:43:02 -04:00
cproudlock
6c4bd20e01 WJ import loader: catalog + assets-hub stages (the machineid->assetid crosswalk)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Adds the keystone stages to the reference loader.

catalog: seeds modeltypes (from classic machinetypes + category), the per-plugin
asset subtypes routed by machinetype (machines/network/measuringtool types),
computer subtypes (from pctype), the 5-row controllertypes vendor/model split,
and the models catalog - each with a persisted legacy->new crosswalk. Verified:
modeltypes 31, computertypes 12, models 118.

assets (the hub): fans classic machines out to the right endpoint by
machinetypeid (+ the pctype metrology override), applying the resolved
decisions - assetnumber = machinenumber else hostname, skip 9999, skip duplicate
machinenumbers, LocationOnly/printer/USB routed out. Persists the machineid ->
assetid crosswalk every downstream stage needs. Verified against a fresh scratch
target: 884 assets (computer 623, machine 68, network 58, measuringtool 135),
zero endpoint errors, idempotent re-run (stays 884). Skips: location 158, dup 69,
other 53, 9999 1.

Harness now runs each plugin's idempotent on_install so the AssetType rows exist
(a DB built with plugin upgrade-all instead of a fresh install lacks them, and
the create routes 500 without them). 409-resolve lookups page through per_page.

Remaining stages: communications (primary IP fold - source is the communications
table, not machines.ipaddress1), applications/installs, warranties, notifications,
KB, subnets/VLANs, usb, verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:37:11 -04:00
cproudlock
b11a6f26d8 WJ legacy-import reference loader: harness + reference/employees stages
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Layer 2 of the import design (see the memory + scratchpad/IMPORT-PLAN.md): a
SITE-SPECIFIC reference loader that maps WJ's classic-ASP schema onto the
maintained, schema-agnostic IMPORT-API contract. Other sites copy the pattern
against their own source DB; nobody runs this loader as-is.

Harness (scripts/site_imports/wjf/harness.py): builds the app against the
current DATABASE_URL (point it at a throwaway import DB), mints an unscoped
admin PAT in-process, and drives the real import endpoints through the app test
client with Authorization: Bearer + X-Import-Mode - exercising the same
routes/authz/validation an HTTP client would, no running server needed.
Read-only pymysql access to the three scratch source DBs; legacy-id -> new-id
crosswalks persist to JSON so a crashed run resumes and later stages resolve FKs.

Stages implemented + verified idempotent against a fresh scratch target
(shopdb_flask_import): reference (vendors 46, businessunits 13, operatingsystems
11) and employees (directory 415, re-run updated-not-duplicated). Remaining
stages (models, applications, assets hub + crosswalk, dependents, network, usb,
verify) are stubbed with the same shape; README documents the adoption playbook.

idmap.json is generated state (gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:20:58 -04:00
cproudlock
012718f0fd Import-API: preserve app-version timestamps in import mode
Some checks failed
CI / backend (push) Successful in 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
POST /api/applications/{id}/versions now calls apply_import_timestamps so an
imported version's original dateadded/releasedate survives (was skipped, unlike
the app + install endpoints). No-op outside import mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:12:03 -04:00
cproudlock
be1ea29403 Import-API hardening: network device IP endpoint + communicationtypes seeder
The legacy-import surface (docs/IMPORT-API.md) is the schema-agnostic contract
every adopting site targets; these close two gaps found while mapping the WJ
classic import.

Network device IP: POST/PUT /api/network now accept an `ipaddress` and
materialize a primary Communication (mirroring the printer route), and GET
(list + detail + create/update result) surface it. Previously a network
device's IP - which lives in the communications table, not on the extension -
had no HTTP import path at all.

communicationtypes seed: `flask seed reference-data` now seeds the eight
canonical communication types (IP/Serial/Network_Interface/USB/Parallel/VNC/
FTP/DNC), which IMPORT-API.md already documents as a prerequisite. The IP type
must exist before any asset import so printer/network routes can attach an IP.
There is no CRUD endpoint for these, so seeding is the only path.

Tests: network create/update IP upsert + GET surfacing + seed creates IP type.
204 targeted tests pass; naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:11:01 -04:00
cproudlock
1c6c7ba14b DB review fixes: drop redundant indexes + dead column, add CI MySQL-upgrade job
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
From the database review (verdict: sound-with-minor-issues). Applies the
actionable findings.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:09:29 -04:00
cproudlock
cd02cd20f4 Frontend naming + CSS-variable cleanups (review low)
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
Naming convention (LOCKED): rename the ManifestEditor simulate state sim ->
simulateInputs / simResult -> simulateResult (+ .sim-result CSS class) - 'sim'
was banned standalone shorthand. Rename AssetRelationships props assetId ->
assetid and machineNumber -> machinenumber so a prop holding a DB field value
mirrors it verbatim; updated the five detail-page call sites (:assetid=).

CSS variables: SearchResults per-domain badge palette moved into CSS variables
on the container; the duplicated prefers-color-scheme dark block collapses to a
single set of variable overrides instead of restating all ten selectors.

frontend build green; vitest 49 pass; naming green; search badges + detail
relationships verified rendering with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:48:50 -04:00
cproudlock
b99da362b5 Docs: refresh ROADMAP to current state (contract 0.11.0, phase 6 done)
Review flagged ROADMAP as six contract versions stale. Set opening version to
0.11.0; mark phase 6 (multi-site distribution) done and name the real last
milestone (legacy import + prod pilot); drop the three completed items
(per-plugin Alembic chains per ADR-008, local font bundling - Inter is bundled
via @fontsource, measuringtools plugin built); reframe the frontend item to the
part that actually remains (external plugin UI packaging - hooks + gating
already shipped via ADR-009/010); add ADR-007..012 to the decision-log pointers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:42:51 -04:00
cproudlock
ea3cca8954 Test coverage: plugin lifecycle, registry, lockout unlock, contract fleet, seed
Fills the review's highest-value coverage gaps in the framework's own core
feature.

New tests/test_plugin_lifecycle.py: manager enable/disable dependency guards
(beta depends on alpha - enable-beta-first refused, disable-alpha-while-beta-on
refused) via synthetic plugins; enable seeds a plugin's RBAC permissions
idempotently; registry disable-survives-reload, corrupt-file recovery, and the
equipment->machines rename migration; `flask seed settings` idempotency.

test_plugin_contract.py: BUNDLED_PLUGINS now covers all bundled plugins incl.
geenforce, measuringtools, warranty (was 9, contradicting the CLAUDE.md "all
bundled satisfy the contract" claim); the structural checks now run against them.

test_authz.py: account lockout auto-unlock path (expired lockeduntil -> correct
password logs in and clears the lock state), previously untested.

All new tests pass; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:41:23 -04:00
cproudlock
d49baeb5fa Security closeout: settings public allowlist, audit.view gating, test-user guard
Settings exposure (review medium): GET /api/settings and /api/settings/<key>
now return the full table only to an authenticated principal. Unauthenticated
callers (kiosk dashboards, print pages, login screen, setup router) get just a
public allowlist - categories branding + map plus a named set (site_base_url,
facility_name, printer_hostname_template, contact_email_domain,
servicenow_enabled, setup_complete). A non-public single-key GET returns 404 so
existence is not confirmed. Secrets stay masked in both cases. Closes the
unauthenticated enumeration of smtp_host / employee_db_host / zabbix_url /
servicenow URLs. Allowlist mirrors the keys siteSettings.js + mapConfig.js +
setupState.js read before login.

audit.view (review low): the three audit-read routes (list, entity-history,
stats) were jwt_required only despite a defined-but-unwired audit.view
permission; now gated by it (seeded to admin), so a role-less member or unscoped
PAT can no longer read the cross-user audit trail.

flask seed test-user (review low): refuses outside DEBUG/TESTING - it creates
the well-known admin/admin123; production sites use `flask seed admin`.

Tests: unauthenticated allowlist + authed-full-masked + private-key-404, and
member-403 / admin-200 on audit routes. 336 authz tests pass; naming green.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:02:43 -04:00
cproudlock
85a6ab8645 Setup wizard: plugin display names + GE-Enforce next-steps pointer
All checks were successful
CI / backend (push) Successful in 1m37s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Plugins list now carries a displayname (manifest display_name, else the
machine name title-cased). Adds display_name to the four whose title-case
was wrong: GE-Enforce, USB, Measuring Tools, Knowledge Base. The setup
wizard Features step and Settings > Plugins render it, so "Geenforce"/"Usb"
are gone.

Finish step shows a pointer when GE-Enforce is enabled: it still needs a
scoped service token (Settings > API Tokens) and a share export root
(GE-Enforce page) before the fleet uses it - operational config the wizard
does not collect.

frontend build green; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:41:32 -04:00
cproudlock
d7b777a7a0 GE-Enforce editor: onboarding field guidance
All checks were successful
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Per-method detection hint: a plain-language line explains what "already
correct" means for the selected detection method (Registry/File/FileVersion/
Hash/MarkerFile/ValueMatches/pnputil/Always), updating live as the author
picks one. Lives in entryForm.js (DETECTION_METHOD_HINTS + detectionMethodHint)
with 4 new vitest cases; the editor renders it under the dropdown.

Also: relative-path hint on Installer/Source (path under the scope payload
folder or an inline payload), an InUseCheck behavior hint, and refresh the
entryForm.js header now that the editor imports these helpers directly.

vitest 49 pass; frontend build green; naming green.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:25:01 -04:00
cproudlock
4e5b4228c1 GE-Enforce: compliance view, inline payload upload, frontend test harness
Fleet-install compliance for app-linked manifest entries: new service
compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active
ComputerInstalledApp rows by curated appid (null-safe when computers plugin
absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb
metadata and never enters manifest JSON, so behavioral parity is unaffected.

Inline manifest payloads: store_inline_payload (sha256, 1MB cap,
payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor
gains an upload control. Entry payload metadata surfaced in _entry_payload.

Frontend test harness: extract the editor's entry-form logic into pure
entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope
gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports
those helpers, so the tests exercise the shipped code path (no duplication).

908 backend tests pass; vitest 45 pass; frontend build green; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:24:51 -04:00
cproudlock
3355436fcd Add curated manifest-entry -> Application link (honest app tracking)
All checks were successful
CI / backend (push) Successful in 1m34s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
The honest replacement for the backed-out auto-seeding: instead of scraping
manifest labels into duplicate Application rows, an entry can be LINKED to an
existing catalog Application, cross-referencing what shopdb already tracks.

- Model: manifestentries.appid (nullable soft ref to core applications; in the
  0001 baseline). It is shopdb METADATA, deliberately NOT a manifest field - it
  never appears in the rendered manifest JSON, so enforcement + parity are
  unaffected (test asserts it stays out of the preview manifest).
- API: _entry_payload returns appid + resolved appname; create/update accept an
  optional appid (validated, unknown id ignored, null unlinks) via _apply_app_link;
  GET /geenforce/applications is the picker source (id + name).
- Editor: a "Tracked application (optional)" select in the entry modal, and the
  entry summary line notes the linked app ("...; tracked: eDNC").
- Foundation for a future desired-vs-observed compliance view.

889 tests green (incl. the link test + parity/migration unaffected); build +
naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 06:45:10 -04:00
cproudlock
810687953f ADR-012: GE-Enforce manifest ownership in shopdb (ACCEPTED)
All checks were successful
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Formalizes the design built this session: manifest as shopdb data (wide entries
table + entrytype discriminator, per-plugin Alembic chain), immutable published
snapshots + rollback, behavioral-parity gate, engine-as-source-of-truth filter
mirror, payload integrity separate from detection, observed-state reporting,
service-token auth (contract 0.11.0), client kit + provisioning-agnostic
Install-GEEnforce bootstrap (engine referenced not vendored), Milestone-1
export-to-share + staged cutover, and NO application auto-seeding (curated
linking instead). Indexed in ADR README + CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 06:37:40 -04:00
cproudlock
3b400d8cc4 Fix GE-Enforce client kit under PowerShell 7 (header-array coercion)
All checks were successful
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Found by running the kit under pwsh 7 against the live API: Invoke-WebRequest
returns header values as string ARRAYS in PS7 (scalars in Windows PowerShell
5.1), so X-Manifest-Version came back as @('1') and [int] on it threw - report
build failed. The target scheduled task runs 5.1 (works), but the kit must be
robust under PS7 too (target preinstalls PowerShell 7). Coerce ETag and
X-Manifest-Version with @(...)[0], a clean scalar in both.

Validated end to end on Linux pwsh 7.6.3 against the dev API: fetch (200) ->
cache-304 -> report sent -> landed received=true/status=ok. All 4 client scripts
parse clean; PSScriptAnalyzer shows only cosmetic warnings (Write-Host in a CLI,
intentional log-guard catch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 06:23:56 -04:00
cproudlock
8dceb8812f Add GE-Enforce agent deployment: Install-GEEnforce.ps1 + deploy doc
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Closes the "how do sites actually deploy GE-Enforce" gap (esp. OOBE-ppkg sites
without a PXE/WinPE step). Site-neutral + imaging-path independent.

- plugins/geenforce/client/Install-GEEnforce.ps1: a bootstrap that writes the
  PC's identity (C:\Enrollment\pc-type.txt is what determines the PC type; plus
  machine-number/cmm version/cmm id/site-config as needed), sets the shopdb
  BaseUrl + token in HKLM:\SOFTWARE\GE\ShopDB, deploys the client kit, optionally
  copies the engine from -EngineSource, and registers the SYSTEM scheduled task
  (at logon + every N min). Idempotent; fails loud (installer, not the fail-safe
  runtime). Engine is REFERENCED not vendored - it belongs to the GE-Enforce
  framework; the script warns if absent but still labels the PC.
- docs/GE-ENFORCE-DEPLOY.md: the deploy contract - the three things a PC needs
  (client, identity, credential), the identity table (what determines PC type,
  no auto-detection - the provisioner supplies it; shopdb cannot set it at
  imaging), and how to invoke per path (PXE step, OOBE ppkg via
  ProvisioningCommands, Intune, manual), the engine boundary, and verification.
- Cross-linked from docs/GE-ENFORCE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 23:02:54 -04:00
cproudlock
6e7d51de21 GE-Enforce editor: phase-aware form, scope summary, narrower entries table
All checks were successful
CI / backend (push) Successful in 1m37s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
- Phase-aware editing: a preinstall scope now only offers MSI/EXE types and
  Registry/File detection (the preinstall runner silently skips the rest), and
  the preinstall flags show only for a preinstall scope - so an author cannot
  pick an option that would do nothing.
- Per-scope summary line ('Installs PC-DMIS 2016, ...; 4 entries; runs after
  common') under the scope header.
- Entries table: dropped the redundant Detection + Filters columns (the
  plain-English entry line already conveys them), fixed-layout with sized button
  columns and stacked Up/Down - no more horizontal scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:06 -04:00
cproudlock
e6ed47c533 Fix 7 factual errors in GE-Enforce doc (Fable fact-check vs real source)
All checks were successful
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Verified claim-by-claim against the engine/dispatcher/preinstall runner/manifests.
Corrections (3 were ship-blocking):

- Timeline (ship-blocking): identity files (pc-type/machine-number/cmm version/
  site-config) are written in WinPE at the PXE menu BEFORE the image boots, not
  during a post-imaging 'enrollment' step; preinstall already reads them. Added a
  step [0]; enrollment is now only Intune + Azure DSC credential provisioning.
- _CmmVersion (ship-blocking): a CMM bay with NO resolved version gets ALL
  PC-DMIS versions (legacy install-all), not none.
- machine-number 9999 (ship-blocking): the enforcement engine does not
  special-case 9999; it is a placeholder that won't match real bay gates (the
  9999-skip is status-write-back only).
- Preinstall runner implements only MSI/EXE + Registry/File detection, not the
  full matrix (that is runtime-only).
- Runtime processes up to three scopes: common, type, then optional type-subtype.
- pc-subtype.txt is legacy (no longer written at imaging since 2026-05-04).
- The collector ComputerType mapping lives at Settings > Collector PC Types, not
  the geenforce scope (scope computertypeid is a local reference field).
- FileVersion is a raw string compare; 4-part is convention, not engine-enforced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:47:59 -04:00
cproudlock
ab0c9a454b Make GE-Enforce editor more legible: plain-English entries + how-it-works panel
All checks were successful
CI / backend (push) Successful in 1m37s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
- Each manifest entry now shows a one-line intent summary under its name
  (e.g. 'Installs eDNC; reinstalls if not detected; 2 PC types') instead of only
  the raw Type/Detection/Filters columns - turns jargon into what it actually does.
- A collapsible 'How this works' panel at the top gives the mental model in four
  sentences (desired state + self-heal, entries + detection, targeting, doc link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:45:50 -04:00
cproudlock
655fe1087f Add GE-Enforce guide: concepts, shopdb plugin, imaging-time integration
All checks were successful
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
docs/GE-ENFORCE.md - operator-facing guide grounded in the real engine +
manifests (Fable-verified analysis): how GE-Enforce works (preinstall vs runtime
phases, the enforce loop, manifest scopes/entries, self-heal detection, gates,
enrollment), WHEN it installs/takes over in the imaging timeline (preinstall at
imaging -> GE-Enforce laid down -> enrollment provisions creds -> runtime
enforcement from first logon), how the shopdb plugin manages it (Manifests
authoring + contextual targeting + simulate + publish/rollback + Export to Share
Milestone 1, Enforcement Reports), day-to-day IT tasks, and a reference index.
Complements GE-ENFORCE-CLIENT.md (client contract) and the proposal (plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:36:18 -04:00
cproudlock
378af32c1d Rebuild GE-Enforce editor: themed (Fable redesign) + semantics-aware targeting
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Replace the generic field-dump editor with the Fable-coordinated redesign
(stock .section-card / .settings-grid / .setting-row / .table-container /
badge / btn primitives, opaque global .modal on --bg-card-solid, proper
modal-header/body/footer), then layer the manifest semantics on top:

- CONTEXTUAL targeting: an entry only shows the gates its scope actually uses,
  instead of every gate on every entry. Data-driven + scope-aware -
  common/preinstall (fleet-wide) show PC types; CMM shows the version gate;
  a scope whose entries use machine numbers (collections) shows those; hostnames
  show when used. A short explainer states why (a per-type manifest already runs
  only on its own type), and "Show all targeting options" reveals everything.
  Hidden gates keep their value on save (no data loss).
- Preserves all functionality: type-switched payload blocks, detection reveal,
  structured InUseCheck rows (name/ExePath/timeout), LogFile, preinstall
  checkboxes, simulate, publish/versions/rollback, export. Entry modal widened
  to min(1100px,96vw).

EnforcementReports adopts the themed redesign (filters, card > table-container,
badge-mapped statuses, global modal) with the error handling kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:15:48 -04:00
cproudlock
3ac41c1556 Back out app auto-seeding; fix report-status + PCTypesStrict bugs (manifest review)
All checks were successful
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
A deep Fable review of the real manifest corpus (READ-ONLY reference) showed the
manifests are an ENFORCEMENT PROGRAM, not an application inventory, and that
auto-seeding the Applications catalog from entry Type + Name was wrong:

- The catalog ALREADY tracks these apps from the classic-shopdb migration, with
  version histories (PC - DMIS, UDC x11 versions, eMX / eDNC, CLM, CSF, Oracle
  Database, FormTracePak). Seeding from manifest labels created DUPLICATES under
  different names (PC-DMIS 2016 vs PC - DMIS; eDNC (bundles NTLARS) vs eMX / eDNC;
  OpenText HostExplorer ShopFloor vs CSF). It also misclassified config drops
  (eMxInfo.txt) as apps and could never match a PC's reported ARP name.
So the seed-applications command + service are removed. Properly linking
manifest entries to the EXISTING catalog is a curated feature, not label-scraping.

Two REAL bugs the review found are fixed and kept:
- Report status (R4): every healthy cycle runs Always/no-detection scripts the
  engine counts as "installed", so keying self-heal off installed>0 marked the
  common scope selfhealed forever and made 'ok' unreachable. Status now derives
  from explicit per-entry self-heal flags only; the stored flag no longer infers
  from action=='installed'; the client kit doc reflects it.
- PCTypesStrict (R5): the runtime engine has no strict handling (preinstall
  runner only). filters.matches_pctype now applies strict only when phase ==
  'preinstall'; simulate + parity thread the scope phase through; the strict test
  uses a preinstall scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:56:18 -04:00
cproudlock
fc1f56fec3 Widen GE-Enforce entry modal to min(1100px,96vw), 3-column form grid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:26:54 -04:00
cproudlock
796007bcca Track manifest apps in the Applications catalog; make CMM gate contextual
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
seed-applications: a flask geenforce seed-applications command + service that
reads the imaging-PC-type manifests and creates a core Application for every
installer entry (MSI/EXE/CMD/BAT), so shopdb tracks what GE-Enforce actually
deploys. Idempotent, deduped by appname; File/Registry/PS1/INF config entries
are skipped. Run against the West Jefferson reference: 27 apps tracked (PC-DMIS
2016/2019/2026, eDNC, Oracle Client, Adobe Reader, HostExplorer, the VC++ redist
matrix, Keyence VR-6000, PowerShell, Display Kiosk, ...). 2 tests.

Editor: the CMM version gate (_CmmVersion) now only shows for CMM scopes - it is
metrology-specific, so a printer/common entry form no longer carries the
irrelevant field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:21:49 -04:00
cproudlock
d9d40297b6 Fix transparent + cramped GE-Enforce modals
--bg-card is translucent (rgba .4) in dark mode - a glass effect for cards on
the page, but a floating modal rendered over content showed the page through it.
Modals now use --bg-card-solid (the opaque surface the shared Modal.vue uses),
with a border + shadow, and are wider (entry editor min(920px,94vw)) so the full
field set fits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:19:06 -04:00
cproudlock
24e38c6e75 Remove orphaned settings-dir GE-Enforce views (moved to geenforce/ section)
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
The earlier move copied instead of moving, leaving unreachable duplicates under
views/settings/. The routed copies live in views/geenforce/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:14:00 -04:00
cproudlock
7089a61b50 Move GE-Enforce to its own top-level section; fix overflow + input theming
GE-Enforce is a large operational surface (manifest authoring + fleet
compliance), not a setting, and it was squished in the settings two-pane shell.
Promote it to a dedicated full-width top-level section:

- New sidebar entry "GE-Enforce" (plugin get_navigation_items, shield icon,
  auto-gated to the enabled plugin) instead of two Settings > Integrations cards.
- Tabbed shell GeEnforceLayout.vue (Manifests | Enforcement Reports) with
  full-width children under AppLayout, not the narrow settings rail.
- Views moved settings/ -> geenforce/ (ManifestEditor.vue, EnforcementReports.vue).

Theming + overflow fixes (the "chaotic / cut off / different inputs" report):
- Inputs/selects/textareas now match the stock settings look (border, radius,
  --bg, focus color) instead of browser defaults.
- No horizontal overflow: editor grid uses minmax(0,1fr) + min-width:0 on
  children, collapses to one column under 1000px; entry table and reports table
  scroll inside their own overflow-x containers; detail actions wrap.

Verified at 1280px: no page overflow, detail pane + tables fit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:12:57 -04:00
cproudlock
0dcd186820 Fix defects found in session review of GE-Enforce plugin
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Consolidated fixes from a three-dimension adversarial review.

Data-loss (HIGH): the manifest entry editor stripped fields the form did not
expose, because PUT /entries is a full reset-then-apply. The form now captures
everything - InUseCheck processes as structured name/ExePath/timeout rows (not
just names), LogFile, and the three preinstall flags as checkboxes; the dead
payload-source control (never wired) is removed. New regression test proves an
edit preserves ExePath/timeout/LogFile/PreEnrollment/PCTypesStrict.

Update-entry crash (found by that regression test): replacing an entry's
one-to-one InUseCheck (unique entryid) collided with the old row mid-flush ->
IntegrityError -> 400. update_entry now frees the old InUseCheck (delete+flush)
before populate re-inserts it.

Export truncation (MEDIUM): export_scope_to_share used a plain truncating open,
so a failed/partial write left the live on-share manifest (every PC reads it)
empty. Now writes a temp file in the same dir and os.replace() atomically.

Report dedup case bug (MEDIUM, confirmed by scratch test): the iscurrent demote
matched hostname case-sensitively while the read path uses ilike, so a PC
reporting different casing left two iscurrent rows and double-counted. Demote is
now case-insensitive; regression test added.

Simulator fidelity (MEDIUM): PCTypesStrict was captured but ignored by the
filter mirror, so the simulator wrongly matched a collections-only strict entry
to a nocollections PC via the shared Standard alias group. matches_pctype now
honors PCTypesStrict (disables alias expansion); test added.

Hardening: removed the dead/unscoped GEENFORCE_API_KEY env fallback (never wired
into config; tokens are the only path); create/update entry return 400 on a
duplicate Name instead of 500; parity now asserts scope-level Version/Site; a
new test guards real-manifest field lengths against column limits (the DB-free
parity harness can't see truncation); error handling added to the previously
unguarded editor + reports API calls.

Full suite green; naming + frontend build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:46:09 -04:00
cproudlock
d894f054ac Add GE-Enforce P4 client kit: fetch + report + shadow mode (reference)
All checks were successful
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Client-side integration kit for sourcing manifests from shopdb and reporting
results back. Site-neutral reference a site adapts into its GE-Enforce.ps1; the
live dispatcher and engine are NOT touched (they are read-only reference under
projects/pxe). Only the manifest JSON source moves from a share file to shopdb,
plus a result report.

- plugins/geenforce/client/ShopdbEnforceClient.psm1: Sync-ShopdbManifest (GET
  with ETag -> local cache; falls back to last-known-good when shopdb is
  unreachable so a PC is never left unmanaged), Compare-ShopdbShadow (behavioral
  diff vs the on-share manifest), Send-ShopdbReport / New-ShopdbReport (best-
  effort POST /report), Get-ShopdbConfig (BaseUrl + token from
  HKLM:\SOFTWARE\GE\ShopDB).
- plugins/geenforce/client/Invoke-ShopdbEnforce.ps1: orchestrator. Fetches,
  optionally shadow-compares (installs from the share, only logs the diff), runs
  the unchanged engine, and reports. Fail-safe: any error exits 0.
- docs/GE-ENFORCE-CLIENT.md: the fetch + report contracts, config, cache/fail-
  safe behavior, the staged shadow -> read-cutover -> payload-migration runbook,
  and TLS/payload-integrity notes.

The report JSON shape matches the POST /api/geenforce/report contract already
covered by the reporting tests. Nothing here runs the live client; shadow mode
and cutover stay a site decision after Milestone 1 sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:30:08 -04:00
cproudlock
adc5b7f69e Add GE-Enforce export-to-share + fleet-compliance UI (Milestone 1 UX)
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
Rounds out the Milestone 1 admin experience: author + publish in shopdb, push
to the share by a button, and see what the fleet actually did.

Export to share:
- GET/PUT /api/geenforce/config stores the on-share export root (Setting
  geenforce_share_root); POST /scopes/<id>/export-share writes the current
  published JSON to <shareroot>/<scope>/manifest.json (preinstall.json for the
  preinstall phase), backing up the existing file to _meta/history first.
  geenforce.publish gated. The engine and PCs are untouched - this is the safe
  Milestone 1 push whose rollback is restoring the history backup.
- Editor: a share-root config row + an "Export to Share" button per scope.
- 3 tests (config roundtrip, export writes the file, second export backs up).

Fleet-compliance UI (Settings > Enforcement Reports):
- New page over GET /reports + /reports/<id>: latest report per PC with
  received (applied vs latest published version), status (ok/selfhealed/failed),
  and install/skip/fail counts; row detail shows per-entry outcomes with
  self-heal flags, exit codes, and messages. Hostname/PC-type filters.
- ADR-010 settings card + ADR-009 plugin-gated route.

Full suite 883 green; frontend build + naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:06:33 -04:00
cproudlock
7eb6cebeb5 Add GE-Enforce P3 manifest editor UI (Settings > Imaging PC Types)
All checks were successful
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
The imaging-PC-type manifest editor, contributed as an ADR-010 settings card
(Integrations group) and an ADR-009 plugin-gated route
(/settings/imagingpctypes, hidden when geenforce is disabled).

- Scope list: every imaging PC type with phase, entry count, and published
  version (or "unpublished"). New PC Type button.
- Scope detail: ComputerType/MeasuringToolType mapping + description; Publish,
  Versions (with per-version Roll Back), Preview (draft JSON), Delete.
- Entry table: ordered with Move Up/Down (the ordering contract, not drag),
  Name/Type/Detection/Filters, Edit/Delete. Add Entry opens a typed modal whose
  fields switch on entry Type (MSI/EXE/... vs PS1 vs File vs Registry), with a
  detection block, comma-separated targeting filters, CMM version gate, payload
  source, and an Advanced disclosure for the inert ApplyMode/UpdateWindow and
  InUseCheck. RegValue is typed by RegType (DWord/QWord -> number).
- Simulator: "what would a PC get" - enter a machine profile, see which entries
  apply and which filter excluded the rest. Verified live: CMM version 2019 ->
  applies 2019 + untagged, filters 2016/2026 by _CmmVersion.

Uses the P2 admin API; JWT+admin gated. Frontend build + naming green; full
backend suite 876 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:22:49 -04:00
cproudlock
d157502d5b Add GE-Enforce P2 admin CRUD API: scopes, entries, reorder, simulate, publish
Full HTTP admin surface behind the manifest editor (geenforce.manage for edits,
geenforce.publish for shipping):

- Scopes: POST/GET/PUT/DELETE /scopes[/<id>] (create imaging PC types, edit the
  ComputerType/MeasuringToolType mapping + metadata, delete).
- Entries: POST /scopes/<id>/entries, PUT/DELETE /entries/<id>. Payloads use the
  manifest Applications[] shape; populate_entry (refactored out of build_entry)
  updates an entry in place, resetting omitted fields and replacing children.
- Reorder: PUT /scopes/<id>/entries/reorder enforces the ordering contract
  (body must list exactly the scope's entry ids).
- Simulate: GET /scopes/<id>/simulate?pctype&subtype&hostname&machinenumber&
  cmmversion returns which entries apply and which filter excluded the rest,
  reusing the engine-mirror filters. The "what would this PC get" tool.
- Publish lifecycle: POST /scopes/<id>/publish (records publishedby from JWT),
  GET /scopes/<id>/versions, GET .../versions/<n> (frozen manifest),
  POST /scopes/<id>/rollback.

Entry type validated against ENTRY_TYPES; 8 CRUD tests. JWT+permission gated so
the authz sweep covers them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:13:27 -04:00
cproudlock
6dc31c6149 Add GE-Enforce observed-state reporting: receipt + self-heal from PCs
All checks were successful
CI / backend (push) Successful in 1m37s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
PCs now report enforcement results back to shopdb, closing the desired-vs-observed
loop.

- POST /api/geenforce/report (geenforce.report service token): each cycle a PC
  posts the published version it applied, install/skip/fail/filtered counts, and
  per-entry outcomes.
- Two tables: manifestenforcementreports (latest-per-host + history: applied
  version, enforcer version, counts, derived status ok/selfhealed/failed) and
  manifestenforcementresults (per entry: action installed/skipped/failed,
  selfhealed flag, exit code, warning/error message).
- RECEIVED: reports carry the applied version; the admin view derives
  receivedlatest by comparing it to the scope's current published version, so
  the fleet view shows which PCs picked up an update.
- SELF-HEAL: per-entry action captures drift correction (installed when it
  should already be present) vs skipped (already good) vs failed, with messages.
- Admin reads: GET /reports (fleet compliance rollup) and GET /reports/<id>
  (per-entry detail). New geenforce.report permission.
- Tables added to the (undeployed) 0001 baseline; geenforce.post_report is a
  service-token endpoint so it is exempt from the JWT authz sweep, like the
  collector blueprint. 8 reporting tests; full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:04:07 -04:00
cproudlock
d85b33bd68 Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce
manifest becomes shopdb data.

P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled
false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its
0001 baseline really creates the tables.

P1a model: one wide manifestentries table + entrytype discriminator (not STI,
not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value
filter child tables, inusechecks + processes, immutable manifestpublishedversions
(frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases
(mirror of the engine lib's alias graph). regvalue stored as its raw JSON
literal so DWord typing survives.

P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into
draft rows and rebuild the JSON verbatim from rows in sortorder.

P1d parity harness (GATE A): filters.py mirrors the engine's four filter
functions + alias graph; parity.py proves import+export is behaviorally lossless
(field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT
byte-diffing. Verified PASS against all 11 real reference manifests (64 entries)
and a synthetic site-neutral fixture covering every type/filter (the CI gate).

First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/
export-to-share), CLI (parity, import-share, publish, export-share), and the
client endpoint GET /api/geenforce/manifest serving the current published
snapshot (never the draft) with ETag/304. Split permissions
geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits
never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope).

Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin
service endpoints authorize a scoped managed token without importing core token
internals. Documented in PLUGIN-HOOKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:53:18 -04:00
cproudlock
cf2f9c308e Add manifest revision history + draft audit trail to GE-Enforce plan
All checks were successful
CI / backend (push) Successful in 1m32s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
- Every publish is a permanent immutable revision (manifestpublishedversions),
  kept indefinitely; optional retention policy (keep last M / prune older than N)
  deferred, default keep-everything.
- Draft edits are not versioned (working copy overwrites), so field-level "who
  changed what between publishes" rides the existing core audit system - no new
  table, shows in the Audit Logs UI IT already uses.
- Runbook: History tab for published versions + roll back; Audit Logs for draft
  edit provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:24:28 -04:00
cproudlock
c88d673d7b Fold Fable execution review into GE-Enforce plan: simpler, IT-manageable
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Add an execution plan and simplify the design for average-site-IT operability
(the governing constraint from the review):

Model simplifications:
- One wide manifestentries table with an entrytype discriminator, not SQLAlchemy
  STI subclasses and not a JSON blob. ~64 total entries fleet-wide make sparse
  columns free and keep rows readable in plain SQL.
- Published snapshots freeze the rendered JSON document in a single manifestjson
  column; drop the row-mirrored manifestpublishedentries family. Immutability is
  structural, rollback is a one-flag flip, diff is a text diff.
- New manifestpayloads table for inline bytes with a ~1 MB app cap.
- regvalue stores the raw JSON literal (DWord typing); applymode/updatewindow
  flagged inert-in-engine so the UI labels them.

Execution plan (section 13):
- Phases P0-P6 with gates; parity harness spec (two checks, IT-readable output,
  ~16-18 machine-profile fixtures); first vertical slice through
  gea-shopfloor-cmm; ranked fail-fast risks.
- Milestone 1 = author + publish in shopdb, export to the share by a button,
  engine/dispatcher/PCs unchanged. Real pain relief at zero client risk, with a
  rollback IT already knows (restore the _meta/history backup).
- Export-to-share promoted to a first-class feature and permanent break-glass.
- Split permission geenforce.manage (edit) vs geenforce.publish (ship).
- Move Up/Down instead of drag-and-drop; a "what would this PC get" simulator
  endpoint + UI; three-increment editor build.
- Two-source pctypemap transition window; scope-inventory reconciliation
  (gea-shopfloor-display has no share dir).
- Plain-English IT day-to-day runbook proving the design is manageable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:15:41 -04:00
cproudlock
9dd2aa3cc6 Revise GE-Enforce plugin plan after review: parity, integrity, snapshots
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
Fold six review findings into docs/proposals/ge-enforce-plugin.md:

- Parity gate is behavioral equivalence, not byte-identity. Re-serialized JSON
  differs in key order/whitespace/_comment formatting, so a raw diff never
  converges; the test is same ordered entry set with identical detection/
  targeting/action per entry.
- Dedicated payloadsha256 column, independent of detectionmethod. DetectionValue
  is a SHA256 only for detectionmethod=Hash; MSIs with Registry/FileVersion
  detection carry no payload hash, so an HTTP/inline fetch would otherwise run
  unverified bytes. Client verifies fetched bytes against payloadsha256.
- Immutable published snapshots (manifestpublishedversions). Editing touches a
  draft only; publish freezes a snapshot; the client is always served the latest
  published snapshot, never the live draft; rollback republishes a prior
  snapshot (the post-cutover safety net once the on-share JSON is retired).
- Scope uniqueness is (scopename, phase), not scopename alone; preinstall is one
  flat scope gated internally by PCTypes, not per-pctype scopes.
- Alias graph: engine lib stays the single source of truth, shopdb only mirrors
  it for validation; do not invert to engine-fetches-from-shopdb.
- Desired-vs-observed needs a new collector field (the installedVersions status
  map), not existing data; flagged as a dependency.

Plus TLS trust for the SYSTEM-context client and importer skips .bak variants.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:29:08 -04:00
cproudlock
f6dcaef4c0 Persist list pagination and search in the URL
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
List pages kept the current page in local state, so clicking into an
asset and hitting Back remounted the list at page 1. A shared
useListQuery composable now mirrors the page (and search term) into the
URL query via router.replace across all 18 list pages, so Back restores
the page you were on and lists are deep-linkable. Page 1 with no search
stays a bare path; changing a filter resets to page 1; unrelated query
keys are preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:19:50 -04:00
cproudlock
b2e1827e5d Normalize the asset detail card layout across all five pages
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
One canonical card order applied to machines, PCs, printers, network,
and measuring tools: Identity -> type-specific -> status -> Location &
Organization -> domain -> Custom Fields -> Warranty -> Relationships ->
Notes -> audit footer, with a documenting comment on each page so they
stop drifting. The location card is Location & Organization everywhere;
the network detail page is rebuilt into the family (Asset Information
folded into Identity, Record Info retired for the standard audit
footer, a Location & Organization card added). Fixed two latent bugs
found in the process: printer detail had no audit footer, and the
network Record Info read datecreated/datemodified (not in the payload)
so its timestamps rendered blank - the footer now uses the correct
fields.

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

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

822 tests pass; both verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:01:20 -04:00
cproudlock
9daf1578a7 Balance the two-column card layout on detail pages
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 7s
The content grid hand-assigned cards to a fixed left/right split, so a
PC or machine with many tall cards (installed apps, warranty,
relationships) piled them all on one side. Switched .content-grid to a
balanced CSS multicolumn flow (display:contents flattens the wrappers
so no markup changes), with break-inside:avoid keeping cards intact.
Cards now distribute by height and the columns stay even on every asset
detail page.

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

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

810 tests pass; PC 259 installed apps verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:37:58 -04:00
cproudlock
68b6949f1c Print a single label at a chosen ULINE sheet cell
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
The single-label page gains an Output toggle (standalone vs place on a
ULINE 6-up sheet) with a 2x3 cell picker, so one label can be printed
into the correct physical position on a partially-used sheet - the
single-label equivalent of the batch page start-cell offset. Batch page
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:09:55 -04:00
231 changed files with 18393 additions and 3895 deletions

View File

@@ -79,3 +79,10 @@ ZABBIX_TOKEN=
# CMMC_USB_DB_USER=
# CMMC_USB_DB_PASSWORD=
# CMMC_USB_DB_NAME=cmmc_usb
# ---- Subpath deployment (optional) ----
# Serve the app under a URL prefix instead of the server root, e.g. as an IIS
# Application at /ops under an existing site. The frontend must be rebuilt with
# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the
# app owns its own site/port (the default). See docs/INSTALL-WINDOWS-IIS.md.
# MOUNT_PATH=/ops

View File

@@ -5,11 +5,19 @@
# for the host distro). The backend job uses the system python3 in a venv
# instead. setup-node works because node is resolved differently.
#
# Three jobs run on push and pull_request:
# backend - pytest (tests use in-memory SQLite via TestingConfig, so no
# database service is needed).
# naming - the CONTRIBUTING.md naming/style gate.
# frontend - Vue build.
# Jobs run on push and pull_request:
# backend - pytest (tests use in-memory SQLite via TestingConfig, so no
# database service is needed).
# naming - the CONTRIBUTING.md naming/style gate.
# frontend - Vue build.
# migrations-mysql - proves the REAL multi-site deploy path: a fresh
# `flask db upgrade` + per-plugin install on utf8mb4 MySQL
# from empty, idempotent on a second run. The pytest suite
# only exercises SQLite create_all(), so without this a
# regression in the Alembic chain on MySQL would ship
# undetected. Needs a runner that supports service
# containers; if yours does not, run these steps against a
# host MySQL instead.
name: CI
@@ -54,3 +62,60 @@ jobs:
npm ci
npm run build
working-directory: frontend
migrations-mysql:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shopdb_ci
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -uroot -proot"
--health-interval=5s --health-timeout=5s --health-retries=20
env:
DATABASE_URL: mysql+pymysql://root:root@127.0.0.1:3306/shopdb_ci?charset=utf8mb4
SECRET_KEY: ci-secret
JWT_SECRET_KEY: ci-jwt-secret
steps:
- name: Check out
uses: actions/checkout@v4
- name: Install dependencies
run: |
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
- name: Force utf8mb4 on the CI database
run: |
mysql -h 127.0.0.1 -uroot -proot -e \
"ALTER DATABASE shopdb_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
- name: Fresh core upgrade from empty
run: .venv/bin/flask db upgrade
- name: Install every bundled plugin (runs its chain)
run: |
for p in computers employees geenforce knowledgebase machines \
measuringtools network notifications printers slides usb warranty; do
.venv/bin/flask plugin install "$p"
done
- name: Assert schema built + utf8mb4, and a second upgrade is a no-op
run: |
.venv/bin/python - <<'PY'
from shopdb import create_app
from shopdb.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
insp = db.inspect(db.engine)
tables = insp.get_table_names()
assert len(tables) >= 70, f'only {len(tables)} tables built'
row = db.session.execute(text(
"SELECT default_character_set_name FROM information_schema.schemata "
"WHERE schema_name = 'shopdb_ci'")).first()
assert row[0] == 'utf8mb4', f'charset is {row[0]}, not utf8mb4'
print(f'OK: {len(tables)} tables, charset {row[0]}')
PY
- name: Second core upgrade must be a clean no-op
run: .venv/bin/flask db upgrade

1
.gitignore vendored
View File

@@ -75,3 +75,4 @@ secrets.yml
*_secret
*_secrets
credentials.json
scripts/site_imports/wjf/idmap.json

View File

@@ -10,6 +10,63 @@ ADR-007 and ADR-002.
## [Unreleased]
### Changed
- Asset detail pages (machines, PCs, printers, network devices, measuring
tools) now share one canonical card skeleton: Identity -> type-specific ->
status -> Location & Organization -> domain -> Custom Fields -> Warranty ->
Relationships -> Notes -> audit footer. The location card reads "Location &
Organization" on every page. The network device page was rebuilt into the
family (its "Asset Information" folded into Identity, "Record Info" converted
to the standard audit footer). Printer Notes moved out of mid-page to
second-to-last and the printer gained an audit footer. Template reordering
only; no data or API changes.
### Fixed
- List pages keep the current page (and search term) in the URL query, so
paging to page 9, opening an item, and hitting browser Back returns to page 9
instead of resetting to page 1. Applies to all 18 list views via a shared
useListQuery composable; page 1 with no search stays a bare path.
- PC detail Installed Applications no longer 500s and silently disappears on
real PCs (ComputerInstalledApp had no to_dict); the section renders app
name, version, and description again.
- Employee detail skips its USB panels when the usb plugin is disabled (no
more 404 console noise).
- Shopfloor kiosk header text is readable (light on the dark navy header).
### Added
- Collector-driven PC -> printer relationships. The computers collector schema
gained optional `defaultprinter` (string) and `printers` (array of strings)
fields carrying Win32_Printer identifiers. On ingest each identifier resolves
to a printer asset (by windows name / share / hostname / asset number-name or
a communications IP) and the PC is linked to it: the default via a
`defaultprinter` relationship, the rest via `connectedto`. The links render in
the shared Relationships card on both the PC and printer detail pages. The
sync is idempotent and archives collector-created links to printers no longer
reported (tagged `assetrelationships.label = 'collector:printers'`, so
manually-created links are never touched); unresolved identifiers become
response warnings, never failures. The collector response carries
`printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md.
- Searchable custom fields. Each custom-field definition gained a `searchable`
flag (Settings > Custom Fields). When on, that field's stored values are
matched by global search and a hit routes to the owning asset's detail page.
The asset's `search_<type>_enabled` domain toggle still applies, and matches
dedupe against built-in-field asset hits so an asset appears once. Inactive or
non-searchable fields are never matched.
- Single-label sheet-position printing. The single asset-label page
(`/print/asset-label/:assettype/:id`) gained an Output control that toggles
between the standalone label (unchanged default) and placing that one label at
a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the
other five cells blank. This prints a single label onto the correct physical
spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the
legacy shopdb behavior and complementing the batch page's start-cell offset.
The ULINE 6-up cell layout and dimensions are replicated from
`AssetLabelBatch.vue` (left untouched); encode resolution stays shared via
`assetLabel.js`.
## [0.7.0] - 2026-07-12
### Added
@@ -204,7 +261,7 @@ ADR-007 and ADR-002.
card on the application detail page and a new `settings/supportteams`
management page render them.
- Import mode: a complete, idempotent HTTP migration surface so a script or LLM
- Import mode: a complete, idempotent HTTP migration surface so a migration script
can import the classic ASP shopdb through the API alone (no direct DB writes).
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
`shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`,
@@ -248,7 +305,7 @@ ADR-007 and ADR-002.
- Audit log: hovering a user's SSO now shows their full name (best-effort,
resolved from the employee directory in either mode).
- Refreshed the internal status docs to match the code (CLAUDE.md active
- Refreshed the internal status docs to match the code (project active
state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
corrected the get_asset_panels endpoint path in the hook docstring, and
removed leftover debug console.log lines.
@@ -384,7 +441,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
- Multi-stage Docker build that compiles the Vue frontend and ships
`frontend/dist`, which Flask serves.
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
reconciled README, DEPLOY, CLAUDE, and ROADMAP.
reconciled README, DEPLOY, status docs, and ROADMAP.
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort
Gitea Actions CI (backend tests, naming/style gate, frontend build).

View File

@@ -21,12 +21,13 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
- 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
## 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.
## Current state (as of 2026-07-12)
## Current state (as of 2026-07-13)
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.
@@ -41,11 +42,12 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 808 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.10.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d23_user_mustchangepassword` (30 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty.
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script/LLM import the whole legacy DB (X-Import-Mode preserves timestamps).
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 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).
- 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 15 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
@@ -133,4 +135,4 @@ Each plugin must have:
- `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix
- `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods
- `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 `7d16_directoryemployees`). Run `flask db upgrade` to apply.
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes`). Run `flask db upgrade` to apply.

View File

@@ -2,8 +2,9 @@
#
# One image, one site. Per ADR-004, each adopting facility runs its own
# stack with its own DB, secrets, and enabled-plugin list. This image
# bundles all ten core plugins (computers, employees, equipment,
# knowledgebase, network, notifications, printers, slides, usb, warranty);
# bundles all eleven core plugins (computers, employees, knowledgebase,
# machines, measuringtools, network, notifications, printers, slides, usb,
# warranty);
# install them at runtime with `flask plugin install <name>`.
#
# The frontend is built in a first stage and its dist output is copied into

View File

@@ -7,14 +7,20 @@ A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Pyt
ShopDB tracks and manages:
- **Machines** - CNC equipment, CMMs, inspection systems, etc.
- **PCs** - Shopfloor computers, engineering workstations
- **Printers** - Network printers with Zabbix integration
- **Applications** - Software deployed across the shop floor
- **Printers** - Network printers with Zabbix supply integration
- **Network devices** - Switches, routers, and the subnet browser
- **Measuring tools** - Gage-lab instruments with calibration tracking
- **Applications** - Software deployed across the shop floor, with per-PC install tracking
- **Employees** - Directory, recognition and training notifications
- **Warranties** - Coverage records with Dell warranty lookups
- **USB devices** - CMMC check-in/out tracking
- **Knowledge Base** - Documentation and troubleshooting guides
- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance
## Tech Stack
**Backend:**
- Python 3.x with Flask
- Python 3.12 with Flask
- SQLAlchemy ORM
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
- JWT authentication
@@ -59,13 +65,13 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
### Database
- **Table names:** Lowercase, single word, no underscores or dashes
- Examples: `machines`, `pctypes`, `machinetypes`, `businessunits`
- Examples: `assets`, `computers`, `printers`, `businessunits`
- **Column names:** Lowercase, single word, no underscores or dashes
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
- Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate`
- **Foreign keys:** Referenced table name + `id`
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
- Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid`
- **Boolean columns:** Prefixed with `is` or `has`
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `islicenced`
- Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced`
### Code
@@ -77,9 +83,9 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
### API
- **Endpoints:** Lowercase, plural nouns
- Examples: `/api/machines`, `/api/pctypes`, `/api/locations`
- Examples: `/api/machines`, `/api/computers`, `/api/locations`
- **Query parameters:** Lowercase, single word
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
- Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer`
## Style Guidelines
@@ -92,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
### Prerequisites
- Python 3.8+
- Python 3.12
- Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
@@ -102,7 +108,7 @@ SQLite). Do not run dev or production against SQLite.
### Distribution
The application is distributed internally through the GE Aerospace Gitea. Clone
The application is distributed through the internal GE Aerospace git server. Clone
it from there; there is no public package or image registry.
### Fast path (Docker)
@@ -158,12 +164,10 @@ npm run build # production build into frontend/dist (served by Flask)
Complete first-run setup at `/setup`, or run `flask seed admin` for a headless
admin account.
To import data from the legacy ShopDB MySQL database (one-time, see
`migrations/DATA_MIGRATION_GUIDE.md`):
```bash
python scripts/import_from_mysql.py
```
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
through documented endpoints (`X-Import-Mode` preserves original timestamps).
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
@@ -192,31 +196,36 @@ The REST API follows standard conventions:
| PUT | `/api/machines/:id` | Update machine |
| DELETE | `/api/machines/:id` | Soft delete machine |
Each asset plugin exposes the same CRUD pattern on its own prefix
(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`),
and cross-cutting asset endpoints live under `/api/assets`.
Query parameters for list endpoints:
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 25)
- `perpage` - Items per page
- `sort` - Sort field
- `order` - Sort direction (asc/desc)
- `dir` - Sort direction (asc/desc)
- `search` - Search term
- `type` - Filter by asset type (computer, printer, machine, network_device)
- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool)
## Plugin System
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles eleven plugins; only the ones a site installs are loaded:
The image bundles twelve plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
- **employees** - Employee directory
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
- **machines** - CNC, CMM, and other shop-floor machines
- **measuringtools** - Gage-lab instruments with calibration tracking
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices
- **network** - Network devices and subnets
- **notifications** - Shopfloor notifications and recognition feed
- **printers** - Extended printer management with Zabbix integration
- **slides** - TV/kiosk slideshows
- **usb** - CMMC USB check-in/out tracking
- **warranty** - Dell warranty lookups
- **warranty** - Warranty records with Dell lookups
## Legacy Migration

View File

@@ -28,7 +28,7 @@
<httpPlatform
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 wsgi:app"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
stdoutLogEnabled="true"
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
startupTimeLimit="120"
@@ -38,6 +38,13 @@
config (SQL echo, debug, wrong DB URL). Real secrets go in .env. -->
<environmentVariable name="FLASK_ENV" value="production" />
<environmentVariable name="PYTHONPATH" value="C:\shopdb-flask" />
<!-- Subpath method only: when this web.config sits in an IIS
Application (e.g. /ops) under an existing site instead of its own
site, tell the app its mount path. Must match the alias the
Application was created with AND the VITE_BASE_PATH the frontend
was built with ('/ops/'). Omit for the own-site method.
<environmentVariable name="MOUNT_PATH" value="/ops" />
-->
</environmentVariables>
</httpPlatform>

View File

@@ -209,10 +209,38 @@ a column.
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
| `defaultprinter` | string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a `defaultprinter` relationship. Unresolved -> warning. |
| `printers` | array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a `connectedto` relationship (the default is skipped here since it already links as `defaultprinter`). Unresolved entries -> warning. |
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
If you change the payload, change it there and re-check this table.
### PC -> printer relationship sync
When a payload carries `defaultprinter` and/or `printers`, the collector syncs
`AssetRelationship` rows so a PC page shows its printers and a printer page shows
the PCs that use it (both render in the shared Relationships card).
- Resolution: each identifier is matched, first hit wins, against the printer's
`windowsname`, `hostname`, `sharename`, its asset number/name, then any active
printer communications IP. Case-insensitive except the IP (exact). An
identifier that resolves to nothing adds a warning and is skipped; it never
fails the whole push.
- Link types: the default printer links with `defaultprinter` (directional, PC
is the source); every other reported printer links with `connectedto`
(symmetric). A printer that is both default and in `printers` links only as
the default.
- Idempotent: re-reporting the same set creates no duplicate rows (an existing
matching row is reactivated if it was archived, otherwise left as is).
- Stale-link archive: on every push, collector-created links to printers no
longer reported are set inactive. Collector-created rows are tagged in
`assetrelationships.label = 'collector:printers'`; only tagged rows are ever
archived, so links you create by hand in the UI are never touched. A payload
that omits BOTH printer keys leaves all existing printer links untouched
(report an empty `printers: []` to clear the auto links instead).
- Response: the collector response carries `printerlinkcount` and a
`printerlinks` list of `{assetid, relationshiptype}` for the links kept.
### pc-type mapping (configurable per site)
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
@@ -487,8 +515,32 @@ function Send-ShopdbCollectorReport {
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
# --- Installed printers (Win32_Printer). The Default flag marks the one
# default printer. We report each printer's port name (an IP or a queue
# host for network printers) and fall back to the share/printer name, which
# the collector resolves flexibly against printer windowsname/hostname/IP. ---
$defaultPrinter = ''
$printerIds = @()
try {
$printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop
foreach ($p in $printers) {
if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote)
# Prefer the port name (IP or queue host); fall back to ShareName,
# then the printer Name.
$identity = $p.PortName
if (-not $identity) { $identity = $p.ShareName }
if (-not $identity) { $identity = $p.Name }
if (-not $identity) { continue }
$printerIds += $identity
if ($p.Default) { $defaultPrinter = $identity }
}
$printerIds = @($printerIds | Select-Object -Unique)
} catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" }
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname }
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }

View File

@@ -319,6 +319,15 @@ One boolean key per search domain, keyed `search_<type>_enabled` (default
`true`). Toggles whether a domain appears in global search results. The set is
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
## Custom fields
Site-defined extra attributes per asset type (Settings > Custom Fields, table
`customfields`). Each field has a `searchable` flag (default off). When on, the
field's stored values are matched by global search and a hit routes to the
owning asset's detail page. The asset's `search_<type>_enabled` domain toggle
still applies, so a custom-field hit on a computer only shows when the computer
search domain is enabled. Inactive or non-searchable fields are never matched.
---
## See also

View File

@@ -128,6 +128,13 @@ to reproduce the exact enabled set, then just run `flask plugin upgrade-all`.)
## 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

126
docs/GE-ENFORCE-CLIENT.md Normal file
View File

@@ -0,0 +1,126 @@
# GE-Enforce client integration (shopdb manifest source + reporting)
This is the client-side contract for the GE-Enforce manifest-store plugin: how a
PC sources its install manifest from shopdb instead of a share file, and how it
reports its enforcement result back. It pairs with the plugin proposal in
`docs/proposals/ge-enforce-plugin.md`.
The reference kit lives in `plugins/geenforce/client/`:
- `ShopdbEnforceClient.psm1` - fetch (with ETag + last-known-good cache),
shadow compare, and report helpers.
- `Invoke-ShopdbEnforce.ps1` - a reference orchestrator that fetches a manifest,
runs the UNCHANGED engine against it, and reports the result.
These are site-neutral references, not the live dispatcher. A site adapts them
into its GE-Enforce.ps1 flow. The engine (`Install-FromManifest.ps1`),
detection, self-heal, and SMB payload resolution are untouched - only the source
of the manifest JSON moves, plus a result report.
## What does NOT change
- The engine and its four filters, all detection methods, self-heal, marker
files, and SMB payload staging.
- Payload transport for `smb` rows: the client still mounts the share and
resolves `apps/...` paths exactly as today. Only the manifest JSON source moves.
- The fail-safe posture: any error exits 0. A PC is never blocked or broken
because shopdb is unreachable.
## Configuration
Registry (provisioned by Azure DSC, same channel as the SFLD credentials):
```
HKLM:\SOFTWARE\GE\ShopDB
BaseUrl https://shopdb.<site>.geaerospace.net
ApiToken <a geenforce.fetch (+ geenforce.report) managed service token>
```
Mint the token in shopdb: Settings > API Tokens, scopes `geenforce.fetch` and
`geenforce.report`. It is a service token (owner must hold those permissions).
## Fetch contract
```
GET /api/geenforce/manifest?pctype=<scope>[&phase=runtime]
X-API-Key: <token>
If-None-Match: <cached ETag> (optional)
```
- `200` - body is the full published manifest JSON for the scope (fat client:
the engine filters locally, exactly as today). Response headers carry `ETag`
and `X-Manifest-Version`. Cache the body + ETag + version.
- `304` - your cached copy is current; use it.
- `404` - no such scope, or the scope has no published version yet.
- Network failure - enforce from the last-known-good cached manifest (the kit
does this automatically) and log a warning.
The served manifest is always the current PUBLISHED snapshot, never a live draft
being edited in shopdb, so a half-finished edit can never reach a PC.
## Report contract
Each enforcement cycle, POST the result (best-effort; a failed report never
fails the cycle):
```
POST /api/geenforce/report
X-API-Key: <token>
Content-Type: application/json
{
"hostname": "WJCMM01",
"scopename": "gea-shopfloor-cmm",
"appliedversion": 3, // the published version you actually ran
"enforcerversion": "2.6",
"counts": { "installed": 1, "skipped": 3, "failed": 0, "filtered": 2 },
"results": [
{ "name": "PC-DMIS 2019 R2", "action": "installed", "selfhealed": true },
{ "name": "Protect Viewer", "action": "skipped" },
{ "name": "eDNC", "action": "failed", "exitcode": 1603,
"message": "MSI 1603" }
]
}
```
- `appliedversion` lets shopdb show which PCs received the latest manifest
(`receivedlatest` in the fleet view).
- `action` per entry: `installed` (fired - a self-heal when it should already be
present), `skipped` (detected present), `failed`, `filtered`. `selfhealed`
marks a drift correction.
- shopdb keeps the latest report per (hostname, scope, phase) plus history, and
surfaces it under Settings > Enforcement Reports.
The engine already computes these counts (`installed/skipped/failed/pcFiltered`
at the end of its main loop) and knows each entry's action; shape them into the
`results` list at the call site (`New-ShopdbReport` in the kit takes a summary
with `Installed/Skipped/Failed/Filtered` + a `Results` list).
## Cutover (safe, staged)
1. **Configure** the registry values on a canary PC; mint the token.
2. **Shadow mode**: run `Invoke-ShopdbEnforce.ps1 -ShadowMode -ShareManifestPath
<current share manifest>`. It installs from the SHARE (no behavior change),
fetches the shopdb manifest, logs any diff, and reports. Watch for zero diffs
across one PC of every pctype for ~20 cycles.
3. **Read cutover**: drop `-ShadowMode`. The engine now runs against the
shopdb-sourced manifest; payloads still come from the share. Rollback is a
one-line revert to the share-sourced call. Keep exporting manifests from
shopdb to the share (Settings > Imaging PC Types > Export to Share) so the
share stays a break-glass copy.
4. **Payload migration** (optional, later): move small scripts/configs to
`http`/`inline` payloads, verified by `payloadsha256`. Big MSIs stay on SMB.
Do not cut a fleet over before the shadow diffs are clean. Preinstall
(`phase=preinstall`) stays share-sourced until its own cutover is planned - it
runs before enrollment provisions a token.
## Security notes
- The client runs as SYSTEM, so shopdb's TLS certificate must be in the machine
trust store (air-gapped/self-signed sites provision the CA via the same DSC
step as the token).
- `http`/`inline` payloads are verified against `payloadsha256` before running,
independent of how the entry detects install state. This is the real integrity
guarantee and holds even over plain HTTP inside a trusted segment.
- The token is a scoped service token: it can fetch manifests and report, and
nothing else.

135
docs/GE-ENFORCE-DEPLOY.md Normal file
View File

@@ -0,0 +1,135 @@
# Deploying the GE-Enforce agent on a PC
This is the deploy contract: what has to be laid down on a PC so GE-Enforce runs,
and how to do it regardless of imaging path (PXE, OOBE provisioning package,
Intune, or by hand). It complements `docs/GE-ENFORCE.md` (concepts) and
`docs/GE-ENFORCE-CLIENT.md` (the fetch/report contract).
The reference installer is `plugins/geenforce/client/Install-GEEnforce.ps1`. It
is site-neutral: you pass the PC's identity in, it writes the files/registry the
engine reads and registers the enforcement task.
---
## 1. What "deploying GE-Enforce" means
A PC needs three things present before enforcement works. HOW they get there is
up to your imaging path; WHAT they are is fixed:
1. **The GE-Enforce client** - the engine (`Install-FromManifest.ps1`), the
shopdb client kit (`ShopdbEnforceClient.psm1`, `Invoke-ShopdbEnforce.ps1`),
and a scheduled task (at logon + periodic) that runs as SYSTEM.
2. **Identity** in `C:\Enrollment` - so the PC knows what it is (see section 2).
3. **A credential** - the SFLD share credential (for a share-sourced manifest)
and/or the shopdb service token (for the fetch/report client). This is what
gates enforcement actually starting; until it exists the task exits 0 and
retries.
`Install-GEEnforce.ps1` lays down 1 and 2, and can write the shopdb token for 3.
The engine itself is the GE-Enforce framework's, not shopdb's - point the
installer at your copy with `-EngineSource`, or place it under the install root
first (see section 5).
---
## 2. Identity: how a PC determines its PC type (and bay)
There is NO auto-detection. The provisioner supplies the values; the engine only
reads files. This is the core of "set the PC up to know its type."
| Value | Written to | Purpose | Required? |
|---|---|---|---|
| **PC type** | `C:\Enrollment\pc-type.txt` (first line) | picks the manifest scope (`gea-shopfloor-<type>`) | YES |
| Machine (bay) number | `C:\Enrollment\machine-number.txt` (fallback; DNC registry `MachineNo` wins) | per-bay gates | only for bay-gated entries |
| CMM version | `C:\Enrollment\cmm\version.txt` | `_CmmVersion` gating (CMM PCs) | CMM only |
| CMM bay id | `C:\Enrollment\cmm\cmmid.txt` | CMM bay identity | CMM only |
| Share root + site | `C:\Enrollment\site-config.json` | where manifests/payloads live | for share-sourced |
| shopdb URL + token | `HKLM:\SOFTWARE\GE\ShopDB` (BaseUrl, ApiToken) | fetch/report client | for shopdb client |
Valid `pc-type` values are the manifest scope names
(`gea-shopfloor-cmm`, `-collections`, `-nocollections`, `-common`, `-keyence`,
`-genspect`, `-heattreat`, `-partmarker`, `-waxtrace`) or a legacy alias the
engine maps (`Standard`, `CMM`, ...).
**shopdb cannot set the type at imaging** - a PC is not known to shopdb until it
enrolls and reports. If you want the value to come from an asset system, pre-map
asset-tag / hostname -> PC type in your provisioning and feed it to the
installer.
---
## 3. Running it, per imaging path
`Install-GEEnforce.ps1` is the same in every case; only how you invoke it differs.
### PXE / imaging step (identity known at image time)
Run it as an imaging step after the OS lays down, passing the type the operator
selected:
```
powershell -ExecutionPolicy Bypass -File Install-GEEnforce.ps1 `
-PCType gea-shopfloor-cmm -MachineNumber 0615 -CmmVersion 2019 `
-ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
-EngineSource \\server\share\dt\shopfloor\common
```
### OOBE provisioning package (ppkg)
Sites that apply a ppkg during OOBE (no PXE/WinPE step) embed the installer + the
client kit in the ppkg and run it from a `CommandLine` / `ProvisioningCommands`
action. Supply the PC type from a ppkg variable, a first-boot prompt, or an
asset lookup:
```
powershell -ExecutionPolicy Bypass -File Install-GEEnforce.ps1 -PCType %PCTYPE% ...
```
Timing is forgiving: the scheduled task is fail-safe, so if OOBE finishes before
Intune/DSC provisions the credential, enforcement simply waits and starts once
the credential lands. There is no ordering trap.
### Intune / manual
Same script as a Win32 app / remediation, or run by hand on an existing PC to
retrofit it. `-NoTask` provisions identity + kit without registering the task.
---
## 4. What the installer does (idempotent)
1. Writes the `C:\Enrollment` identity files (section 2).
2. Writes `HKLM:\SOFTWARE\GE\ShopDB` (BaseUrl + token) if provided.
3. Copies the client kit (the two files shipped next to it) to `-InstallRoot`
(default `C:\ProgramData\GE-Enforce`).
4. If `-EngineSource` is given, copies `GE-Enforce.ps1` + `lib\Install-FromManifest.ps1`.
5. Registers the scheduled task (SYSTEM, at logon + every `-IntervalMinutes`) to
run `Invoke-ShopdbEnforce.ps1 -Scope <PCType> -EnginePath <engine>`.
Re-running it updates identity/config and re-registers the task in place.
---
## 5. The engine boundary
shopdb ships the **manifest store + client kit + this installer**, not the
GE-Enforce **engine** (`Install-FromManifest.ps1`) or dispatcher - those live in
the GE-Enforce framework. So one of:
- pass `-EngineSource <path>` pointing at a folder that has `GE-Enforce.ps1` and
`lib\Install-FromManifest.ps1` (e.g. your share's `common` dir), or
- place the engine under `<InstallRoot>\lib\Install-FromManifest.ps1` yourself
before enforcement runs.
The installer warns if the engine is missing but still provisions identity so a
PC is at least correctly labelled. Use engine lib >= 2.6 (required for the
`_CmmVersion` gate).
---
## 6. Verify a provisioned PC
- `Get-Content C:\Enrollment\pc-type.txt` -> the expected scope.
- `Get-ItemProperty HKLM:\SOFTWARE\GE\ShopDB` -> BaseUrl + ApiToken set.
- `Get-ScheduledTask GE-Enforce` -> Ready.
- Trigger it once and check the client log
(`C:\Logs\Shopfloor\shopdb-enforce-*.log`), then confirm the PC appears under
**GE-Enforce > Enforcement Reports** in shopdb with the right PC type.

329
docs/GE-ENFORCE.md Normal file
View File

@@ -0,0 +1,329 @@
# GE-Enforce: concepts, the shopdb plugin, and imaging-time integration
This guide explains how GE-Enforce works, how the shopdb `geenforce` plugin
manages it, and when GE-Enforce installs and takes over during the imaging
process. It is written for site IT.
It pairs with two companion docs:
- `docs/GE-ENFORCE-CLIENT.md` - the client fetch/report contract + the reference
PowerShell kit (`plugins/geenforce/client/`).
- `docs/proposals/ge-enforce-plugin.md` - the design/plan and the staged cutover.
The ground truth for behavior is the engine itself
(`Install-FromManifest.ps1`) and the on-share manifests; this guide describes
what they do, it does not replace them.
---
## 1. What GE-Enforce is
GE-Enforce is a **desired-state enforcement** system for shopfloor PCs. Instead
of a one-time install during imaging, it continuously makes each PC match a
declared list of what should be installed - and RE-installs anything that drifts
(uninstalled, corrupted, or overwritten). It is the shopfloor equivalent of a
lightweight, air-gapped-friendly configuration-management agent.
Two things make up the system:
1. **The engine + dispatcher on each PC** - PowerShell that reads a manifest and
enforces it every logon and periodically.
2. **The manifests** - JSON files that declare, per imaging PC type, what to
install / copy / write and how to detect whether it is already correct.
The shopdb `geenforce` plugin adds a third piece: it lets you **author, publish,
and version those manifests in shopdb** (instead of hand-editing JSON on a file
share) and **see what every PC actually did** (fleet compliance reporting).
---
## 2. How GE-Enforce works (the framework)
### 2.1 Two phases
Every shopfloor PC is governed in two distinct phases:
| Phase | When | Runs what | Purpose |
|---|---|---|---|
| **Preinstall** | ONCE, at imaging | `preinstall.json` (via the imaging `00-PreInstall` step) | Day-zero foundation: PowerShell 7, the VC++ redistributable matrix, Oracle Client, Adobe Reader, HostExplorer, serial drivers, etc. "Install once at imaging, no drift correction." |
| **Runtime** | EVERY logon + periodically | `common/manifest.json`, then `gea-shopfloor-<type>/manifest.json`, then an optional `<type>-<subtype>` manifest | Ongoing enforcement + self-heal: app versions, config-file drift, registry drift, per-cycle scripts (asset report, VNC firewall, EventSaver), version-gated installs. |
The two phases share the same entry SHAPE (field names) but are run by different
runners with different capabilities. Preinstall is a one-shot at imaging that
implements only `Type=MSI` and `Type=EXE`, with only `Registry` / `File`
detection (other types/detections are skipped). Runtime is the continuous
enforcement loop and implements the full Type + DetectionMethod matrix below.
### 2.2 The runtime loop, step by step
On each cycle (`GE-Enforce.ps1` on the PC):
1. Read the PC's identity from `C:\Enrollment\` (see 2.4).
2. Look up the SFLD share credential in the registry and **mount the share**
(SYSTEM cannot reach the share as its computer account, so it mounts as the
provisioned SFLD user - `net use W: ...`). If no credential yet, exit 0 and
retry next cycle (Azure DSC has not provisioned it).
3. Run the engine (`Install-FromManifest.ps1`) against `common/manifest.json`,
then `gea-shopfloor-<pctype>/manifest.json`, then a `<pctype>-<subtype>`
manifest if one exists. Common runs first so shared prerequisites (e.g.
Oracle Client) land before type-specific apps that depend on them.
4. Write a status file back to the share (and, in the shopdb model, POST a
report - see 4.3).
Every failure is non-fatal (exit 0) so a network blip or a not-yet-provisioned
credential never blocks or breaks a PC.
### 2.3 The manifest: scopes and entries
A manifest is `{ "Version", "_comment", "Applications": [ entry, ... ] }`. Each
imaging PC type is a **scope** with its own manifest, plus the fleet-wide
`common` scope:
- `common` - runs on EVERY PC type; entries use a `PCTypes` filter to target
subsets (e.g. "EventSaver on collections + heattreat, but not CMM").
- `gea-shopfloor-collections`, `-nocollections`, `-cmm`, `-keyence`, `-common`
(lab/timeclock), `-genspect`, `-heattreat`, `-partmarker`, `-waxtrace` - each
runs only on PCs of that type, so its entries usually do NOT set `PCTypes`
(the manifest already only runs there). Keyence is the exception: it uses
`PCTypes` for hardware SUBTYPE targeting (`keyence-vr6000` vs `keyence-vr3000`).
Each **entry** declares one action. Its `Type` picks the action:
| Type | Action |
|---|---|
| MSI / EXE / CMD / BAT | run an installer with `InstallArgs` |
| PS1 | run a script from the share |
| INF | install a driver via `pnputil` |
| File | copy `Source` -> `Destination` |
| Registry | write a value |
### 2.4 Self-heal via detection
Every entry has a `DetectionMethod` that decides whether the action fires:
| Method | Means "already correct" when... |
|---|---|
| Registry | the key/value exists (optionally equals a value) |
| File | the file exists |
| FileVersion | the file's version string matches exactly (fleet convention is a 4-part string like 6.4.5.0; the engine does a raw string compare, it does not enforce 4 parts) |
| Hash | the file's SHA256 matches (case-insensitive) |
| MarkerFile | a marker file exists (the engine writes it after a clean install) |
| ValueMatches | a registry value equals the entry's target |
| pnputil | a driver matching a pattern is present |
| Always / (none) | fires EVERY cycle (used for per-cycle scripts) |
If detection says "not correct," the action runs. That is the self-heal: delete
`DncMain.exe` and next cycle re-installs eDNC; corrupt a config file whose Hash
no longer matches and next cycle re-copies it. **Entry order is execution
order** - config-restore entries sit AFTER their installer so a mid-cycle vendor
overwrite is healed on the same cycle.
### 2.5 Targeting gates (all ANDed)
An entry can be narrowed by any combination of:
- `PCTypes` - which PC types (alias-aware: old names like `Standard` map to
`collections`/`nocollections`/`common`). Fleet-wide `common` uses this heavily.
- `TargetHostnames` - specific hostnames (supports `*` wildcards).
- `TargetMachineNumbers` - specific bay machine numbers (e.g. Okuma bays).
- `_CmmVersion` - CMM PCs only: a tagged entry applies when it equals the bay's
resolved PC-DMIS version (`C:\Enrollment\cmm\version.txt`). IMPORTANT: if no
version is resolved (file missing/empty - a pre-picker bay), ALL tagged
entries apply (deliberate legacy "install-all" behavior), so such a bay gets
every PC-DMIS version, not none. Requires engine lib >= 2.6.
- `PCTypesStrict` - disables alias expansion (PREINSTALL runner only; the runtime
engine ignores it).
Different PC types have different niche gates: CMM uses a version gate, Keyence a
model subtype, Collections per-bay machine numbers. The shopdb editor shows only
the gates a given scope actually uses (see 4.1).
### 2.6 What the PC needs to know about itself (enrollment)
The runtime engine reads the PC's identity from `C:\Enrollment\`:
- `pc-type.txt` - the imaging PC type (which scope to run). `pc-subtype.txt` is
LEGACY (no longer written at imaging since the 2026-05-04 rename reorg; the
dispatcher still honors it if present on older fleet PCs).
- `machine-number.txt` - the bay number FALLBACK; the eDNC/DNC registry
`MachineNo` value wins if present. `9999` is the imaging placeholder by
convention - the enforcement engine does NOT special-case it; it is simply a
value that won't match a real bay number in a `TargetMachineNumbers` gate.
(The 9999-skip you may see is only in the status write-back, not enforcement.)
- `cmm/version.txt` - CMM bays only: the resolved PC-DMIS version for `_CmmVersion`.
- `site-config.json` - the share root and site settings.
- SFLD credentials at `HKLM:\SOFTWARE\GE\SFLD\Credentials` - provisioned by
Azure DSC after enrollment (this is what gates the runtime phase starting).
Note: all of the identity files above (pc-type, machine-number, cmm version,
site-config) are written in WinPE at the PXE menu, BEFORE the image boots - the
preinstall phase already reads them. What happens post-imaging is only Intune
enrollment + the Azure DSC credential (see the timeline below).
---
## 3. When GE-Enforce installs / takes over (the imaging timeline)
This is the "when to implement it during imaging" question. The order is:
```
[0] WinPE / PXE menu (BEFORE the image boots)
- identity written to C:\Enrollment: pc-type.txt, machine-number.txt,
cmm/version.txt, site-config.json (startnet.cmd). The PC already knows
what it is before Windows starts.
|
v
PXE image applied, Windows boots
|
v
[1] PREINSTALL (00-PreInstall runner runs preinstall.json ONCE)
- reads the step-0 identity files, then installs the foundation:
PowerShell 7, VC++ redists, Oracle Client, Adobe Reader, HostExplorer,
serial drivers, Display kiosk app, ... (things later runtime apps need)
- preinstall implements MSI/EXE + Registry/File detection only
|
v
[2] GE-ENFORCE ITSELF is laid down during imaging
- the dispatcher (GE-Enforce.ps1), the engine lib (Install-FromManifest.ps1),
and a scheduled task (at-logon + every ~5 min + shift windows) are
registered as part of the image / shopfloor setup
|
v
[3] ENROLLMENT (post-imaging)
- Intune / GCCH enrollment, THEN Azure DSC provisions the SFLD share
credential into HKLM:\SOFTWARE\GE\SFLD\Credentials
- (the identity files already exist from step 0 - enrollment adds only the
credential, which is what unblocks runtime)
|
v
[4] FIRST LOGON -> RUNTIME ENFORCEMENT BEGINS
- the scheduled task runs GE-Enforce.ps1: mount share, run common + the
PC-type (+ subtype) manifests, install/self-heal, report
- repeats every logon + periodically forever after
```
Key points on timing:
- **Preinstall (step 1) is the imaging-time install.** Put anything that must
exist before first logon, or that never needs drift correction, here (runtimes,
redistributables, drivers). It runs once and is done.
- **Runtime enforcement (step 4) does not start until enrollment (step 3)
provisions the SFLD credential.** Before that, GE-Enforce exits 0 each cycle
and waits. So a freshly imaged PC that is not yet enrolled is inert, by design.
- **The engine lib version matters.** `_CmmVersion` gating needs lib >= 2.6 on
the PC; deploy the lib before a manifest that uses it.
- Some apps appear in BOTH phases: preinstalled at imaging for day-zero, then
carried by a runtime entry so drift is corrected later (Oracle, UDC, Adobe,
HostExplorer, Defect Tracker).
Rule of thumb: **imaging-time (preinstall) = foundation that must be there or
never drifts; runtime = everything that needs to stay correct over the PC's
life.**
---
## 4. How the shopdb plugin manages this
The `geenforce` plugin turns the manifest from hand-edited JSON on a share into
shopdb data you author, version, publish, and monitor. It lives under the
top-level **GE-Enforce** section (Manifests | Enforcement Reports), not Settings,
because it is a full management surface.
### 4.1 Manifests - authoring (GE-Enforce > Manifests)
- **PC Types (scopes):** each imaging PC type is a row; add/edit/delete. (The
scope carries an optional `computertypeid` reference field, but the collector's
imaging-pc-type -> ComputerType mapping is configured separately at
Settings > Collector PC Types.)
- **Entries:** an ordered list (Up/Down = the execution-order contract). Add/Edit
opens a typed form: the payload fields switch on `Type` (MSI shows Installer +
InstallArgs, PS1 shows Script + Args, File shows Source + Destination, Registry
shows the Reg* fields), a detection block, an InUseCheck editor, and a
**Targeting** section that shows only the gates the scope uses (CMM shows the
version gate; the common/preinstall scopes show PC types; a scope whose entries
use machine numbers shows those) with a "Show all targeting options" escape
hatch.
- **Simulate ("what would a PC get?"):** enter a PC profile (type, subtype,
hostname, machine number, CMM version) and see which entries apply and why the
rest are filtered - without reading a PowerShell log.
- **Publish / Versions / Roll Back:** editing changes a DRAFT only. Publish
freezes an immutable version; PCs are only ever served the published version;
Roll Back restores an earlier one. History (date, author, note) per version.
### 4.2 Milestone 1 - export to the share (engine unchanged)
Today the enforcement engine still reads manifests from the SFLD share. The
plugin's **Export to Share** button writes the current published manifest to
`<shareroot>/<scope>/manifest.json` (backing up the old file to `_meta/history`
first). So the workflow is:
**author + publish in shopdb -> Export to Share -> the unchanged engine picks it
up next cycle.**
Nothing about the engine, the share layout, or the PCs changes. Rollback is
restoring the `_meta/history` backup (or re-publishing an older version and
re-exporting). This is the safe first milestone: all the authoring benefit, zero
client risk.
Configure the share root once at the top of the Manifests page.
### 4.3 Enforcement Reports - fleet compliance (GE-Enforce > Enforcement Reports)
Each PC reports its enforcement result back to shopdb (see the client kit). The
Reports page shows, per PC:
- **Received** - did the PC apply the latest published version? (applied vs
latest). "behind" means it has not picked up your newest publish yet.
- **Status** - `ok` (nothing needed), `selfhealed` (drift corrected), `failed`.
- **Counts** - installed / skipped / failed, plus per-entry detail (action,
self-heal flag, exit code, message) in the row's Detail view.
This is the observed-state half of the loop: the manifest is what SHOULD be
installed; the report is what each PC ACTUALLY did.
### 4.4 The client side (per PC)
The engine sources the manifest and reports results using the reference kit in
`plugins/geenforce/client/` (`ShopdbEnforceClient.psm1` +
`Invoke-ShopdbEnforce.ps1`), configured from `HKLM:\SOFTWARE\GE\ShopDB`
(BaseUrl + a `geenforce.fetch`/`geenforce.report` service token). See
`docs/GE-ENFORCE-CLIENT.md` for the fetch/report contract, the last-known-good
cache, shadow mode, and the staged cutover from share-sourced to shopdb-sourced
manifests. Until that cutover, the client only REPORTS; the manifest still comes
from the share via Export to Share (4.2).
---
## 5. Day-to-day: common tasks
All in GE-Enforce > Manifests. No PowerShell, no editing JSON on the share.
- **Add an app to a PC type:** open the PC type, Add Entry, pick the Type (the
form adapts), fill the installer + detection + any targeting, place it in order
with Up/Down (config restores go BELOW their installer), Preview, Publish, then
Export to Share.
- **Bump an app version:** drop the new installer in the scope's `apps/` folder
on the share, open the entry, update the Installer filename + the Detection
value (the new version), Publish, Export to Share. PCs self-heal next cycle.
- **Roll back a bad publish:** the PC type's Versions list -> Roll Back to the
last good version -> Export to Share.
- **Canary a risky change:** add the one test PC under Target hostnames (via
"Show all targeting options"), Publish; when happy, remove the filter and
Publish again.
- **Check "did PC Y get app X":** use Simulate with that PC's type / machine
number / CMM version; and check Enforcement Reports for what it actually did.
---
## 6. Reference
- Engine (behavior ground truth): `Install-FromManifest.ps1` (lib >= 2.6).
- Dispatcher: `GE-Enforce.ps1` (mount + run common then type scope).
- Preinstall runner: `00-PreInstall-*` over `preinstall.json` (imaging-time).
- shopdb model + API: `plugins/geenforce/` (models, importer/serializer,
filters mirror, service, routes).
- Behavioral parity gate (proves the shopdb model round-trips the real
manifests): `plugins/geenforce/parity.py` + `flask geenforce parity`.
- Client kit + contract: `plugins/geenforce/client/`, `docs/GE-ENFORCE-CLIENT.md`.
- Agent deployment (per PC, any imaging path): `docs/GE-ENFORCE-DEPLOY.md` +
`plugins/geenforce/client/Install-GEEnforce.ps1`.
- Design + cutover plan: `docs/proposals/ge-enforce-plugin.md`.

71
docs/IMPORT-ADOPTION.md Normal file
View File

@@ -0,0 +1,71 @@
# Importing a site's legacy data
Every adopting site has its own source database - it will not match another
site's schema. So the import is split in two layers:
1. **The import API is the stable contract** (`docs/IMPORT-API.md`). Whatever
your source looks like, you create flask records through the same documented
REST endpoints, authenticated with an admin PAT and the `X-Import-Mode`
header (which preserves legacy timestamps). This layer is the product; it is
schema-agnostic.
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.
The West Jefferson loader in `scripts/site_imports/wjf/` is reference
implementation #1. Read it alongside this guide.
## The shape of a loader
- `harness.py` - builds the app against the target `DATABASE_URL`, mints an
unscoped admin PAT in-process, and drives the real endpoints through the app
test client with `Authorization: Bearer <pat>` + `X-Import-Mode: true`. This
exercises the same routes/authz/validation an HTTP client would, no running
server needed. It also holds read-only access to the source DB and a JSON
`IdMap` of legacy-id -> new-id crosswalks.
- `run.py` - ordered `stage_*` functions. Each reads a slice of the source,
POSTs it, and records the crosswalk later stages resolve foreign keys against.
### Stage order matters
Reference/lookup tables first (so foreign keys resolve), then the entity hub,
then dependents, then links:
```
reference -> catalog -> assets (persist the source-id -> assetid crosswalk)
-> dependents (installs, warranties, notifications, ...) -> relationships
```
The **crosswalk is the keystone**: capture every legacy id -> new id as you
create rows, and resolve foreign keys through it in later stages. New
autoincrement ids will not match the source's.
## Producing the mapping
You do not have to hand-derive the source -> target mapping. Point the
agent-assisted workflow at a source database plus this API contract and it emits
a per-table mapping (source columns -> endpoint fields, transforms, what is
importable vs out of scope) and a loader skeleton. That is the repeatable
onboarding path.
## Running (against a THROWAWAY import database)
1. Build a fresh target: `flask db upgrade` + `flask plugin upgrade-all` +
`flask seed permissions/settings/reference-data`. Enable every bundled plugin
you need (some ship disabled; a plugin's routes only register when it is
enabled at app start).
2. Load your source dump into a scratch DB the loader can read.
3. Run the loader stages in order, dry-running / spot-checking as you go.
4. Verify: row-count + foreign-key-resolution audit against the source, then a
UI spot-check (log in, eyeball the lists / map / a detail page).
5. Only then point a real instance at the imported database.
## What the WJ loader demonstrates
- Fanning one legacy "machine" table out to the flask asset types
(computer/machine/network/measuring-tool) by a routing rule, with the
duplicate/placeholder/skip decisions applied.
- Synthesizing a natural key when the source lacks one (printers -> `PRN-{id}`).
- Folding a primary IP onto an asset, pairing a check-in/out event log into
checkouts, deduping colliding names, reversing an inverse relationship type.
- The handful of narrow gaps the API cannot cover (e.g. no bulk-communications
endpoint) handled as documented direct-ORM writes.

View File

@@ -6,7 +6,7 @@ API. No direct writes to the `shopdb_flask` database are needed or wanted: every
row is created through a documented endpoint so authorization, validation,
auditing, and plugin hooks all run exactly as they do for a human operator.
An LLM or a plain Python script can run the whole migration from this document.
A plain Python script can run the whole migration from this document.
Contents:

View File

@@ -129,6 +129,16 @@ venv\Scripts\flask seed admin --username admin --email admin@yourfacility.exampl
## 7. IIS site
Two supported deployment methods:
- **Method A - own site (recommended, default):** the app gets its own IIS
site, port (or hostname), app pool, and venv. Steps 1-5 below.
- **Method B - subpath under an existing site:** the app runs as an IIS
**Application** (e.g. `/ops`) under a site you already have (such as the
classic ASP site or Default Web Site), so it shares that site's binding and
TLS cert: `https://<host>/ops/`. Do steps 1-4 below, then follow **7b**
instead of step 5.
1. Copy `deploy\windows\web.config` to `APP_ROOT\web.config`. If `APP_ROOT` is not
`C:\shopdb-flask`, fix the paths inside it. Create `APP_ROOT\logs`.
2. Create an app pool with **No Managed Code**:
@@ -159,10 +169,45 @@ IIS launches `waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` per the
web.config and reverse-proxies the site port to it. First request takes ~15s
(the app boots + connects to MySQL).
### 7b. Method B: subpath under an existing site
The mount path must match in **three places**: the IIS Application alias, the
`MOUNT_PATH` the backend sees, and the `VITE_BASE_PATH` the frontend was built
with. `/ops` is the example throughout; any alias works.
1. Rebuild the frontend for the subpath (on the dev box, then copy `dist`):
```bash
cd frontend && VITE_BASE_PATH=/ops/ npm run build # note the trailing slash
```
2. Create the Application under the existing site (instead of `New-Website`):
```powershell
New-WebApplication -Site "Default Web Site" -Name ops -PhysicalPath APP_ROOT -ApplicationPool shopdbflask
```
3. Tell the backend its mount path: in `APP_ROOT\web.config`, uncomment the
`MOUNT_PATH` environment variable (value `/ops`), or set `MOUNT_PATH=/ops`
in `APP_ROOT\.env`. `wsgi.py` then serves everything under the prefix
(requests outside it get a plain 404 naming the mount).
4. Recycle the app pool. The app is at `http(s)://<host>/ops/` and the API at
`/ops/api/...`.
The handler mappings in the app's web.config apply only inside the
Application, so the parent site's own handlers (classic ASP, static files)
are untouched. `CORS_ORIGINS` in `.env` is origin-only (scheme + host + port,
no path), so it is the same for both methods.
> The `X-Forwarded-For` URL Rewrite rule in web.config is **commented out by
> default**. It needs the URL Rewrite module; with it active but the module
> absent, IIS returns 500.19. Install URL Rewrite, then uncomment the
> `<rewrite>` block, to record real client IPs in audit logs.
>
> Two companion requirements, or the app keeps seeing 127.0.0.1:
> `allowedServerVariables` is locked at server level by default (500.52 when
> the block activates) - unlock once with
> `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`.
> And waitress 2+ strips X-Forwarded-For from untrusted proxies, so the
> waitress `arguments` line must carry
> `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for`
> (the shipped web.config already does).
---
@@ -188,7 +233,11 @@ each gets its own site, app pool, port, and venv.
| --- | --- |
| `flask db upgrade` -> error **1071** | MySQL 5.6 without the step-1 flags (or server not restarted). |
| IIS **500.19** | handler sections not unlocked (step 7.4), or the `<rewrite>` block active without URL Rewrite. |
| IIS **500.52** after enabling the rewrite block | `allowedServerVariables` locked at server level - `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`. |
| Audit log shows only **127.0.0.1** with the rewrite block active | waitress strips untrusted proxy headers - `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for` missing from the waitress `arguments`. |
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). |
| Method B: SPA loads but every API call 404s | `MOUNT_PATH` unset or not matching the Application alias (step 7b.3). |
| ConfigError on boot | a required `.env` var missing or left at a dev default. |

160
docs/PILOT-DEPLOY.md Normal file
View File

@@ -0,0 +1,160 @@
# Production pilot runbook (West Jefferson)
Goal: stand up a real shopdb-flask instance loaded with WJ's classic-ASP data,
run it **in parallel** with the classic app for a validation window, then cut
over. This runbook adds the legacy-data import + verification + cutover on top of
the generic stand-up in [`DEPLOY.md`](DEPLOY.md). Read that first; this only
calls out the pilot-specific steps.
Related: [`IMPORT-ADOPTION.md`](IMPORT-ADOPTION.md) (import model),
[`IMPORT-API.md`](IMPORT-API.md) (the contract), [`BACKUP-RESTORE.md`](BACKUP-RESTORE.md),
`scripts/site_imports/wjf/` (the loader).
---
## 0. Pre-flight checklist
- [ ] Host provisioned (Docker + compose, or a VM with Python 3 + MySQL 8).
- [ ] Three current classic dumps in hand: `shopdb` (main), `cmmc_usb`,
`wjf_employees`. Take fresh dumps at import time - the classic app is live.
- [ ] Target MySQL 8, utf8mb4 (charset is contract, ADR-004). Old MySQL <5.7
needs `innodb_large_prefix=ON` + Barracuda.
- [ ] Decide the pilot URL (e.g. `shopdb-pilot.wjs.geaerospace.net`) - separate
from the classic app; do not reuse its hostname yet.
- [ ] Confirm the import decisions still hold (see the loader README / the
import plan): assetnumber fallback + skip-dups, metrology routing,
cmmc-only USB, warranties = Dell, occurrences parked.
## 1. Stand up the pilot instance
Follow `DEPLOY.md` steps 1-6 against a NEW empty database (name it clearly, e.g.
`shopdb_flask_pilot`):
```bash
flask db upgrade
flask plugin upgrade-all # applies every plugin's chain
flask seed permissions
flask seed settings
flask seed reference-data # seeds communicationtypes (IP) + the rest
```
**Enable every bundled plugin the site tracks - including usb**, which ships
disabled. A plugin's routes only register when it is enabled at app start, and
the importer needs them:
```bash
for p in computers employees machines measuringtools network notifications \
printers slides usb warranty knowledgebase geenforce; do
flask plugin enable "$p"
done
```
Do **not** run the setup wizard yet - the import fills the data the wizard would
otherwise ask you to seed.
## 2. Load the classic data
The loader (`scripts/site_imports/wjf/`) reads the classic dumps and drives the
import API. It is site glue, not product code.
1. Load the three dumps into scratch source DBs the loader can read (strip the
`CREATE DATABASE`/`USE` lines so they land under scratch names, no clobber):
```bash
for pair in "shopdb_src:shopdb_dump.sql" "cmmc_usb_src:cmmc_usb_dump.sql" \
"wjf_employees_src:wjf_employees_dump.sql"; do
db="${pair%%:*}"; f="${pair##*:}"
mysql -h HOST -u root -p -e "CREATE DATABASE $db CHARACTER SET utf8mb4;"
sed -E '/^CREATE DATABASE/d; /^USE `/d' "$f" | mysql -h HOST -u root -p "$db"
done
```
2. Point the loader at the PILOT database and run all stages:
```bash
DATABASE_URL='mysql+pymysql://USER:PW@HOST:3306/shopdb_flask_pilot?charset=utf8mb4' \
venv/bin/python -m scripts.site_imports.wjf.run
```
The 15 stages run in order (reference -> catalog -> assets hub -> locations ->
printers -> dependents -> relationships -> subnets -> usb -> verify). It is
idempotent - a crashed run resumes from `idmap.json`.
Expected magnitude (from the WJ dumps used in development - your fresh dumps will
differ slightly):
| entity | count |
|---|---|
| assets | ~983 (computer ~663, machine ~76, network ~58, measuring-tool ~136, printer ~50) |
| locations | ~24 |
| employees | ~415 |
| installs | ~850 |
| primary IPs | ~461 |
| warranties | ~464 |
| notifications | ~261 |
| knowledge base | ~341 |
| relationships | ~93 |
| subnets | ~37 |
| USB devices / events | ~18 / ~232 |
The `verify` stage prints a source-vs-target row-count audit; the gaps are the
documented skips (inactive rows, duplicate machinenumbers, LocationOnly, the
9999 placeholder).
## 3. Verify the import
- [ ] Read the `verify` stage output - source vs target counts line up modulo
the documented skips.
- [ ] Create the admin: `flask seed admin --username ... --email ...` (password
printed once). Mark setup done so the app does not force the wizard:
set `setup_complete=true` in settings (or click through the wizard,
skipping the seed steps).
- [ ] UI spot-check (log in): Computers list paginates the full fleet; the Shop
Floor Map plots assets, color-coded by type (positions came from
mapleft/maptop); open a PC detail (installs), a printer (IP + share), an
application (installed-on list), a KB article; check the employee
directory; check a couple of asset relationships.
- [ ] Branding: upload the site logo + floor-plan blueprint under Settings, set
facility name (Settings drive these per `CONFIG.md`).
- [ ] Photos are deferred - employees show initials until a photo batch is run.
## 4. Parallel-run window
- Keep the classic app authoritative during the window. The pilot is read-mostly
for validation; do not dual-write.
- Have a few real users (IT + a floor lead) work the pilot and log gaps.
- Re-import is cheap: fix a loader mapping, drop + rebuild the pilot DB, re-run.
Nothing you do to the pilot touches classic.
- Point the **collector** (GE-Enforce fleet ingest) at the pilot in parallel to
confirm live PC check-ins land (see `COLLECTOR-INTEGRATION.md`), using a
scoped service token.
## 5. Cutover
When the window is clean:
1. Freeze classic writes (announce a short read-only window).
2. Take final fresh dumps; re-run the loader into a clean pilot DB so the
cutover data is current.
3. Verify counts + a fast UI spot-check.
4. Repoint the production hostname/DNS (or the reverse proxy) at the pilot.
5. Retire the classic app to read-only standby (do not delete - keep it as the
rollback for the agreed period).
## 6. Rollback
- Pre-cutover: trivially point back at classic (it never stopped being
authoritative).
- Post-cutover, within the standby window: repoint DNS/proxy back at classic;
investigate; re-cut when fixed. Because the loader is deterministic and the
classic DB is untouched, a re-run reproduces the flask DB exactly.
## 7. Post-cutover
- [ ] Backups on a schedule (`BACKUP-RESTORE.md`) - mysqldump + the `instance/`
dir (uploaded logos, floor plans, tokens).
- [ ] Run the employee-photo batch.
- [ ] GE-Enforce: publish manifests + cut the fleet over to the flask endpoints
when ready (`GE-ENFORCE-DEPLOY.md`) - independent of this pilot.
- [ ] Schedule the deferred data (occurrences, full communications fidelity)
only if a real need appears.

View File

@@ -532,9 +532,8 @@ form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdm
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
reorganize the file; just add the block, mirroring `machinesApi`.
**Views mirror the master templates.** The frontend has master templates the
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`
for detail pages). `measuringtools` mirrors the equivalent equipment views:
**Views mirror the master templates.** The frontend has master templates
(`PrintersList.vue` for lists, `PrinterDetail.vue` for detail pages). `measuringtools` mirrors the equivalent equipment views:
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
and a calibration-status filter; the status badge uses `utils/colorStyle` with

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.10.0'
__contract_version__ = '0.12.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -469,12 +469,19 @@ What `shopdb.api` exposes:
- Responses: `success_response`, `error_response`, `paginated_response`,
`ErrorCodes`
- Pagination: `get_pagination_params`, `paginate_query`
- Authorization: `require_permission`, `require_role`
- Authorization: `require_permission`, `require_role`,
`service_token_authorized`
(`service_token_authorized(scope)` returns True when the request carries a
managed service token scoped for `scope` whose owner holds that permission -
for unattended plugin endpoints like the GE-Enforce fetch API)
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
`dualpath_single_machine_enabled`
- Import mode: `apply_import_timestamps`, `import_mode_active`,
`parse_import_datetime`
- Legacy employee directory: `employee_connection`
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients
```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query

View File

@@ -0,0 +1,341 @@
# Plugin lab: build the printedparts plugin
A hand-held, build-along tutorial: construct the 3D-printed-parts storefront +
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by
stage, seeing each piece work before moving on. Written for someone building
their first plugin. The finished implementation lives on the
`feat/printedparts-plugin` branch with one commit per stage, tagged
`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03
lab-stage-04` shows exactly what a stage changes.
Know before you start
- You are building a BUNDLED plugin inside this repo. Plugin frontend files
live in core (`frontend/src/...`), and three core files get small edits:
`frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for
all bundled plugins - external-plugin UI packaging does not exist yet.
- Three deliberate divergences from the scaffold, each a teaching point:
(1) NO AssetType - these are quantity consumables, not ADR-001 assets
(stage 1); (2) the migration is a REAL baseline that creates tables, not a
stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's
first UNauthenticated write - read the decision record in the proposal
before stage 7.
- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
DB names lowercase concatenated (`quantityonhand`); run
`bash scripts/check-naming-and-style.sh` + the tests at every stage; one
git commit per stage.
Prerequisites: working dev environment (README quick start), skim
`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this
lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules.
---
## Stage 0 - orientation (no code)
Read the proposal. Tour the two reference plugins you will imitate:
`plugins/usb/` (checkout ledger + badge contract) and
`plugins/measuringtools/` (post-cutover migration baseline, hooks).
See it work: run the app, log in.
## Stage 1 - scaffold, minus the AssetType
```
flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout"
```
Walk the generated tree. Then diverge:
1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its
`on_install` call - a printed part is a kind-with-a-count, not an asset.
Replace it with settings seeding (see the tagged commit): three Setting
rows, category `printedparts` - `printedparts_code_prefix` (3DP),
`printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny).
2. `manifest.json`: `"dependencies": ["employees"]` (badge names),
`"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`,
`"display_name": "3D Printed Parts"`.
See it work: `flask plugin list` shows printedparts [Available].
Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec`
## Stage 2 - models + real migration baseline + tables live
1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem`
(itemcode unique+indexed, itemname, itemdescription, imageurl,
quantityonhand, lowstockthreshold, binlocation, printnotes) and
`PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype
take/restock/adjust, SIGNED quantitychange, employeesso, employeename,
reason, transactiondate) - both on `BaseModel`. The ledger is the source
of truth; quantityonhand is a cache moved in the same commit.
2. Update `models/__init__.py` exports and `plugin.py` `get_models`.
3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`):
`'printedparts': ('printeditems', 'printeditemtransactions'),`
4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from
measuringtools (change PLUGIN_NAME), then hand-write
`versions/0001_printedparts_baseline.py` with explicit `op.create_table`
for both tables + the three transaction indexes.
5. The scaffold's `api/routes.py` still imports the model you deleted - make
the blueprint import cleanly (a placeholder route is fine for now).
See it work:
```
flask plugin install printedparts && flask plugin enable printedparts
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
flask plugin upgrade-all -- printedparts: ok (idempotent)
```
Common errors (both hit for real while building this):
- An empty `Migration error:` on install. Root cause: anything that makes
`plugins.printedparts.models` fail to import - the alembic env imports the
models package, which pulls in plugin.py and routes.py. Here it was the
scaffold routes importing the deleted model; the ImportError gets caught
and retried down a subprocess path with no stderr. Fix the import, not the
migration.
- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add
`EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` -
the guard makes every new plugin declare its expected head on purpose.
Commit + tag `lab-stage-02`.
## Stage 3 - read API + list page (the first visible win)
1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via
`get_pagination_params`/`paginate_query`, search across
code/name/description/bin, `?lowstock=true` filter) and
`GET /items/<id>` returning the item + its 25 most recent transactions.
2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box',
'route': '/printedparts', 'position': 46}`.
3. Frontend: paste the `printedpartsApi` client into
`frontend/src/api/index.js` (list/get for now, paths under
`/printedparts/items`); rename the scaffold views to
`PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint
`router/routes/printedparts.js`; build the list page from
`PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an
image thumb column and a red/green quantity badge vs the threshold.
4. Seed two or three rows by hand (SQL or flask shell) purely to have
something to look at. NOTE: hand-seeded stock has no ledger backing - the
stage-9 reconcile report will flag exactly these rows, which is the check
working.
See it work: navigate to `/printedparts` - your parts in a table, low-stock
row red-badged. Everything before this moment was invisible; from here on
every stage shows on screen.
Common error: nav icon missing. The sidebar maps icon NAMES to Lucide
components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing.
Add `'box': Box` to the map (and the import) or reuse an existing name.
Commit + tag `lab-stage-03`.
## Stage 4 - catalog mutations + item photos + detail/form pages
1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the
id: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
updates catalog fields but REFUSES `quantityonhand` (ledger-managed).
`DELETE` soft-retires. All `@jwt_required()` (permissions come in
stage 6).
2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE
`/items/<id>/image` + public `GET /image/<filename>`, storing
`printeditem-<id>.<ext>` in `instance/printedpartsimages/`, wiping prior
extensions on replace, prefix-guarded delete.
3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info
list, transactions table); `PrintedItemForm.vue` create/edit + photo
upload on edit; extend the api client.
See it work: add a part with a photo in the UI; thumbnail on the list, hero
on the detail; `PUT` with `quantityonhand` returns the ledger-managed error.
Commit + tag `lab-stage-04`.
## Stage 5 - the ledger: restock/adjust with badge attribution
1. `services/badges.py` - COPY the USB badge contract (do not import
`plugins.usb`; cross-plugin imports fail the contract test):
`^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees
plugin `DirectoryEmployee` (lazy import, graceful fallback), and the
`printedparts_unknown_badge` policy - deny raises a kiosk-displayable
`BadgeError`, allow records the SSO with an empty name.
2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant:
append the transaction row and move the cached quantity in ONE commit.
Every write path goes through it.
3. `POST /items/<id>/restock` {quantity, badge} and `/adjust`
{quantitychange, reason, badge}; adjust requires a reason and refuses to
drive stock below zero.
4. Detail page: Restock/Adjust modals (shared `Modal.vue`).
5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge
shape, reason-required + below-zero guards, the policy toggle, 401 for
anonymous. See `tests/test_plugins/test_printedparts_ledger.py`.
See it work: restock from the detail page with your SSO - quantity moves AND
a named transaction row appears.
Common error: in tests, mutating rows through a nested `app.app_context()`
does not reliably stick in the sqlite test env - stock the item through the
real restock endpoint instead (also more honest).
Commit + tag `lab-stage-05`.
## Stage 6 - RBAC
1. `get_permissions` on the plugin: view/create/edit/delete/restock, category
`printedparts` (seeded automatically on install/enable and by
`flask seed permissions`).
2. Add `@require_permission('printedparts.<x>')` under `@jwt_required()` on
every mutation: create/edit/delete/image = create/edit/delete; restock +
adjust = restock.
3. Test with the `member_headers` fixture (authenticated, role-less): 403
where admin succeeds - authentication alone is not authorization.
See it work: the permissions appear in the role grid (Settings > Roles), and
the member test passes.
Commit + tag `lab-stage-06`.
## Stage 7 - the kiosk (the deliberate open write)
Read the decision record in the proposal first. The take endpoint must stay:
decrement-only, badge-attributed server-side, bounded, physically
rate-limited. Put the justification in the plugin README.
1. Backend, both UNdecorated: `GET /kiosk/item/<itemcode>` (summary for a
scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} -
validate active item, 1 <= qty <= onhand, resolve the badge, then
`_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown
verbatim on the kiosk - write them for a person standing at a screen.
2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace.
3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside
`/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout,
`meta.plugin` so a disabled plugin dead-ends). Three steps - scan item,
scan badge, keypad quantity - driven by ONE hidden always-focused input
that consumes keyboard-wedge scans (scanners type the code + Enter) for
whichever step is active; manual type-in fallbacks for damaged labels.
Success screen auto-resets after a few seconds.
4. Kiosk test: open access, over-take guard, unknown-badge 422, and
cache==ledger afterward.
See it work: full walkthrough in a browser - type a code, badge in, keypad 2,
TAKE - stock drops with your name in the ledger.
Common error (by design): the full suite fails with
`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`.
That sweep asserts EVERY mutating route rejects a role-less user - the
framework's net against accidentally-open writes. Your kiosk take is open on
purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment
pointing at the decision record. The net stays; the exception is explicit
and reviewable.
Commit + tag `lab-stage-07`.
## Stage 8 - 1in x 0.5in bin labels
1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public
`/print/printedparts-labels` route beside `/print/usb-labels` (a plugin
OWNS its label page - the USB precedent; parts are not in the asset-label
TYPE_CONFIG because they are not assets).
2. The label: CODE128 of the itemcode via JsBarcode
(`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`)
+ the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner
tolerance; CODE128 of `3DP-0042` is comfortable.
3. Roll stock = one label per page: a global (unscoped) print style with
`@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on
each `.bin-label`. Multi-select + per-item copies; `?item=<id>`
preselects (the Detail page's Bin Label button).
See it work: print preview shows one 1x0.5 label per page; scan the printed
barcode (or the on-screen one with a phone scanner app) into the kiosk -
label -> scan -> badge -> take -> ledger is the demo moment.
Commit + tag `lab-stage-08`.
## Stage 9 - reports + the reconcile check
1. Three jwt-optional endpoints in the plugin blueprint, each honoring
`?format=csv` (local CSV helper - `generate_csv` is not on the contract
surface): `/reports/stock`, `/reports/consumption?days=N`,
`/reports/by-person?days=N`.
2. The stock report's `ledgerdelta` column = cached quantityonhand minus the
ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a
write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows
show up here, proving the check works.
3. `get_reports` on the plugin (endpoint-style entries, categories
inventory/usage) - they merge into `GET /api/reports` and the /reports hub
while the plugin is enabled.
Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries
strings.
Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only
renderers; needs a core component) and a Settings card (needs a settings page
to link). Reports are the monitoring surface.
Commit + tag `lab-stage-09`.
## Stage 10 - closeout
1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and
grantable permissions disappear; API routes only disappear after a
RESTART (blueprints register at startup - the guide's section 12 gotcha).
Re-enable.
2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` +
`flask plugin install/enable printedparts` + `upgrade-all` - green with
zero manual SQL.
3. Full suite: backend pytest, vitest, frontend build, naming hook.
4. Walk `PLUGIN-GUIDE.md` section 12's End checklist.
Done means: a colleague can clone the repo, enable the plugin, print a bin
label, and take a part at the kiosk with their badge - without asking you
anything.
## Stage 11 (extension) - low-stock email alerts
Per-item thresholds already exist; alerting on them is a worked example of a
CONTRACT ADDITION, because the mailer was not on the plugin surface:
1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump
`__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the
docs-drift guard test fails until the doc's version example matches.
Manifest pins `core_version >=0.12.0` since the plugin now needs it.
2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the
threshold (before > threshold >= after). Crossing, not being-below, is the
natural debounce: one alert per depletion, restocking above rearms.
Best-effort try/except AFTER the commit - mail failure must never fail
the take.
3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty
falls back to the site's alert_recipients via `send_alert`. Seed the new
setting in on_enable too (idempotent) so already-installed sites get it.
4. Test with a monkeypatched sender: no alert above threshold, one on the
crossing, no re-fire while below, rearm after restock (see
`test_lowstock_alert_fires_on_crossing_only`).
## Stage 12 (extension) - the admin settings page
A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred
it. The page is ordinary:
1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four
keys via `settingsApi.list({category: 'printedparts'})`, save each with
`settingsApi.update(key, value)` (admin-gated server-side).
2. Route in the PLUGIN's router file with path `settings/printedparts` +
`requiresAuth, requiresAdmin, plugin` meta - the router shell
automatically nests any `settings/...` path under the two-pane settings
rail.
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
the card appears in the rail's catalog while the plugin is enabled.
---
## Where each pattern lives (cheat sheet)
| Need | Copy from |
|---|---|
| Standalone (non-asset) plugin shape | `plugins/knowledgebase/` |
| Checkout/ledger + badge contract | `plugins/usb/` (`api/routes.py` badge regex, `api/selfhosted.py` name resolve) |
| Real-baseline plugin migration | `plugins/measuringtools/migrations/` |
| Blueprint style, pagination, authz | `plugins/measuringtools/api/routes.py` |
| Image upload/serve/delete | `shopdb/core/api/models.py` |
| Open kiosk endpoints precedent | `plugins/employees/api/routes.py`, `plugins/notifications/api/routes.py` |
| Plugin-owned label print view | `frontend/src/views/print/USBLabelBatch.vue` |
| Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` |
| Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` |
| List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` |
| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` |
| Permissions declaration | `plugins/usb/plugin.py::get_permissions` |
| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` |

View File

@@ -1,6 +1,6 @@
# Roadmap
shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.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 at `__contract_version__ = '0.12.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.
## Phase status
@@ -12,24 +12,22 @@ shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document cap
| 3 - Manifest-first loader, shopdb.api namespace, auto-register blueprints | DONE | `6f085a1` |
| 4 - Plugin scaffolding (`flask plugin new`) | DONE | `8eb9362` |
| 5 - Alembic baseline, per-site deploy, ADRs to docs/adr | DONE | `d4e3ac9` |
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | IN PROGRESS | this phase |
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | DONE | v0.5.0-v0.7.0 |
The last big milestone before 1.0 is the legacy-ASP data import plus a production pilot deployment; the framework work below is what remains after that.
## What's left before tagging 1.0.0
### Must-have
- **Asset model fully wired**. `Asset.mapx, Asset.mapy` columns, `AssetRelationship.inheritsposition`, `AssetRelationship.propagatesthroughid` columns. Models match the locked ADR-001 surface that `resolve_asset_position` already targets.
- **Equipment data migration script** for facilities migrating from legacy ASP shopdb. One-shot script under `scripts/migration/`. See [migrating-asset-schema](../../.claude/skills/migrating-asset-schema.md) for the policy. Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates.
- **Equipment data migration script** for facilities migrating from legacy ASP shopdb. One-shot script under `scripts/migration/`. Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates.
- **Printers retirement**. Legacy `PrinterData` model, `printers_bp` legacy blueprint, and the frontend `PrinterForm.vue` references to `printer.printerdata.*` get removed in lockstep. Coordinated with the equipment migration.
- **Frontend hook contract**. Vue side equivalents for the backend hook system: how plugins expose asset-detail components, map markers, search-result renderers. Requires its own design ADR.
- **Per-plugin Alembic migrations**. The framework supports them via `shopdb/plugins/migrations.py`; bundled plugins still rely on `db.create_all()`. Move each bundled plugin onto its own version chain before sister sites adopt.
- **External plugin UI packaging**. The Vue-side hook contract ships (ADR-010: get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation) and route gating is backend-driven (ADR-009), but plugin routes/views still live in core `frontend/src`. Let an external plugin ship its own Vue bundle so adopters can add UI without editing core.
### Nice-to-have
- **Bundle the Roboto font locally.** `frontend/src/assets/style.css:2` imports Roboto from Google Fonts (`fonts.googleapis.com`). Air-gapped facilities have no route to that host, so the font silently falls back to a system font. Vendor the woff2 files into `frontend/src/assets/` and `@font-face` them locally so every site renders identically offline.
- **Full palette theming.** `brand_primary_color` is settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the one primary color.
- **Frontend plugin contract.** The backend hook system has no Vue-side equivalent yet (routes/views still ship in core; nav is already backend-driven). See the must-have entry above; this is the design ADR that unblocks external plugins shipping their own UI.
- `measuringtools` plugin built using the scaffold (validates the scaffold under realistic conditions).
- **Full palette theming.** `brand_primary_color` and a few brand colors are settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the brand colors.
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).
- Plugin contract surface diff tooling. Today version bumps are manual judgment; a CI check that diffs the contract surface against the previous tag would catch missed bumps. See ADR-002.
@@ -63,3 +61,9 @@ When a roadmap item gets prioritized, document the why in a new ADR and link fro
- [ADR-004](adr/ADR-004-deployment-topology.md) - Deployment topology (per-site)
- [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) - Equipment vs measuringtools
- [ADR-006](adr/ADR-006-collector-contract.md) - Collector contract pattern
- [ADR-007](adr/ADR-007-product-versioning-and-releases.md) - Product versioning and releases
- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - Plugin migration ownership (per-plugin chains)
- [ADR-009](adr/ADR-009-frontend-plugin-gating.md) - Frontend plugin route gating
- [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) - Frontend plugin hook contract
- [ADR-011](adr/ADR-011-machines-rename.md) - Machines rename + modeltypes retyping
- [ADR-012](adr/ADR-012-geenforce-manifest-ownership.md) - GE-Enforce manifest ownership

View File

@@ -106,7 +106,7 @@ core discovers it, mirroring the backend model. Sketch:
named extension points instead of editing core files: an `iconMap`
registration for nav/asset icons, asset-detail panels, map-marker
renderers, and search-result renderers (the "Frontend hook contract"
already listed as deferred in the project CLAUDE.md). Core exposes a
already listed as deferred in docs/ROADMAP.md). Core exposes a
stable set of shared components (form controls, detail-page shells,
table primitives) as the plugin frontend's only allowed core imports,
the frontend analogue of the `shopdb.api` namespace.

View File

@@ -0,0 +1,123 @@
# ADR-012: GE-Enforce manifest ownership in shopdb
- **Status:** ACCEPTED
- **Date:** 2026-07-13
- **Deciders:** cproudlock
- **Relates to:** ADR-002 (plugin contract versioning), ADR-004 (per-site
deployment), ADR-006 (collector contract), ADR-008 (per-plugin Alembic chains)
## Context
GE-Enforce is a desired-state enforcement system for shopfloor PCs: a PowerShell
engine (`Install-FromManifest.ps1`) reads per-PC-type `manifest.json` files off
an SMB share every logon and installs / self-heals what they declare. Authoring
those manifests today means hand-editing JSON on a file share, and there is no
central view of what each PC actually did.
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
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
its own provisioning - PXE at West Jefferson, OOBE provisioning packages at
others).
The manifests are an enforcement PROGRAM, not an application inventory: entry
`Type` is not an app/config discriminator and entry `Name` is a manifest label,
not a Windows ARP DisplayName. Any design that treats them as an app catalog is
wrong.
## Decision
Build a bundled `geenforce` plugin that owns the manifest as shopdb data, with a
client kit and a deployment bootstrap. Specifically:
1. **Data model.** One wide `manifestentries` table with an `entrytype`
discriminator and nullable per-type columns (not SQLAlchemy STI, not a JSON
blob - the fleet is ~64 entries, so sparse columns are free and stay
queryable). Scopes are `manifestscopes`, unique on `(scopename, phase)`;
runtime is per-pctype scopes, preinstall is one flat scope. Multi-value gates
(PCTypes / hostnames / machine numbers) and the nested InUseCheck are child
tables. `sortorder` is the execution-order contract. RegValue is stored as a
raw JSON literal so DWord-vs-string typing survives. Per-plugin Alembic chain
(ADR-008).
2. **Published snapshots.** Editing touches a DRAFT only. Publish freezes the
rendered JSON document into an immutable `manifestpublishedversions` row; the
client is ALWAYS served the current published snapshot, never the draft;
rollback flips `iscurrent` to an older version. Freezing the document (not
row-mirroring) makes immutability structural.
3. **Behavioral-parity gate, not byte-identity.** A DB-free harness
(`parity.py`) imports each real manifest and renders it back, then proves
BEHAVIORAL equivalence (same ordered entries with identical detection /
targeting, and the same entries fire across machine-profile fixtures) - never
byte equality, which re-serialization would never satisfy. This gates any
build that touches the model.
4. **Filter mirror; engine is the single source of truth.** `filters.py`
mirrors the engine's four gate functions and alias graph for the "what would
this PC get" simulator and parity. The engine lib stays authoritative;
shopdb mirrors it (never the reverse). PCTypesStrict is honored only for the
preinstall phase, matching the runners.
5. **Payload integrity is separate from detection.** For `http`/`inline`
payloads a dedicated `payloadsha256` is verified before running - independent
of `DetectionMethod` (DetectionValue is a hash only for `Hash` detection).
`smb` payloads keep the share ACL as their trust boundary. Large binaries
stay on SMB; small config/scripts may move to http/inline later.
6. **Observed-state reporting.** Each PC POSTs its enforcement result;
`manifestenforcementreports` (+ results) records the applied version
(received-latest) and per-entry self-heal / failure. Status derives from
explicit self-heal flags only, never the raw installed count (Always/no-
detection scripts install every cycle without being drift corrections).
7. **Service-token auth.** Client endpoints authorize via managed service
tokens scoped `geenforce.fetch` / `geenforce.report`, through a new
`service_token_authorized(scope)` on the `shopdb.api` contract surface
(contract 0.11.0). Admin CRUD uses `geenforce.manage` / `geenforce.publish`.
8. **Client + deployment, engine referenced not vendored.** shopdb ships the
fetch/report kit (`plugins/geenforce/client/`) and a site-neutral bootstrap
(`Install-GEEnforce.ps1`) that provisions a PC's identity
(`C:\Enrollment\pc-type.txt` etc. - what determines the PC type; there is no
auto-detection, the provisioner supplies it), the shopdb registry config, and
the scheduled task. The GE-Enforce ENGINE is referenced (`-EngineSource`),
not carried by shopdb. Deployment is provisioning-path independent (PXE step,
OOBE ppkg, Intune, manual); the runtime task is fail-safe.
9. **Milestone 1 = export to share; staged cutover.** Until a site cuts its
client over to shopdb-sourced manifests, the plugin publishes and EXPORTS the
manifest to the share (with a `_meta/history` backup, atomic write); the
unchanged engine picks it up. Cutover is staged: shadow mode (fetch from
shopdb AND read the share, log diffs, install from share) then read cutover.
10. **No application auto-seeding.** The core Applications catalog already
tracks these apps (from the classic-shopdb migration) with version
histories; auto-creating Applications from manifest labels produced
duplicates and misclassified config drops. Application linkage, if wanted, is
a curated manifest-entry -> existing-Application link, not label scraping.
## Consequences
- **Positive.** Manifests become validated, versioned, publishable data with
one-click rollback and a fleet-compliance view; desired-state and observed-
state live in one system. The parity gate + published snapshots + separate
payload hash make a fleet-wide-SYSTEM system safe to author. The plugin is
provisioning-agnostic, so any GE Aerospace site can adopt it regardless of
imaging path. Validated end to end: parity green against the real manifests,
and the client kit + installer proven on a Windows VM (PS 5.1) and Linux
pwsh 7.
- **Boundaries / risks.** The engine remains the GE-Enforce framework's, so
shopdb's parity mirror must be kept in sync with the lib (guarded by the parity
fixtures; the plugin pins lib >= 2.6 for `_CmmVersion`). Provisioning writes
the PC identity - shopdb cannot set a PC's type at imaging (a PC is unknown
until it enrolls and reports). Manifest-label vs ARP-name mismatch means the
catalog link, when built, needs a curated alias layer.
- **Deferred.** Desired-vs-observed per-entry compliance (needs a collector
installedVersions field); curated manifest-entry -> Application linking; the
live client cutover (a site operational decision); inline payload upload.
See `docs/proposals/ge-enforce-plugin.md` (design + cutover), `docs/GE-ENFORCE.md`
(concepts + imaging timeline), `docs/GE-ENFORCE-CLIENT.md` (fetch/report
contract), and `docs/GE-ENFORCE-DEPLOY.md` (agent deployment).

View File

@@ -24,6 +24,7 @@ Each ADR captures a single architectural decision: the context, the decision its
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | ACCEPTED |
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
| [012](ADR-012-geenforce-manifest-ownership.md) | GE-Enforce manifest ownership in shopdb | ACCEPTED |
## Authoring

View File

@@ -0,0 +1,648 @@
# Proposal: GE-Enforce as a shopdb plugin
Status: DRAFT / planning only. Not accepted, not built.
Author: planning session 2026-07-12.
## 1. What this is
Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type
`manifest.json` files off an SMB share (`\\tsgwp00525.wjs.geaerospace.net\
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
self-heals apps, files, drivers, registry values, and scripts. A parallel
`preinstall.json` runs the same schema once at imaging.
This proposal turns the *manifest* into shopdb data: the authoritative manifest
lives in the shopdb database, is edited through the shopdb UI (an expansion of
`/settings/pctypemapping`), and is served to clients over HTTP as JSON. The
*payloads* (MSI/EXE/PS1/config bytes) stay on SMB, on HTTP, or both, referenced
by URL/path from the manifest rows. GE-Enforce.ps1 changes from "read a file on
W:" to "GET a manifest from shopdb, then fetch each payload from wherever the
row says."
The result: managing imaging PC types, their apps, scripts, files, registry
rules, and version gates becomes a first-class shopdb feature instead of hand-
edited JSON on a file share.
## 2. Why it fits shopdb
- shopdb already models the fleet (the collector ingests every PC's hostname,
pctype, installed software, versions). Making shopdb *also* own what SHOULD be
installed closes the loop: desired-state (manifest) and observed-state
(collector) live in one system and can be diffed.
- `/settings/pctypemapping` already maps `gea-shopfloor-*` PC types to
`ComputerType`. That page becomes the entry point for full imaging-PC-type
management.
- The plugin contract (per-plugin models, migrations, API prefix, settings
cards, collector hooks) is exactly the shape this needs.
- ADR-004 (per-site instances) matches: each site's shopdb owns each site's
manifest. No multi-tenant complication.
## 3. Grounding: the real manifest schema
Source of truth for these field names (do not invent others):
- Schema: `pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_meta/manifest-schema.json`
- Engine: `pxe-images/common/lib/Install-FromManifest.ps1`
- Dispatcher: `.../shopfloor/common/GE-Enforce.ps1`
- Architecture: `pxe/docs/ge-enforce-v2-architecture.md`
A manifest is `{ "Version": str, "_comment": str, "Applications": [entry, ...] }`.
Only `Name` and `Type` are required per entry.
### Per-entry fields (complete set)
Identity / action:
- `Name` (required, unique, also the status-key `<scope>/<Name>`)
- `Type` (required): one of `MSI EXE CMD BAT PS1 INF File Registry`
- `_comment` (documentation, heavily used in practice)
Type-specific payload references (sparse; depends on Type):
- MSI/EXE/CMD/BAT/INF: `Installer` (relative path) + `InstallArgs`
- PS1: `Script` (relative path, falls back to `Installer`) + `Args`
- File: `Source` (relative) + `Destination` (absolute on-PC path)
- Registry: `RegPath` + `RegName` + `RegValue` + `RegType`
(`RegType` in `String DWord QWord MultiString ExpandString Binary`)
- Optional `LogFile`, `WaitTimeoutSec` (EXE hang kill), `InUseCheck`
Detection (decides whether the action fires / self-heals):
- `DetectionMethod`: one of
`Registry File FileVersion Hash MarkerFile ValueMatches pnputil Always`
- `DetectionPath`, `DetectionName`, `DetectionValue`, `DetectionPattern`
- Note: `DetectionValue` is method-dependent - SHA256 for Hash, a 4-part
version for FileVersion, a registry value for Registry, ignored for
Always/File. Same column, different meaning per method.
- No `DetectionMethod` = always installs.
Targeting filters (all ANDed; each is multi-value):
- `PCTypes` (array; `"*"` = all; alias graph expands old<->new names)
- `PCSubTypes` / subtype via `<pctype>-<subtype>` values
- `TargetHostnames` (array; exact + `-like WJS-*` wildcards)
- `TargetMachineNumbers` (array; per-bay)
- `_CmmVersion` (scalar; per-entry PC-DMIS version gate, needs lib >= 2.6)
Nested:
- `InUseCheck`: `{ Behavior, Processes: [{Name, ExePath, GracefulCloseTimeoutSec}] }`
Behavior in `Defer CloseAndReopen ForceClose ScheduleForReboot`
Parsed-but-inert today (model them, mark inert):
- `ApplyMode` (`Nightly Immediate ImmediateReboot`), `UpdateWindow` (`HH:MM-HH:MM`)
Preinstall-only extras (phase discriminator):
- `PreEnrollment`, `KillAfterDetection`, `PCTypesStrict`, `_pcTypesNote`
### Load-bearing behaviors the model must preserve
1. **Array order IS execution order.** Config-restore entries are deliberately
placed AFTER their vendor installer so a mid-cycle overwrite heals the same
cycle (eMxInfo.txt after eDNC; udc_webserver_settings after UDC). We MUST
store an explicit per-scope `sortorder`, not a set.
2. **PCTypes alias graph** is many-to-many old<->new names resolved by set
intersection, with a `PCTypesStrict` escape hatch. Not a simple FK.
3. **Polymorphic entry by Type** - sparse column set per type. DECISION: one
wide `manifestentries` table with an `entrytype` discriminator column and
nullable per-type columns. NOT SQLAlchemy STI subclasses, NOT a JSON blob.
Justification (section 4): the whole fleet is ~64 entries, so sparse columns
cost nothing; real columns get validated, indexed, field-diffed, joined
against collector data, and read in plain SQL by an IT tech - a JSON blob
hides all of that, and class-per-type STI is expert ceremony for no gain. A
`validate()` that switches on `entrytype` (mirroring the engine's own
`switch ($App.Type)`) is ~40 obvious lines.
4. **Two manifest phases** - runtime (self-heal, per logon) and preinstall
(once at imaging) share the schema. One table with a `phase` discriminator.
## 4. Data model (new `geenforce` plugin)
Per-plugin Alembic chain (ADR-008). Tables (lowercase concatenated per naming
convention). Sizing that shapes every decision here: the real fleet is 10
runtime scopes = 43 entries, plus 1 preinstall manifest = 21 entries, so ~64
rows total. That smallness is why this stays deliberately low-tech (one wide
table, JSON-document snapshots, no row-mirroring) - the design target is an
average site IT tech maintaining it, not a specialist.
- `manifestscopes` - one row per imaging PC type / scope.
- `scopeid` PK
- `scopename` (e.g. `gea-shopfloor-cmm`)
- `phase` enum (`runtime` | `preinstall`)
- UNIQUE (`scopename`, `phase`), NOT `scopename` alone: `common` exists in
runtime, and a scope name can appear in both phases. Note the phases are
shaped differently - runtime is many per-pctype scopes (one manifest file
each), preinstall is ONE flat manifest gated internally by `PCTypes`, so
preinstall is modeled as a single `phase=preinstall` scope, not per-pctype
scopes.
- `computertypeid` FK -> `computertypes` (this REPLACES the thin
`pctypemap_<pxetype>` setting; the mapping becomes a column here).
Runtime-scope only; null for the preinstall scope.
- `measuringtooltypeid` FK -> `measuringtooltypes`, nullable (metrology
scopes: what device this scope implies; keeps imaging + collector agreed,
see section 11).
- `manifestversion` (string, mirrors manifest `Version`)
- `description`, `isactive`
- `iscommon` bool (the `common/` fleet-wide scope)
- `manifestentries` - one row per Applications[] entry (the working/draft copy).
- `entryid` PK, `scopeid` FK
- `sortorder` int (preserves array order; the ordering contract)
- `name`, `entrytype` (MSI/EXE/.../Registry), `comment`
- payload columns (nullable, per type): `installer`, `installargs`,
`scriptpath`, `scriptargs`, `sourcepath`, `destination`,
`regpath`, `regname`, `regvalue`, `regtype`
- `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref`
(see section 5)
- `payloadsha256` - integrity hash of the payload bytes, INDEPENDENT of the
detection method. Mandatory for `http`/`inline` payloads; optional for
`smb`. Do NOT reuse `detectionvalue` for this - `detectionvalue` is a
SHA256 only when `detectionmethod = Hash`; an MSI with `Registry`/
`FileVersion` detection has no payload hash, so an HTTP fetch would
otherwise run unverified bytes (see section 5).
- `regvalue` stores the RAW JSON literal (`1` vs `"1"`) and is emitted
verbatim on export. `RegValue` is untyped in the manifest schema and real
entries carry numbers; the engine string-coerces for `ValueMatches` but
`Set-ItemProperty -Type DWord` cares, so preserve the literal.
- detection columns: `detectionmethod`, `detectionpath`, `detectionname`,
`detectionvalue`, `detectionpattern`
- gates: `cmmversion`, plus child tables for the multi-value filters
- control: `logfile`, `waittimeoutsec`, `applymode`, `updatewindow`
(`applymode`/`updatewindow` are parsed-but-INERT in the engine today; the
UI must label them "not yet enforced" so a tech does not trust a dead gate)
- preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict`
- `isactive`
- `manifestpublishedversions` - immutable published snapshots, SIMPLIFIED to
freeze the rendered JSON DOCUMENT in a single `manifestjson` column (drop the
row-mirrored `manifestpublishedentries` family the earlier draft proposed).
The only consumer of a snapshot is the client, and it consumes exactly that
document, so freezing the text makes immutability structural (no UPDATE path),
rollback a one-flag `iscurrent` flip, serving a single-row read, and version
diffing a plain text diff - all things average IT can debug; row-mirroring
would add ~6 shadow tables and a copy routine that can drift. Columns:
`publishedversionid`, `scopeid`, `versionnumber` (1,2,3 per scope),
`manifestjson` (MEDIUMTEXT, verbatim), `publishedat`, `publishedby`,
`iscurrent`, `notes`. Editing `manifestentries` never affects the fleet;
"publish" freezes a new snapshot; the client is ALWAYS served the current
snapshot, never the live draft. Rollback = flip `iscurrent` to an older
version (the post-cutover safety net once the on-share JSON is retired).
Mirrors today's `_meta/history/<date>-<scope>.json` backups, but authoritative.
Revision history: every publish is a permanent, immutable revision kept
indefinitely (snapshots are small JSON text, ~10 scopes - storage is a
non-issue). An OPTIONAL retention policy (keep last M per scope, or prune
older than N months) can be added later; default is keep-everything, off.
- Draft-edit audit trail (field-level history BETWEEN publishes): drafts
(`manifestentries`) are not versioned - editing overwrites the working copy.
To answer "who changed this entry and when" in the window between two
published revisions, log every draft mutation through the EXISTING core audit
system (no new table): on create/update/delete of a scope, entry, or child
row, write an audit record with the actor, timestamp, entry name, and the
changed field(s). This gives per-edit provenance for free and shows up in the
same Audit Logs UI IT already uses; the published snapshots remain the
coarse-grained "what the fleet actually got" record.
- `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers`
- child rows for the ANDed multi-value filters (one value + a `sortorder` per
row, wildcards stored verbatim as patterns)
- `manifestinusechecks` + `manifestinusecheckprocesses`
- the nested InUseCheck object and its Processes[] child list (leave
`gracefulclosetimeoutsec` nullable; do not bake the engine's default of 10
into the row, emit it only when set)
- `manifestpayloads` - inline payload bytes for `payloadsource = inline`
(`entryid`, `filename`, `contenttype`, `payloadbytes` LONGBLOB, `payloadsha256`,
`uploadedat`). App-enforced size cap ~1 MB; the upload UI rejects larger with
"use SMB for this" so nobody pastes an MSI into the database. Can ship empty
and unused until P6.
- `pctypealiases` - a MIRROR of the old<->new name alias graph from
`Install-FromManifest.ps1:463-475`, for server-side resolve/validate only.
The engine lib stays the single source of truth (see section 10); shopdb
never becomes the authority the client depends on for aliases.
The JSON the client receives is REBUILT from a published snapshot in exact
array order. Parity with the current engine is proven by BEHAVIORAL equivalence,
not byte-identity (see section 9): re-serialized JSON will differ in key order
and whitespace, so the test is that both manifests parse to the same ordered
entry set with the same detection/targeting/action semantics.
## 5. Payloads: SMB and/or HTTP (both supported)
The user asked whether payloads can be SMB and/or HTTP. Yes - per entry:
- `payloadsource = smb`: `payloadref` is the current relative path
(`apps/eDNC_6-4-5.msi`); the client still mounts W: and resolves it against
the scope root exactly as today. The engine is unchanged for these rows (the
mount + scope-root resolution still happen; an HTTP-only site skips the mount
because it has no `smb` rows). This is the default and the migration target
for large binaries (MSIs are hundreds of MB; SMB streaming beats HTTP).
- `payloadsource = http`: `payloadref` is a URL (absolute, or relative to a
configured payload base). The client downloads to a local temp dir, verifies
the Hash/FileVersion detection value, then runs it. Good for small
config/script payloads and for sites with no SMB share.
- `payloadsource = inline`: for small text payloads (a `.ps1`, a config file, a
registry value), the bytes live in shopdb itself and are served in-band. No
external store at all. Best for scripts and File-type config drops.
Manifest generation emits, per entry, whatever the client needs to fetch the
bytes. The engine's existing "stage network EXE to local temp first" logic
(SYSTEM access-denied workaround) generalizes cleanly to HTTP download.
Payload integrity uses the dedicated `payloadsha256` column, NOT `DetectionValue`.
This is the correction to a subtle trap: `DetectionValue` is a SHA256 only when
`DetectionMethod = Hash`. Most binaries detect by `Registry` or `FileVersion`
and carry no payload hash at all, so relying on `DetectionValue` would let an
HTTP/inline-fetched MSI run unverified. Instead, publishing an `http`/`inline`
payload computes and stores `payloadsha256`, and the client verifies the fetched
bytes against it BEFORE running, independent of how the entry detects install
state. `smb` payloads may set it too (defense in depth) but the share ACL is
their primary trust boundary. Detection stays a separate concern: it decides
whether to act; the payload hash decides whether the bytes are trustworthy.
Transport security: the client fetches as SYSTEM, so the shopdb TLS cert must be
trusted machine-wide. Sites with a self-signed or air-gapped shopdb need the CA
in the machine trust store (provisioned by the same Azure DSC step that writes
the token). Plain HTTP is acceptable only inside a trusted segment, and even
then the `payloadsha256` check is what actually guarantees payload integrity.
## 6. API surface (`/api/geenforce/...`)
Two permissions via the plugin's `get_permissions()` hook (split so day-to-day
techs can edit but only a lead ships to the fleet):
- `geenforce.manage` - create/edit/reorder scopes, entries, drafts, payloads.
- `geenforce.publish` - publish, rollback, export-to-share (the fleet-affecting
actions).
Draft editing (`geenforce.manage`):
- `GET/POST /scopes`, `GET/PUT/DELETE /scopes/<id>` - imaging PC types
- `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries
- `PUT /scopes/<id>/entries/reorder` - the ordering contract; Move Up/Down in the
UI (plain buttons + visible `sortorder`), not a drag-and-drop dependency
- `POST /entries/<id>/payload` - upload an inline/http payload (multipart),
compute + store its `payloadsha256` (the integrity hash; NOT `detectionvalue`)
- `GET /scopes/<id>/preview` - the draft JSON a client WOULD receive on next
publish; `GET /scopes/<id>/published` shows the currently-served snapshot
- `GET /scopes/<id>/simulate?pctype=&subtype=&hostname=&machinenumber=&cmmversion=`
- the "what would this PC get" simulator: runs the entry list through the same
filter logic the engine uses and returns which entries apply and why the rest
are filtered out. Reuses the P1 parity harness's filter engine, so it is
nearly free, and it is the single most IT-empowering endpoint - it answers
"why did/didn't app X install on PC Y" without reading a PowerShell log.
Publishing (`geenforce.publish`):
- `POST /scopes/<id>/publish` - freeze the current draft into a new immutable
`manifestpublishedversions` snapshot (this is what the fleet gets)
- `POST /scopes/<id>/rollback/<version>` - mark an older snapshot current
- `POST /scopes/<id>/export-share` (or a `flask geenforce export-share` CLI) -
write the current published JSON to `<shareroot>/<scope>/manifest.json` after
copying the existing file to `_meta/history/<date>-<scope>.json`. This is a
first-class feature, not a footnote: it is the Milestone 1 product (author in
shopdb, engine untouched) and the permanent break-glass path.
Client-facing (gated by a collector-style service token, `geenforce.fetch`
scope, reusing the PAT + `X-API-Key` machinery already built for the collector):
- `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>`
Returns the latest PUBLISHED snapshot for that scope (never the live draft).
The server can pre-apply the PCTypes/hostname/machinenumber/cmmversion filters
(thin client) OR return the full scope and let the engine filter (fat client,
matches today). Start fat: return the scope manifest unchanged so the engine
logic is untouched. Include the snapshot version + an ETag so the client can
cache and no-op when unchanged.
- Payload fetch for `http`/`inline` rows: `GET /payload/<entryid>` streaming the
bytes; the client verifies them against `payloadsha256` from the manifest.
## 7. Frontend: expand `/settings/pctypemapping`
The current page (`PCTypeMappingSettings.vue`, "Collector PC Types") is a read-
only-ish table of `pxetype -> ComputerType` dropdowns. It grows into the imaging-
PC-type manager:
- **Scopes list**: add/rename/delete imaging PC types; each still carries its
`ComputerType` mapping (that column moves from a setting into `manifestscopes`).
A `phase` toggle (runtime vs preinstall). Common scope flagged.
- **Scope detail / manifest editor**: an ordered list of entries with Move
Up/Down buttons and a visible `sortorder` (the ordering contract made visible;
NOT drag-and-drop - a drag library is the kind of dependency that breaks
silently and average IT cannot fix; add drag later if wanted). Each entry is a
typed form - the visible fields switch on `entrytype` (MSI shows
Installer+InstallArgs; PS1 shows Script+Args; File shows Source+Destination;
Registry shows the Reg* quartet), one line of help per detection method.
Filter chips for PCTypes/hostnames/machine numbers. InUseCheck sub-editor.
Payload source selector (smb/http/inline) with upload for the latter two.
`applymode`/`updatewindow` sit behind an "Advanced (not yet enforced by the
engine)" disclosure. Ship the editor in three usable-alone increments: (a)
scope list + entry table, (b) the typed entry form, (c) publish + diff. That
keeps the biggest chunk of the build from ballooning.
- **Simulator ("what would this PC get")**: a small form (pctype, subtype,
hostname, machine number, CMM version) that calls `GET /scopes/<id>/simulate`
and lists which entries apply and why the rest are filtered. The single most
IT-empowering piece of the UI.
- **Draft, preview, publish**: editing changes only the draft; "publish" freezes
an immutable snapshot (see section 4) and is what the fleet then gets. Show the
draft-vs-published diff before publishing. Rollback republishes a prior
snapshot.
- **Desired vs observed (BUILT: observed-state reporting)**: rather than extend
the collector, the plugin has its own reporting path. Each enforcement cycle a
PC POSTs `POST /api/geenforce/report` (geenforce.report service token) with the
published version it applied, the installed/skipped/failed/filtered counts, and
per-entry outcomes. Stored in `manifestenforcementreports` (latest-per-host +
history) and `manifestenforcementresults` (per-entry). Two payoffs fall out:
RECEIVED - `receivedlatest` compares the applied version to the scope's current
published version, so the fleet view shows which PCs picked up an update; and
SELF-HEAL - each entry's action (installed = drift corrected, skipped = already
good, failed) with any warning/error message. Admin reads: `GET /reports`
(fleet compliance) and `GET /reports/<id>` (per-entry detail). This is the
observed half that makes the manifest a closed desired-vs-observed loop.
This is an ADR-010 settings card contributed by the geenforce plugin, so it only
appears when the plugin is enabled.
## 8. Client change (minimal, staged)
`GE-Enforce.ps1` today: mount W:, read `<scope>\manifest.json`, hand to
`Install-FromManifest`. New path: GET the manifest from shopdb, write it to the
same local location the engine reads, then run the engine unchanged. That is the
smallest possible client delta - the engine, detection logic, self-heal, and
SMB payload resolution all stay identical. Only the *source of the JSON* moves
from file to HTTP.
Payloads: `smb` rows need no client change. `http`/`inline` rows need a small
fetch-and-verify helper (download to temp, check SHA256, then the existing
installer action runs against the local copy). The engine already stages network
EXEs to temp, so this is an extension, not a rewrite.
Auth: the client already has SFLD credentials in
`HKLM:\SOFTWARE\GE\SFLD\Credentials`. Add a shopdb service token (a
`geenforce.fetch` PAT) provisioned the same way (Azure DSC writes it to
registry), sent as `X-API-Key`. If shopdb is unreachable, the client falls back
to the last-known-good manifest cached locally (fail-safe: never leave a PC
unmanaged because the web app is down). This mirrors today's "creds missing =
exit 0, retry next cycle" resilience.
## 9. Cutover strategy
The manifest is desired-state that runs as SYSTEM and installs software fleet-
wide. A bad cutover = a fleet-wide mis-install. Stage it:
1. **Import + parity.** Write a one-shot importer that reads the current
on-share manifests (common + every `gea-shopfloor-*` + preinstall.json;
skip `.bak` / `.pre-mtconnect.bak` variants) into the new tables. Then
generate JSON back out and prove BEHAVIORAL equivalence for every scope - do
NOT chase byte-identity. Re-serialized JSON will differ in key order,
whitespace, and `_comment` formatting, so a raw `diff` would never converge.
The correct test: parse both the original and the regenerated manifest,
normalize, and assert the same ordered entry list with identical
detection/targeting/action fields per entry (ideally a small harness that
mimics the engine's filter+detect decisions and confirms the same entries
would fire in the same order on representative machine profiles). That, not
byte equality, is what proves the model is lossless. (Same discipline as the
ADR-001 data migration.)
2. **Shadow mode.** shopdb serves the manifest at a new endpoint; a canary PC
fetches from shopdb but ALSO reads the share, and logs any diff. No install
behavior changes. Run across one of each PC type for a few cycles.
3. **Read cutover, payloads still SMB.** Flip GE-Enforce to source the JSON from
shopdb (payloads stay `smb`). The blast radius is only "where the JSON comes
from"; the bytes and engine are unchanged. Keep the share manifests as the
rollback (revert the dispatcher one-liner).
4. **Payload migration (optional, per entry).** Move small scripts/configs to
`inline`/`http` opportunistically. Leave big MSIs on SMB indefinitely - SMB
is the right transport for them.
5. **Author in shopdb.** Once read-cutover is stable, new manifest edits happen
in the shopdb UI and the on-share JSON is retired (or auto-exported as a
backup for break-glass).
Rollback during cutover (stages 2-4) is a one-line dispatcher revert, because
the engine and payload layout never stop working from the share. AFTER the share
JSON is retired (stage 5), that escape hatch is gone - post-cutover rollback is
republishing a prior `manifestpublishedversions` snapshot (section 4). Both
mechanisms must exist before stage 5, not just the dispatcher revert.
## 10. Risks / open questions
- **The engine is the contract.** Any drift between shopdb's generated JSON and
what `Install-FromManifest.ps1` expects is a fleet-wide install bug. The
byte-identical round-trip test (step 1) is non-negotiable, and the plugin must
pin which engine lib version it targets (>= 2.6 for `_CmmVersion`).
- **PCTypes alias graph** must be kept in sync with
`Install-FromManifest.ps1:463-475`. The engine lib stays the single source of
truth; shopdb only MIRRORS the map for server-side validation. Do NOT invert
this to have the engine fetch aliases from shopdb - that would add exactly the
availability coupling the next bullet warns against. When the lib's alias map
changes, update shopdb's mirror as part of shipping that lib version.
- **Availability coupling.** GE-Enforce currently depends only on SMB. Adding an
HTTP dependency on shopdb means shopdb downtime could stall enforcement -
hence the last-known-good local cache in section 8. Must be built in from day
one, not bolted on. This is also why alias resolution and payloads stay
independent of a live shopdb wherever possible.
- **Transport trust.** The client runs as SYSTEM, so shopdb's TLS cert must be
in the machine trust store (self-signed/air-gapped sites need the CA
provisioned via the same DSC step as the token). `payloadsha256` verification
is the real integrity guarantee and holds even over plain HTTP inside a
trusted segment (section 5).
- **Secrets in payloads.** Some config drops (site-config, credentials) may
contain secrets. `inline` payloads live in the shopdb DB - those must respect
the existing "secrets stay in .env, not the settings table" rule. Likely keep
any secret-bearing payload on SMB with ACLs, never inline.
- **Preinstall runner** is a separate consumer (`00-PreInstall-*` at imaging,
before enrollment). It may not have a shopdb token yet at that point in the
imaging sequence. Preinstall may need to stay share-sourced longer than
runtime, or fetch a bootstrap manifest anonymously over HTTP.
- **This is a big build.** Realistically phased: (P1) model + importer +
behavioral-parity test; (P2) admin API + CRUD + publish/snapshot/rollback;
(P3) frontend editor on /settings/pctypemapping; (P4) client fetch + shadow
mode; (P5) read cutover; (P6) payload migration. P1 is the gating de-risk - if
behavioral parity does not hold, stop. Snapshots (P2) must land before any
client points at shopdb (P4), since serving the live draft to the fleet is
unacceptable.
## 11. Relationship to existing work
- Replaces `plugins/computers/pctypemap.py` (the thin `pctypemap_<pxetype>`
settings) - the pctype -> ComputerType mapping becomes the `computertypeid`
column on `manifestscopes`. Two-source transition window: `pctype_mapping()`
must keep reading the settings until the geenforce plugin is enabled, then
fall back geenforce-table-first / settings-second, and only retire
`seed_pctype_settings` + the settings at Milestone 1 close. Also reconcile the
scope inventory: `pctypemap.py` lists `gea-shopfloor-display` but the share has
no such manifest dir, and the share has a `main/` legacy dir the model ignores
- the importer creates scopes only from what it finds (plus empty scopes for
mapped-but-absent pctypes), and the P1 gate review reconciles the list with
the floor team.
- Also folds in the metrology mapping now living in `pctypemap.py`
(`METROLOGY_TOOL_MAP`). The collector already auto-creates a MeasuringTool
asset and a directional PC->tool `controls` relationship when it sees a
metrology pctype (CMM / Keyence / Genspect / wax-and-trace); the PC stays a
shopfloor PC. A metrology scope in the manifest model should carry the
attached-measuring-tool type alongside its ComputerType so imaging and
collector agree on what device the scope implies.
- Reuses the collector's token machinery (PAT + `X-API-Key` + scopes) for the
client-facing endpoints.
- Reuses `get_permissions()` (contract 0.10.0) for `geenforce.manage` (edit
drafts) / `geenforce.publish` (publish, rollback, export) / `geenforce.fetch`
(the client service token).
- Pairs with the collector: desired-state (this plugin) + observed-state
(collector) enable a fleet compliance view.
## 12. Recommendation
Feasible and a strong architectural fit, but it is a multi-phase build with a
fleet-wide blast radius. The single most important gate is P1: import the real
manifests and prove BEHAVIORAL parity (same entries fire in the same order with
the same detection/targeting), not byte-identity. Do not build the UI or touch a
client until that parity holds. Three things separate a safe build from a
dangerous one and must not be cut: behavioral-parity import (P1), immutable
published snapshots with rollback before any client points at shopdb (P2/P4),
and a dedicated `payloadsha256` for every HTTP/inline payload (section 5). If and
when we proceed, this warrants a new ADR (ADR-012: GE-Enforce manifest
ownership) capturing the desired-state model, the published-snapshot contract,
the SMB/HTTP/inline payload + integrity model, and the fail-safe cache.
## 13. Execution plan (build order, gates, milestones)
Governing constraint: every step must be runnable and maintainable by average
site IT, not just the original developer. Where an earlier draft implied expert
machinery, this section simplifies it (and the model above already reflects
those simplifications: one wide table, JSON-document snapshots, no row-mirroring).
### Phases and gates
- **P0 - Scaffold (S, ~0.5-1 day).** `flask plugin new geenforce`, structure
copied from `plugins/measuringtools/`. Unlike bundled plugins' no-op migration
anchors, this NEW plugin's `0001_geenforce_baseline` actually creates the
tables and registers them in `PLUGIN_TABLE_OWNERS` (ADR-008). Deploy stays the
standard `flask db upgrade` + `flask plugin upgrade-all`. Manifest:
`api_prefix: /api/geenforce`, `default_enabled: false`, tight `core_version`.
- **P1 - Model + importer + parity harness (M, ~1-1.5 wk). THE GATE.** Order
inside: tables -> `flask geenforce import-share` (reads common + every
`gea-shopfloor-*` + preinstall.json, skips `.bak`, idempotent) -> exporter
(rebuilds each scope's JSON from rows in `sortorder`) -> the parity harness
(below). **GATE A:** `flask geenforce parity` prints PASS for all scopes. If
it cannot pass, STOP the project. No API/UI/client work before Gate A.
- **P2 - Publish/snapshot/rollback + admin API + export-to-share (M, ~1.5-2 wk).**
Publish freezes rendered JSON into `manifestpublishedversions`. CRUD per
section 6. Plus `flask geenforce export-share` + an "Export to share" button
that writes each scope's published JSON to the share after backing up the old
file to `_meta/history/`. Engine, dispatcher, share layout, payloads, PCs all
untouched. **GATE B = Milestone 1** (below).
- **P3 - Frontend editor (L, ~2-3 wk; parallel with P4 after P2 API freezes).**
Expand `PCTypeMappingSettings.vue` per section 7, in three shippable
increments; Move Up/Down not drag; the simulator.
- **P4 - Client fetch + shadow mode (M effort + soak time; needs P2, not P3).**
Week-1 spike: a ~20-line PS1 on ONE canary PC proves SYSTEM-context HTTP auth +
TLS trust before any real client change. Then `GE-Enforce.ps1` fetches JSON to
a local cache and hands the file to `Install-FromManifest.ps1` unchanged;
shadow mode installs from the share but logs any diff vs shopdb; ETag +
last-known-good cache from day one. **GATE C:** zero shadow diffs across one PC
of every pctype for >= 20 cycles.
- **P5 - Read cutover (S effort, M calendar).** Per-scope flip, canary first via
`TargetHostnames`. Payloads stay `smb`. Rollback = dispatcher revert; share
export continues as break-glass. **GATE D:** all scopes cut over.
- **P6 - Payload migration (S per entry, optional forever).** Small configs to
`inline` (verified by `payloadsha256`); MSIs stay on SMB. Each entry
independently revertible (flip `payloadsource`).
Hard ordering: P0 -> P1 -> P2 -> rest. **Snapshots (P2) MUST precede any client
pointing at shopdb (P4).** P3 and P4 parallelize. Preinstall stays share-sourced
through at least Milestone 1 (no token pre-enrollment; export writes
`preinstall.json` too, so it is authored-in-shopdb for free with no client risk).
### The P1 parity harness (concrete, IT-re-runnable)
`plugins/geenforce/parity.py` + a CLI, also wrapped as a CI test. Two checks per
scope, output one readable line per scope (`entries N/N identical profiles M/M
same-fire PASS`), exit 0/1, prints the first differing entry/field on fail:
1. **Lossless field check (order-preserving).** Canonicalize each entry to
exactly the fields the engine reads (Name, Type, the payload fields, all
Detection*, the filter arrays, `_CmmVersion`, InUseCheck, preinstall flags);
exclude `_comment` and key order (documentation, not behavior). Compare the
ordered lists position by position.
2. **Same-entries-fire-in-same-order.** Re-implement in ~120 lines of Python the
engine's four filter functions exactly as written in `Install-FromManifest.ps1`
(`Test-PCTypeMatches` incl. the alias groups at lines 463-475, `"*"`, and
`<Type>-<SubType>`; `Test-HostnameMatches` exact + `-like`;
`Test-MachineNumberMatches`; `Test-CmmVersionMatches`). For each machine-
profile fixture, run BOTH manifests through it and assert the identical
ordered list of entry names that pass all filters. Detection itself is not
executed - check 1 already proved detection fields identical, so identical
inputs to detection are guaranteed. This pair proves losslessness without
byte-diffing.
Fixtures (`plugins/geenforce/parityfixtures.json`, ~16-18 profiles): one per
pctype; CMM version variants `2016/2019/2026`/empty; collections machine-number
variants (a credentialed bay, an MTConnect bay, neither); legacy-alias profiles
(`Standard`+`Machine`, `CMM`) to exercise the alias graph both ways; a `WJS-*`
hostname-wildcard profile; preinstall profiles including one that hits
`PCTypesStrict`. Watch-items the harness must handle: empty `Applications: []`
scopes (4 exist), entries with NO `DetectionMethod` (fire every run), and the
`regvalue` literal typing.
### First slice: one vertical through `gea-shopfloor-cmm`
Only 4 entries but hits every hard part - MSI type, Registry detection with and
without a pinned value, nested InUseCheck with Processes[], and the `_CmmVersion`
gate. Tables: scopes, entries, entrypctypes, inusechecks + processes,
publishedversions, pctypealiases. `flask geenforce import-share --scope
gea-shopfloor-cmm`; `flask geenforce publish gea-shopfloor-cmm`; one endpoint
`GET /api/geenforce/manifest?pctype=gea-shopfloor-cmm` serving the published
snapshot (fat-client, ETag, collector-style `X-API-Key`/PAT auth reusing
`shopdb/core/api/collector.py`). **Done =** parity PASS for cmm; the endpoint's
JSON fed to `Install-FromManifest.ps1` on a bench CMM PC logs `4 skipped`
identically to the share manifest; editing a draft does NOT change the served
bytes but publishing does, and rollback restores the prior published bytes;
unauth = 401, wrong-scope = 401.
### Milestone 1 (the recommended first stop)
End of P2 plus the publish/scope-list slice of P3: **manifests are authored and
published in shopdb, exported to the share by a button, and the engine,
dispatcher, share layout, payloads, and every PC are completely unchanged.**
That delivers the real pain relief - validated editing instead of hand-edited
JSON, version history, one-click rollback (republish + re-export), desired-state
data sitting next to collector data - at ZERO client risk, with a rollback any
IT tech already knows (restore the `_meta/history` backup file). Natural point to
write ADR-012 with real experience behind it. P4/P5 (HTTP fetch, cutover) are a
separately green-lit second milestone.
### Ranked risks / fail-fast
1. **Generated-JSON vs engine drift (fleet-wide mis-install).** Parity harness
first; CI re-proves parity against checked-in real manifests on every
exporter change; pin lib >= 2.6.
2. **Serving a half-finished draft.** Structural: client reads only
`iscurrent` snapshots; test asserts a draft edit leaves served bytes
unchanged. Must exist before P4.
3. **Availability coupling.** Last-known-good local cache in the first client
prototype; shadow test blocks shopdb and confirms enforce-from-cache + WARN.
4. **SYSTEM HTTP auth + TLS trust.** The ~20-line canary spike in P4 week 1,
before the real client change. Hours of cost; if it fails, Milestone 1 still
delivers full value.
5. **Alias-graph drift.** Seed pins a lib version; harness legacy-name profiles
fail loudly on divergence; new-lib runbook includes "update the alias seed".
6. **Preinstall has no pre-enrollment token.** Keep share-sourced through
Milestone 1/2; decide later.
7. **Editor scope creep.** Three shippable increments; buttons over drag; reuse
JSON preview.
### IT operability (day-to-day runbook, proving the design is manageable)
All in Settings > Imaging PC Types. No PowerShell, no SQL, no share edits.
- **Add an app to a PC type:** open the PC type, Add Entry, pick Type (fields
adapt), fill installer + detection + targeting, Move Up/Down to order, Preview
(+ simulator), Publish with a note. PCs pick it up next 5-min cycle.
- **Bump a version:** drop the new MSI in the scope's `apps/` on the share,
update the entry's Installer + Detection value, Preview, Publish.
- **Roll back a bad publish:** History -> pick last-good version -> Roll Back
(during Milestone 1 also click Export to Share).
- **Canary a risky change:** add the one test PC under Target Hostnames, Publish;
when happy, remove the filter and Publish again.
- **Check "did PC Y get app X":** the simulator with that PC's type/machine
number/CMM version shows exactly which entries apply and why others are filtered.
- **See revision history / who changed what:** the PC type's History tab lists
every published version (date, author, note) with a Roll Back on each; the
Audit Logs page shows the finer-grained draft edits (who touched which entry
field, when) between publishes.

View File

@@ -0,0 +1,208 @@
# Proposal: printedparts plugin (3D-printed parts storefront + kiosk)
Status: PROPOSED (also serves as the reference design for the plugin-development
lab in `docs/PLUGIN-LAB-PRINTEDPARTS.md`)
## 1. Problem
The 3D-printer engineers stock bins of printed parts (fixtures, clips, covers,
spacers). Anyone on the floor can take parts, so stock silently runs out and
nobody knows who took what or how fast items burn down. They need:
- a catalog ("storefront") of printable items: photo, description, quantity on
hand;
- a barcode label per item (1in x 0.5in) stuck on each bin;
- a touch-screen kiosk: scan the bin barcode, scan your badge, enter how many
you took, submit;
- restock and correction flows for the engineers;
- stock monitoring plus consumption metrics.
## 2. Shape: standalone model plugin, NOT an asset type
These are quantity-based consumables: one row represents a *kind* of part with
a count, not an individually tracked machine. ADR-001 assets are one-row-per-
physical-thing (a PC, a printer). So printedparts follows the
knowledgebase/usb shape - own tables, own blueprint, no AssetType row - and
does NOT join the asset-label TYPE_CONFIG; it ships its own print view the way
USB labels do.
Item identity: `itemcode`, generated `3DP-<zero-padded id>` (prefix
configurable via setting `printedparts_code_prefix`). Short, CODE128-friendly,
human-readable. This is what the bin label encodes.
## 3. Data model (2 tables, LOCKED naming, per-plugin Alembic)
### printeditems
| column | type | notes |
|---|---|---|
| printeditemid | int PK autoincrement | |
| itemcode | varchar(20) unique, indexed | generated on create |
| itemname | varchar(120) NOT NULL | |
| itemdescription | varchar(500) | brief description |
| imageurl | varchar(255) | served upload, models.py pattern |
| quantityonhand | int NOT NULL default 0 | cached; ledger is truth |
| lowstockthreshold | int NOT NULL default 5 | per-item, seeds from setting |
| binlocation | varchar(100) | where the bin lives |
| printnotes | mediumtext | material, print time, slicer file path |
| isactive | tinyint(1) | soft retire |
| createddate / modifieddate | datetime | AuditMixin/BaseModel |
### printeditemtransactions (the ledger - source of truth)
| column | type | notes |
|---|---|---|
| transactionid | int PK | |
| printeditemid | int FK -> printeditems CASCADE, indexed | |
| transactiontype | varchar(10) NOT NULL | take / restock / adjust |
| quantitychange | int NOT NULL | negative for take, signed for adjust |
| employeesso | varchar(20) NOT NULL, indexed | who (badge-resolved) |
| employeename | varchar(120) | resolved at write time (USB pattern) |
| reason | varchar(255) | required for adjust |
| transactiondate | datetime NOT NULL default naive-UTC, indexed | |
Invariants: `quantityonhand` = sum of `quantitychange` (enforced by writing
both in one session/commit; an `adjust` can never drive it below 0 - reject).
Every write records WHO via badge scan; there is no anonymous mutation.
Both tables registered in `PLUGIN_TABLE_OWNERS`
(`shopdb/plugins/alembic_template.py`); migration 0001 is a REAL baseline
(measuringtools pattern - hand-written `op.create_table`, no cross-schema FK
so `create_plugin_tables` would also work, but write the ops explicitly for
the exercise).
## 4. Badge resolution (reuse the USB contract exactly)
Same input shapes as `plugins/usb/api/routes.py`:
- all digits -> SSO;
- `0<digits>BZ` (case-insensitive) -> physical badge wrapping a PayNo;
- resolution to a display name via the employees plugin directory
(`DirectoryEmployee`, selfhosted mode) with graceful "" fallback.
Extract-or-copy decision for the lab: copy the small `_PAYNO_BADGE` regex +
lookup into the plugin (contract-pure, no cross-plugin import of usb).
Resolution happens SERVER-side on the kiosk endpoint - the kiosk client never
supplies a name, only the raw badge string.
Manifest `dependencies: ["employees"]` (name lookup). Badge that resolves to
no employee: configurable policy setting `printedparts_unknown_badge`
(`allow` = record SSO with empty name, `deny` = 422). Default deny.
## 5. API surface (blueprint at /api/printedparts)
Authenticated management (JWT + permission):
| route | method | permission |
|---|---|---|
| `/items` | GET list (search, paginate, lowstock filter) | open read (jwt optional) |
| `/items/<id>` | GET detail + recent transactions | open read |
| `/items` | POST create (mints itemcode) | printedparts.create |
| `/items/<id>` | PUT update | printedparts.edit |
| `/items/<id>` | DELETE soft-retire | printedparts.delete |
| `/items/<id>/image` | POST/DELETE upload/remove | printedparts.edit |
| `/image/<filename>` | GET serve | public (models.py pattern) |
| `/items/<id>/restock` | POST {quantity, badge} | printedparts.restock |
| `/items/<id>/adjust` | POST {quantitychange, reason, badge} | printedparts.restock |
| `/items/<id>/transactions` | GET history, ?format=csv | open read |
Kiosk (unauthenticated, notifications/employees open-endpoint precedent):
| route | method | body |
|---|---|---|
| `/kiosk/item/<itemcode>` | GET | item summary by scanned code |
| `/kiosk/take` | POST | {itemcode, badge, quantity} |
`/kiosk/take` validation: item exists + active; quantity 1..quantityonhand
(clamp/reject configurable? no - reject with clear message, kiosk shows it);
badge resolves per policy. Writes ledger row (negative) + decrements cached
quantity in one commit. Rate of abuse is low (plant floor), but the endpoint
only ever DECREMENTS stock with a recorded badge - it cannot edit the catalog.
Permissions declared via `get_permissions()`: printedparts.view/create/edit/
delete/restock (category `printedparts`), seeded on install/enable.
## 6. Frontend
Management pages (scaffold output, standard layout, master templates
PrintersList/PrinterDetail):
- `PrintedItemsList.vue` - table: image thumb, code, name, qty (red badge when
<= threshold), bin; filters: search, low-stock-only; row click -> detail.
- `PrintedItemDetail.vue` - hero image + fields, transaction history table,
restock/adjust buttons (modal w/ quantity + badge + reason).
- `PrintedItemForm.vue` - create/edit incl. image upload, threshold, bin.
- Router file `router/routes/printedparts.js`, list/detail plugin-gated only,
new/edit + requiresAuth (ADR-009, usb.js precedent).
- Nav via `get_navigation_items()` -> "3D Parts".
Kiosk (net-new, top-level route `/parts-kiosk`, NO requiresAuth, outside
AppLayout - shopfloor precedent):
- Full-screen, 3-step flow: (1) SCAN ITEM - a focused invisible input catches
the keyboard-wedge scan of the bin barcode, shows item card w/ photo + qty;
(2) SCAN BADGE - same wedge input pattern for the badge; (3) QUANTITY - big
touch keypad (0-9, clear, backspace - net-new component
`TouchKeypad.vue`) + TAKE button. Success screen w/ remaining count, auto
reset after a few seconds. All state client-side; one POST at the end.
- Scanner UX rule: keyboard-wedge scanners type the code + Enter. A hidden
always-focused input with @keydown.enter handles both scans; on-screen
prompt tells the user what to scan. Touch fallback: item search + manual
badge entry (small link, for damaged labels).
Labels (own print view, USBLabelBatch precedent):
- `/print/printedparts-labels` public print route.
- NEW physical size: 1in x 0.5in stock -> `@page { size: 1in 0.5in; margin: 0 }`
one label per page (label printers feed roll stock; per-page = per-label).
Layout: CODE128 barcode (JsBarcode, ~0.9in x 0.28in, displayValue false) +
itemcode text under it (~7pt) + optional item name truncated. QR variant
offered but barcode is default at this size (a 0.4in QR is at the edge of
scanner tolerance; CODE128 of `3DP-0042` is comfortable).
- Batch mode: pick items -> one label per page sequence for roll printers;
also a ULINE mini-grid fallback for sheet printers (reuse mini72 pattern).
## 7. Metrics / reports (get_reports hook)
- `printedparts-stock` - current stock levels w/ threshold flags (CSV).
- `printedparts-consumption` - takes per item over a date range (CSV).
- `printedparts-by-person` - takes grouped by employee (CSV).
- Dashboard widget via `get_dashboard_widgets()`: low-stock item count.
- Nice-to-have later: burn-rate (avg takes/week per item + weeks-to-empty
projection) - plain SQL over the ledger, add once basics work.
## 8. Settings (get_settings_cards, category printedparts)
| key | default | purpose |
|---|---|---|
| printedparts_code_prefix | 3DP | itemcode prefix |
| printedparts_default_threshold | 5 | seed for new items |
| printedparts_unknown_badge | deny | kiosk policy for unresolvable badges |
## 9. Manifest
name printedparts, version 0.1.0, api_prefix /api/printedparts,
core_version ">=0.11.0,<1.0.0", dependencies ["employees"],
default_enabled false (site opts in - USB precedent).
## 10. Explicitly out of scope (v1)
- Reservations/approvals, per-item cost, print-queue integration, multi-bin
per item, email low-stock alerts (the reports + dashboard widget cover
monitoring; alerting can ride the existing report-email endpoint later).
## 11. Risks / decisions taken
- Cached quantity vs ledger drift: single-commit writes + a reconcile query in
the stock report (flags items where cache != ledger sum).
### Decision: the kiosk take endpoint is an unauthenticated WRITE
This is the first open mutation in the product - every existing kiosk
endpoint (notifications, employees, shopfloor) is a read, and the closest
write (USB checkout) is JWT + permission gated. Accepted deliberately, on
these grounds, and any future open-write endpoint must meet the same bar:
1. Decrement-only: it can reduce stock of an active item, nothing else - no
catalog edits, no restocks, no reads it does not already expose.
2. Fully attributed: it refuses to act without a badge that resolves per the
site policy; every action lands in the ledger with SSO + name + time.
3. Bounded blast radius: worst case is stock counts driven low, which the
ledger makes visible and reversible (adjust with reason).
4. Physically rate-limited: it exists for a touch screen on the shop floor;
there is nothing to enumerate and nothing returned worth scraping.
- 1x0.5in QR marginal: default to CODE128 barcode.
- Not an Asset: no floor-map plotting or warranty for items. If a site later
wants bins on the floor map, revisit via get_map_overlays (ADR-010).

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
@@ -25,6 +27,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.4.1"
"@vue/test-utils": "^2.4.6",
"jsdom": "^25.0.1",
"vite": "^6.4.1",
"vitest": "^2.1.9"
}
}

View File

@@ -1,7 +1,10 @@
import axios from 'axios'
import { withBase, stripBase } from './../utils/basePath'
// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath
// mount. Keeps the SPA, its API, and IIS all on the same mount path.
const api = axios.create({
baseURL: '/api',
baseURL: import.meta.env.BASE_URL + 'api',
headers: {
'Content-Type': 'application/json'
}
@@ -31,10 +34,13 @@ api.interceptors.response.use(
// Only redirect if user was previously logged in (session expired).
// Preserve the destination so login returns the user to this page.
if (hadToken) {
const here = window.location.pathname + window.location.search
const loginPath = withBase('/login')
// Router paths exclude the mount base; strip it or login's
// router.push double-prefixes under a subpath mount.
const here = stripBase(window.location.pathname) + window.location.search
const target = here && here !== '/login'
? '/login?redirect=' + encodeURIComponent(here)
: '/login'
? loginPath + '?redirect=' + encodeURIComponent(here)
: loginPath
window.location.href = target
}
}
@@ -1120,3 +1126,44 @@ export const measuringtoolsApi = {
}
}
}
// 3D printed parts (printedparts plugin)
export const printedpartsApi = {
list(params = {}) {
return api.get('/printedparts/items', { params })
},
get(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}`)
},
create(data) {
return api.post('/printedparts/items', data)
},
update(printeditemid, data) {
return api.put(`/printedparts/items/${printeditemid}`, data)
},
remove(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}`)
},
uploadImage(printeditemid, file) {
const formData = new FormData()
formData.append('file', file)
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
deleteImage(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}/image`)
},
restock(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
},
adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
},
kioskItem(itemcode) {
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
},
kioskTake(data) {
return api.post('/printedparts/kiosk/take', data)
}
}

View File

@@ -624,26 +624,26 @@ input[type="radio"] {
cursor: pointer;
}
/* Dark mode form adjustments */
@media (prefers-color-scheme: dark) {
.form-control {
background: var(--bg);
border-color: var(--border);
}
/* Dark mode form adjustments. Scoped to the explicit theme attribute (the
theme store always stamps it at startup) - a bare prefers-color-scheme
query here leaks dark widget styles into light mode on dark-OS machines. */
[data-theme="dark"] .form-control {
background: var(--bg);
border-color: var(--border);
}
.form-control:focus {
background: var(--bg);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
[data-theme="dark"] .form-control:focus {
background: var(--bg);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-color: var(--bg);
}
[data-theme="dark"] select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-color: var(--bg);
}
select.form-control option {
background: var(--text);
}
[data-theme="dark"] select.form-control option {
background: var(--bg-card-solid);
}
/* Form grid */
@@ -941,8 +941,17 @@ input[type="radio"] {
}
td.actions {
/* A td must stay display:table-cell - the .actions inline-flex above pulls
the cell out of the table's row box, so its bottom border renders ~1px off
from the other cells. Keep it a cell; space multiple buttons with a margin
instead of the flex gap. */
display: table-cell;
vertical-align: middle;
white-space: nowrap;
}
td.actions .btn + .btn {
margin-left: 0.25rem;
}
/* ============================================
DETAIL PAGES (shared styles)
@@ -1051,17 +1060,19 @@ td.actions {
}
/* Content Grid */
/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned
grid) so cards distribute by height and the columns stay even no matter
how many cards land on either side. display:contents flattens the two
.content-column wrappers so their cards flow directly into the columns,
which keeps the existing markup unchanged. */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 25px;
column-count: 2;
column-gap: 25px;
margin-bottom: 25px;
}
.content-column {
display: flex;
flex-direction: column;
gap: 25px;
display: contents;
}
/* Section Cards */
@@ -1070,6 +1081,10 @@ td.actions {
border-radius: 0.25rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
/* multicol needs per-card spacing (column-gap is horizontal only) and
must not split a card across the column break */
margin-bottom: 25px;
break-inside: avoid;
}
.section-title {
@@ -1484,7 +1499,7 @@ td.actions {
}
.content-grid {
grid-template-columns: 1fr;
column-count: 1;
}
.audit-footer {
@@ -1711,16 +1726,14 @@ td.actions {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.notification-item.type-incident {
background: rgba(245, 54, 92, 0.1);
}
.notification-item.type-change {
background: rgba(255, 136, 0, 0.1);
}
.notification-item.type-awareness {
background: rgba(4, 185, 98, 0.1);
}
[data-theme="dark"] .notification-item.type-incident {
background: rgba(245, 54, 92, 0.1);
}
[data-theme="dark"] .notification-item.type-change {
background: rgba(255, 136, 0, 0.1);
}
[data-theme="dark"] .notification-item.type-awareness {
background: rgba(4, 185, 98, 0.1);
}
/* Light mode is now default, dark mode via prefers-color-scheme */
@@ -1736,3 +1749,9 @@ td.actions {
color: var(--text-light);
cursor: pointer;
}
/* Clickable list rows: the whole row navigates to the item detail; interactive
cells (the actions column, in-row links) stop propagation so they still work
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }

View File

@@ -196,12 +196,12 @@ import { apiError } from '../utils/apiError'
const toast = useToast()
const props = defineProps({
assetId: {
assetid: {
type: Number,
default: null
},
// Alternative: lookup by machine/asset number
machineNumber: {
machinenumber: {
type: String,
default: null
}
@@ -315,14 +315,14 @@ onMounted(async () => {
}
})
watch(() => props.assetId, async () => {
watch(() => props.assetid, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
}
})
watch(() => props.machineNumber, async () => {
watch(() => props.machinenumber, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
@@ -330,17 +330,17 @@ watch(() => props.machineNumber, async () => {
})
async function resolveAssetId() {
// If assetId is provided directly, use it
if (props.assetId) {
resolvedAssetId.value = props.assetId
// If assetid is provided directly, use it
if (props.assetid) {
resolvedAssetId.value = props.assetid
lookupFailed.value = false
return
}
// Otherwise, try to look up by machine number
if (props.machineNumber) {
if (props.machinenumber) {
try {
const response = await assetsApi.lookup(props.machineNumber)
const response = await assetsApi.lookup(props.machinenumber)
resolvedAssetId.value = response.data.data?.assetid
lookupFailed.value = !resolvedAssetId.value
} catch (error) {
@@ -502,6 +502,11 @@ function getAssetRoute(asset) {
border-radius: 8px;
border: 1px solid var(--border);
padding: 1.25rem;
/* Match .section-card spacing so relationships is its own card with a gap
below (not visually merged with the Notes card) and does not split across
a multicol break. */
margin-bottom: 25px;
break-inside: avoid;
}
.section-header {

View File

@@ -0,0 +1,38 @@
<template>
<div class="touch-keypad">
<button v-for="digit in digits" :key="digit" type="button"
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('clear')">C</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('backspace')">&lt;</button>
</div>
</template>
<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
max-width: 20rem;
}
.keypad-button {
font-size: 1.8rem;
padding: 1rem 0;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
}
.keypad-button:active { background: var(--primary); color: #fff; }
.keypad-muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,102 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
// state and remounts at page 1 after visiting a detail page and going Back.
//
// Usage in a list view:
// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows })
// goToPage(p) -> setPage(p); loadRows()
// debouncedSearch -> setSearch(search.value); loadRows()
// filter reset -> setPage(1); loadRows()
//
// Options:
// onChange callback run when the query changes from outside (Back/Forward,
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
const onChange = options.onChange || (() => {})
const extraKeys = options.extraKeys || []
const page = ref(parseInt(route.query.page, 10) || 1)
const search = ref(route.query.q || '')
const extras = {}
for (const key of extraKeys) {
extras[key] = ref(route.query[key] || '')
}
// Build the next query. Keep unrelated keys intact. Write page only when > 1
// and search only when non-empty so the clean state is a bare path.
function buildQuery() {
const query = { ...route.query }
if (page.value > 1) query.page = String(page.value)
else delete query.page
if (search.value) query.q = search.value
else delete query.q
for (const key of extraKeys) {
if (extras[key].value) query[key] = String(extras[key].value)
else delete query[key]
}
return query
}
// replace (not push) so paging does not spam history; Back leaves the list.
function syncUrl() {
const query = buildQuery()
const current = route.query
const keys = new Set([...Object.keys(query), ...Object.keys(current)])
let same = true
for (const key of keys) {
if (String(query[key] ?? '') !== String(current[key] ?? '')) {
same = false
break
}
}
if (!same) router.replace({ query })
}
function setPage(newPage) {
page.value = newPage
syncUrl()
}
// Changing the search resets to page 1.
function setSearch(term) {
search.value = term
page.value = 1
syncUrl()
}
// Changing an extra filter resets to page 1.
function setExtra(key, value) {
extras[key].value = value
page.value = 1
syncUrl()
}
// Re-sync refs when the query changes from outside (Back/Forward, deep link).
// Fire onChange only when page/search/extras actually changed so a self
// syncUrl() call does not trigger a redundant reload.
watch(() => route.query, (newQuery) => {
const newPage = parseInt(newQuery.page, 10) || 1
const newSearch = newQuery.q || ''
let changed = false
if (newPage !== page.value) { page.value = newPage; changed = true }
if (newSearch !== search.value) { search.value = newSearch; changed = true }
for (const key of extraKeys) {
const newValue = newQuery[key] || ''
if (newValue !== String(extras[key].value || '')) {
extras[key].value = newValue
changed = true
}
}
if (changed) onChange()
})
return { page, search, extras, setPage, setSearch, setExtra }
}

View File

@@ -5,6 +5,7 @@
// install still renders; each site uploads its own blueprint in Settings.
import { reactive } from 'vue'
import { settingsApi } from '../api'
import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
@@ -59,7 +60,7 @@ export function reloadMapConfig() {
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return theme === 'light' ? state.blueprintLight : state.blueprintDark
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
}
export function useMapConfig() {

View File

@@ -65,6 +65,14 @@ const routes = [
name: 'shopfloor',
component: () => import('../views/ShopfloorDashboard.vue')
},
{
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
// Open on purpose - see the decision record in the printedparts proposal.
path: '/parts-kiosk',
name: 'parts-kiosk',
component: () => import('../views/printedparts/PartsKiosk.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/tv',
name: 'tv',
@@ -109,6 +117,12 @@ const routes = [
component: () => import('../views/print/USBLabelBatch.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/',
component: AppLayout,
@@ -117,7 +131,7 @@ const routes = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes
})

View File

@@ -0,0 +1,30 @@
/**
* GE-Enforce plugin routes.
*
* A top-level section (not under /settings) - the manifest editor + fleet
* reports are a large operational surface, so they get their own full-width
* shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the
* section when the plugin is disabled. Admin-only.
*/
export default [
{
path: 'geenforce',
component: () => import('../../views/geenforce/GeEnforceLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' },
children: [
{ path: '', redirect: '/geenforce/manifests' },
{
path: 'manifests',
name: 'geenforce-manifests',
component: () => import('../../views/geenforce/ManifestEditor.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
},
{
path: 'reports',
name: 'geenforce-reports',
component: () => import('../../views/geenforce/EnforcementReports.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]
}
]

View File

@@ -5,7 +5,7 @@ export default [
{
path: 'network',
name: 'network',
component: () => import('../../views/network/NetworkDevicesList.vue'),
component: () => import('../../views/network/NetworkHub.vue'),
meta: { plugin: 'network' }
},
{
@@ -26,6 +26,17 @@ export default [
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true, plugin: 'network' }
},
{
// Legacy path -> the Networks tab of the hub.
path: 'networks',
redirect: { path: '/network', query: { tab: 'networks' } }
},
{
path: 'networks/:id',
name: 'network-subnet-detail',
component: () => import('../../views/network/SubnetDetail.vue'),
meta: { plugin: 'network' }
},
// Network-specific settings
{
path: 'settings/vlans',

View File

@@ -0,0 +1,41 @@
/**
* Printedparts plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009
* guard redirects to the dashboard when the printedparts backend plugin is
* disabled. Form routes add requiresAuth so anonymous users cannot reach
* create or edit.
*/
export default [
{
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/new',
name: 'printedparts-new',
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',
name: 'printedparts-edit',
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'settings/printedparts',
name: 'settings-printedparts',
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
}
]

View File

@@ -0,0 +1,25 @@
// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL
// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS
// mount). Every root-absolute URL to a Flask-served asset or route must go
// through withBase() so it resolves under the mount instead of the server root.
export const BASE_URL = import.meta.env.BASE_URL
// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv')
// with the mount base. Leaves full URLs (http, data:) untouched.
export function withBase(path) {
if (!path) return path
if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path
return BASE_URL + String(path).replace(/^\//, '')
}
// Inverse of withBase: turn a browser pathname (which includes the mount
// base, e.g. '/ops/computers') into a router path ('/computers'). Router
// navigation already applies the base; feeding it an un-stripped pathname
// double-prefixes ('/ops/ops/...').
export function stripBase(path) {
if (!path) return path
if (BASE_URL !== '/' && path.startsWith(BASE_URL)) {
return '/' + path.slice(BASE_URL.length)
}
return path
}

View File

@@ -2,6 +2,7 @@
// The settings GET is public (jwt optional), so the kiosk dashboard and the
// print views can read these without auth. Fetched once and cached per page.
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
let settingsCache = null
@@ -38,7 +39,7 @@ export async function getFacilityName() {
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
export async function getSiteLogo() {
return getSetting('site_logo', '/ge-aerospace-logo.svg')
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))
}
// Logo composited into the center of printer QR codes. Empty = no overlay.
@@ -47,17 +48,18 @@ export async function getSiteLogo() {
export async function getQrLogo() {
const settings = await loadSettings()
const value = settings['qr_logo']
return (value === undefined || value === null) ? '/ge-monogram.svg' : value
return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value)
}
// Logo printed on machine inspection badges.
export async function getBadgeLogo() {
return getSetting('badge_logo', '/ge-aerospace-logo.svg')
return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg'))
}
// Browser-tab favicon. Empty = keep the shipped /favicon.svg.
export async function getFavicon() {
return getSetting('site_favicon', '')
const value = await getSetting('site_favicon', '')
return value ? withBase(value) : value
}
// Brand primary color override. Empty = built-in palette from style.css.

View File

@@ -25,8 +25,8 @@
</template>
<div class="nav-section">Displays</div>
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a href="/tv" target="_blank" class="external-link">TV Slideshow</a>
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav>
@@ -101,12 +101,13 @@ import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
KeyRound, LogOut
Box, KeyRound, LogOut
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
import { withBase } from '../utils/basePath'
const router = useRouter()
const route = useRoute()
@@ -118,7 +119,7 @@ const searchQuery = ref('')
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
const siteLogo = ref('/ge-aerospace-logo.svg')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
function getTicketSearchUrl(ticketnumber) {
@@ -146,6 +147,7 @@ const iconMap = {
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)

View File

@@ -41,6 +41,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { authApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
import { withBase } from '../utils/basePath'
import { apiError } from '../utils/apiError'
const router = useRouter()
@@ -48,7 +49,7 @@ const authStore = useAuthStore()
const forced = computed(() => authStore.mustChangePassword)
const siteLogo = ref('/ge-aerospace-logo.svg')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')

View File

@@ -52,6 +52,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
import { withBase, stripBase } from '../utils/basePath'
const router = useRouter()
const route = useRoute()
@@ -62,12 +63,14 @@ const authStore = useAuthStore()
function postLoginTarget() {
const redirect = route.query.redirect
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect
// Tolerate a redirect that still carries the mount base (old bookmarks,
// pre-fix interceptor URLs): router paths must be base-free.
return stripBase(redirect)
}
return '/'
}
const siteLogo = ref('/ge-aerospace-logo.svg')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const mode = ref('login')
const username = ref('')
const email = ref('')

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="search-results">
<div class="page-header">
<h2>Search Results</h2>
<span v-if="results.length" class="results-count">
@@ -385,56 +385,33 @@ watch(results, () => {
flex-shrink: 0;
}
.result-type.machine {
background: #e3f2fd;
color: #1565c0;
/* Per-domain badge palette. Values live in CSS variables on the container so
the dark theme overrides them in one place (below) instead of restating
every selector. Each badge rule just references its pair. */
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
}
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-type.computer {
background: #e8f5e9;
color: #2e7d32;
}
.result-type.application {
background: #fff3e0;
color: #e65100;
}
.result-type.knowledgebase {
background: #f3e5f5;
color: #7b1fa2;
}
.result-type.printer {
background: #fce4ec;
color: #c2185b;
}
.result-type.network_device {
background: #fff8e1;
color: #f57f17;
}
.result-type.measuring_tool {
background: #e0f7fa;
color: #00838f;
}
.result-type.employee {
background: #e0f2f1;
color: #00695c;
}
.result-type.notification {
background: #e8eaf6;
color: #283593;
}
.result-type.subnet {
background: #fbe9e7;
color: #bf360c;
}
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-content {
flex: 1;
@@ -464,10 +441,6 @@ watch(results, () => {
color: var(--text-light);
}
.result-location::before {
content: '\1F4CD ';
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
@@ -490,55 +463,17 @@ watch(results, () => {
}
@media (prefers-color-scheme: dark) {
.result-type.machine {
background: rgba(21, 101, 192, 0.2);
color: #64b5f6;
}
.result-type.pc,
.result-type.computer {
background: rgba(46, 125, 50, 0.2);
color: #81c784;
}
.result-type.application {
background: rgba(230, 81, 0, 0.2);
color: #ffb74d;
}
.result-type.knowledgebase {
background: rgba(123, 31, 162, 0.2);
color: #ce93d8;
}
.result-type.printer {
background: rgba(194, 24, 91, 0.2);
color: #f48fb1;
}
.result-type.network_device {
background: rgba(245, 127, 23, 0.2);
color: #ffd54f;
}
.result-type.measuring_tool {
background: rgba(0, 131, 143, 0.2);
color: #80deea;
}
.result-type.employee {
background: rgba(0, 105, 92, 0.2);
color: #80cbc4;
}
.result-type.notification {
background: rgba(40, 53, 147, 0.2);
color: #9fa8da;
}
.result-type.subnet {
background: rgba(191, 54, 12, 0.2);
color: #ffab91;
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>

View File

@@ -41,7 +41,7 @@
<div v-for="p in plugins" :key="p.name" class="plugin-item">
<label class="plugin-row">
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
<span class="plugin-name">{{ p.name }}</span>
<span class="plugin-name">{{ p.displayname || p.name }}</span>
<span class="plugin-desc">{{ p.description }}</span>
</label>
@@ -140,6 +140,12 @@
<div v-else-if="current.key === 'finish'">
<h2>All set</h2>
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
<div v-if="geenforceEnabled" class="finish-note">
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
uses it: create a service token with the geenforce scopes under
Settings &gt; API Tokens, and set the on-share export root on the
GE-Enforce page.
</div>
</div>
</section>
@@ -235,6 +241,11 @@ function modeOf(plugin) {
return key ? (pluginModes.value[key] || 'selfhosted') : null
}
// GE-Enforce needs post-setup operational config (service token + share root)
// the wizard does not collect; the Finish step points there when it is on.
const geenforceEnabled = computed(() =>
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
// Enabled plugins whose external connection config should be collected right
// now: those in external mode (or with config but no mode concept).
const configurablePlugins = computed(() =>
@@ -420,13 +431,14 @@ async function finish() {
.form-row .form-group { flex: 1; }
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
.plugin-name { font-weight: 600; text-transform: capitalize; }
.plugin-name { font-weight: 600; }
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
.plugin-mode { margin: 0.4rem 0 0.2rem 1.9rem; }
.mode-opt { display: block; font-size: 0.86rem; margin-bottom: 0.3rem; cursor: pointer; }
.mode-opt input { margin-right: 0.4rem; }
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
.provision-note p { margin: 0 0 0.35rem; }
.provision-note p:last-child { margin-bottom: 0; }
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }

View File

@@ -186,10 +186,11 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
const loading = ref(true)
const facilityName = ref('ShopDB')
const siteLogo = ref('/ge-aerospace-logo.svg')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
const businessUnit = ref('')
@@ -444,7 +445,7 @@ function handlePhotoError(e) {
.location-title {
font-size: 18px;
font-weight: 600;
color: #888;
color: #b8c4d8;
text-transform: uppercase;
letter-spacing: 2px;
}
@@ -452,6 +453,7 @@ function handlePhotoError(e) {
.header-center h1 {
font-size: 28px;
font-weight: 700;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0;

View File

@@ -7,7 +7,7 @@
class="slide"
:class="{ active: idx === currentSlide }"
>
<img :src="basePath + slide.filename" :alt="slide.filename" />
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
</div>
<div v-if="error" class="error-message">
@@ -28,6 +28,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import api from '@/api'
import { withBase } from '@/utils/basePath'
const INTERVAL = 10 // seconds between slides

View File

@@ -93,6 +93,23 @@
</div>
</div>
</div>
<!-- Related Knowledge Base -->
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
<div class="kb-list">
<a
v-for="kb in app.knowledgebase"
:key="kb.linkid"
:href="kb.linkurl"
target="_blank"
class="kb-item"
>
<span class="kb-title">{{ kb.shortdescription }}</span>
<span class="kb-keywords" v-if="kb.keywords">{{ kb.keywords }}</span>
</a>
</div>
</div>
</div>
<!-- Right Column -->

View File

@@ -1,213 +1,213 @@
<template>
<div>
<div class="page-header">
<h2>Applications</h2>
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search applications..."
@input="debouncedSearch"
/>
<select v-model="filter" class="form-control" @change="loadApplications">
<option value="installable">Installable Applications</option>
<option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 50px;">Files</th>
<th style="width: 50px;">Docs</th>
<th>Application Name</th>
<th>Description</th>
<th>Support Team</th>
<th>Contacts</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="app in applications" :key="app.appid">
<td class="icon-cell">
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4;
</a>
</td>
<td>
<router-link :to="`/applications/${app.appid}`">
{{ app.appname }}
</router-link>
<span class="app-flags">
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
</span>
</td>
<td class="description">{{ app.appdescription || '-' }}</td>
<td>{{ app.supportteamname || '-' }}</td>
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
<td class="actions">
<router-link
:to="`/applications/${app.appid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="applications.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No applications found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const applications = ref([])
const loading = ref(true)
const search = ref('')
const filter = ref('installable')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadApplications()
})
async function loadApplications() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
search: search.value || undefined
}
// Apply filter
if (filter.value === 'installable') {
params.installable = true
} else if (filter.value === 'hidden') {
params.hidden = true
}
const response = await applicationsApi.list(params)
applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading applications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadApplications()
}, 300)
}
function goToPage(p) {
page.value = p
loadApplications()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadApplications()
}
</script>
<style scoped>
/* Application list specific styles */
.icon-cell {
text-align: center;
width: 50px;
}
.icon-cell a {
font-size: 1.25rem;
text-decoration: none;
}
.icon-download {
color: var(--success);
}
.icon-link {
color: var(--link);
}
.icon-docs {
color: var(--secondary);
}
.icon-cell a:hover {
opacity: 0.7;
}
.app-flags {
display: inline-flex;
gap: 0.375rem;
flex-wrap: wrap;
margin-left: 0.5rem;
vertical-align: middle;
}
.badge-sm {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
.description {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-light);
}
</style>
<template>
<div>
<div class="page-header">
<h2>Applications</h2>
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search applications..."
@input="debouncedSearch"
/>
<select v-model="filter" class="form-control" @change="loadApplications">
<option value="installable">Installable Applications</option>
<option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 50px;">Files</th>
<th style="width: 50px;">Docs</th>
<th>Application Name</th>
<th>Description</th>
<th>Support Team</th>
<th>Contacts</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
<td class="icon-cell">
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4;
</a>
</td>
<td>
<router-link :to="`/applications/${app.appid}`">
{{ app.appname }}
</router-link>
<span class="app-flags">
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
</span>
</td>
<td class="description">{{ app.appdescription || '-' }}</td>
<td>{{ app.supportteamname || '-' }}</td>
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/applications/${app.appid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="applications.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No applications found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const applications = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
const filter = ref('installable')
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadApplications()
})
async function loadApplications() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
search: search.value || undefined
}
// Apply filter
if (filter.value === 'installable') {
params.installable = true
} else if (filter.value === 'hidden') {
params.hidden = true
}
const response = await applicationsApi.list(params)
applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading applications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadApplications()
}, 300)
}
function goToPage(p) {
setPage(p)
loadApplications()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadApplications()
}
</script>
<style scoped>
/* Application list specific styles */
.icon-cell {
text-align: center;
width: 50px;
}
.icon-cell a {
font-size: 1.25rem;
text-decoration: none;
}
.icon-download {
color: var(--success);
}
.icon-link {
color: var(--link);
}
.icon-docs {
color: var(--secondary);
}
.icon-cell a:hover {
opacity: 0.7;
}
.app-flags {
display: inline-flex;
gap: 0.375rem;
flex-wrap: wrap;
margin-left: 0.5rem;
vertical-align: middle;
}
.badge-sm {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
.description {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-light);
}
</style>

View File

@@ -64,7 +64,7 @@
</div>
<!-- Currently Checked Out USB Devices -->
<div class="section-card">
<div v-if="usbEnabled" class="section-card">
<h2 class="section-title">Checked Out USB Devices</h2>
<div v-if="usbLoading" class="loading">Loading...</div>
<div v-else-if="usbDevices.length === 0" class="empty">
@@ -99,7 +99,7 @@
</div>
<!-- USB Checkout History -->
<div class="section-card">
<div v-if="usbEnabled" class="section-card">
<h2 class="section-title">USB Checkout History</h2>
<div v-if="historyLoading" class="loading">Loading...</div>
<div v-else-if="checkoutHistory.length === 0" class="empty">
@@ -115,7 +115,7 @@
</tr>
</thead>
<tbody>
<tr v-for="record in checkoutHistory" :key="record.log_id">
<tr v-for="record in displayedHistory" :key="record.log_id">
<td>
<router-link :to="`/usb/${record.device_id}`">
{{ record.device_id }}
@@ -126,6 +126,20 @@
</tr>
</tbody>
</table>
<button
v-if="checkoutHistory.length > historyLimit && !showAllHistory"
class="btn btn-secondary show-more-btn"
@click="showAllHistory = true"
>
Show {{ checkoutHistory.length - historyLimit }} more
</button>
<button
v-if="showAllHistory && checkoutHistory.length > historyLimit"
class="btn btn-secondary show-more-btn"
@click="showAllHistory = false"
>
Show less
</button>
</div>
</div>
</template>
@@ -136,6 +150,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '../../composables/toast'
const toast = useToast()
@@ -145,6 +160,7 @@ const employee = ref(null)
const recognitions = ref([])
const usbDevices = ref([])
const checkoutHistory = ref([])
const usbEnabled = ref(true)
const loading = ref(true)
const recognitionsLoading = ref(true)
const usbLoading = ref(true)
@@ -161,6 +177,16 @@ const displayedRecognitions = computed(() => {
return recognitions.value.slice(0, recognitionsLimit)
})
const historyLimit = 10
const showAllHistory = ref(false)
const displayedHistory = computed(() => {
if (showAllHistory.value) {
return checkoutHistory.value
}
return checkoutHistory.value.slice(0, historyLimit)
})
const fullName = computed(() => {
if (!employee.value) return ''
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
@@ -175,7 +201,13 @@ const initials = computed(() => {
onMounted(async () => {
await loadEmployee()
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()])
// Skip the USB panels entirely when the usb plugin is disabled - its
// /api/usb routes 404 otherwise and spam the console.
await loadEnabledPlugins()
usbEnabled.value = isPluginEnabled('usb')
const tasks = [loadRecognitions()]
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
await Promise.all(tasks)
})
async function loadEmployee() {

View File

@@ -0,0 +1,161 @@
<template>
<div class="enforcement-reports">
<div class="page-header">
<h2>Enforcement Reports</h2>
</div>
<p class="setting-description">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures.
</p>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="filters">
<input
type="text"
class="form-control"
v-model="filterHost"
placeholder="Filter hostname"
@keyup.enter="load"
/>
<input
type="text"
class="form-control"
v-model="filterScope"
placeholder="Filter PC type"
@keyup.enter="load"
/>
<button class="btn btn-primary" @click="load">Filter</button>
<button class="btn btn-secondary" @click="clearFilters">Clear</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
<th>Last check-in</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.reportid">
<td>{{ report.hostname }}</td>
<td>{{ report.scopename }}</td>
<td>
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
{{ report.receivedlatest ? 'yes' : 'behind' }}
</span>
</td>
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
<td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
</td>
</tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Detail modal -->
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal modal-report">
<div class="modal-header">
<h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
<button class="modal-close" @click="detail = null">x</button>
</div>
<div class="modal-body">
<p class="setting-description">
Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
status {{ detail.status }}
</p>
<div class="table-container">
<table>
<thead>
<tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr>
</thead>
<tbody>
<tr v-for="(result, index) in detail.results" :key="index">
<td>{{ result.entryname }}</td>
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
<td class="muted">{{ result.exitcode ?? '' }}</td>
<td class="muted">{{ result.message }}</td>
</tr>
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="detail = null">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../../api'
const reports = ref([])
const detail = ref(null)
const error = ref('')
const filterHost = ref('')
const filterScope = ref('')
function payload(response) { return response.data.data }
async function load() {
const params = {}
if (filterHost.value) params.hostname = filterHost.value
if (filterScope.value) params.scopename = filterScope.value
try {
reports.value = payload(await api.get('/geenforce/reports', { params }))
error.value = ''
} catch (e) { error.value = 'Failed to load reports' }
}
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
async function openDetail(reportid) {
try {
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
} catch (e) { error.value = 'Failed to load report detail' }
}
function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
}
function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || ''
}
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
load()
</script>
<style scoped>
.enforcement-reports { max-width: 1100px; }
.muted { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.modal-report { max-width: 640px; }
.modal-close {
background: transparent;
border: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="geenforce-section">
<div class="section-header">
<h1>GE-Enforce</h1>
<p class="section-sub">Desired-state install manifests for imaging PC types, and the fleet's reported results.</p>
</div>
<nav class="section-tabs">
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
</nav>
<router-view />
</div>
</template>
<script setup>
// Tabbed shell for the GE-Enforce section. Children render the manifest editor
// and the fleet-compliance reports full-width (not squeezed into the settings rail).
</script>
<style scoped>
.geenforce-section { max-width: 1400px; }
.section-header { margin-bottom: 0.5rem; }
.section-header h1 { margin: 0; }
.section-sub { color: var(--text-light); margin: 0.25rem 0 0; }
.section-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin: 1rem 0 1.25rem;
}
.tab {
padding: 0.5rem 0.9rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover { color: var(--text); }
.tab.router-link-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
// Pure, framework-free helpers for the GE-Enforce manifest editor.
//
// ManifestEditor.vue imports these directly (the component no longer keeps its
// own copies), so the unit tests in entryForm.spec.js exercise the shipped
// code path. Change the editor logic HERE.
//
// Everything here is a plain function of its inputs. No Vue, no reactivity,
// no network. That is the whole point - deterministic logic we can pin down.
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
// Plain-language description of each detection method, shown under the Detection
// method dropdown so a first-time site admin understands what "present" means
// for the method they picked. Key '' is the no-detection case.
export const DETECTION_METHOD_HINTS = {
'': 'No detection rule: the action runs every cycle.',
Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).',
File: 'Already correct if the file at Detection path exists.',
FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.',
Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.',
MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.',
ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.',
pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.',
Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.',
}
// Description for the currently selected detection method, or '' if unknown.
export function detectionMethodHint(method) {
return DETECTION_METHOD_HINTS[method || ''] || ''
}
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
export function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
inuseBehavior: '', inuseProcesses: [], appid: null,
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
// Split a comma list into trimmed non-empty parts.
export function splitList(value) {
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
}
// Turn an edit-form object into the API body sent to create/update an entry.
//
// Rules that matter (and that the specs pin):
// - appid is always present (null unlinks). It is shopdb metadata; the backend
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
// - empty/undefined/null scalars are dropped.
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
// - comma lists become arrays, and are dropped when empty.
// - preinstall flags only appear when truthy.
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
export function buildEntryPayload(form) {
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
// off the manifest JSON.
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
}
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
}
const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (form[flag]) out[flag] = true
}
if (form.inuseBehavior) {
out.InUseCheck = {
Behavior: form.inuseBehavior,
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const processData = { Name: process.name }
if (process.exepath) processData.ExePath = process.exepath
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
processData.GracefulCloseTimeoutSec = Number(process.timeout)
}
return processData
}),
}
}
return out
}
// Which entry types the type dropdown offers, given the scope phase. Preinstall
// supports MSI/EXE only, but keeps a current out-of-set value visible.
export function availableEntryTypes(isPreinstall, currentType) {
if (!isPreinstall) return ENTRY_TYPES
const allowed = ['MSI', 'EXE']
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
}
// Which detection methods the dropdown offers. Preinstall supports Registry/File
// only, but keeps a current out-of-set value visible.
export function availableDetectionMethods(isPreinstall, currentMethod) {
if (!isPreinstall) return DETECTION_METHODS
const allowed = ['Registry', 'File']
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
}
// Which targeting gates a scope actually surfaces by default (before "Show all").
export function targetingGates(scope) {
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
}
// The contextual hint under the Targeting header.
export function targetingHint(scope) {
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
}
// One-line summary of what a scope installs (installer entries only).
export function scopeSummary(scope) {
if (!scope || !scope.entries || !scope.entries.length) return ''
const installers = scope.entries
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
.map(e => e.Name)
const shown = installers.slice(0, 6).join(', ')
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
return `${apps}${scope.entries.length} entries; runs after common.`
}
// Verb table for describeEntry.
export const ACTION_VERB = {
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
PS1: 'Runs a script for', INF: 'Installs a driver for',
File: 'Copies a file for', Registry: 'Sets a registry value for',
}
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
export function describeEntry(entry) {
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
const method = entry.DetectionMethod
if (!method || method === 'Always') text += '; runs every cycle'
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
else if (method === 'Hash') text += '; re-copies if the file changed'
else if (method === 'MarkerFile') text += '; installs once'
else text += '; reinstalls if not detected'
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
text += '; specific hostname(s)'
} else if (entry.PCTypes && entry.PCTypes.length) {
text += `; ${entry.PCTypes.length} PC type(s)`
}
if (entry.appname) text += `; tracked: ${entry.appname}`
return text
}

View File

@@ -0,0 +1,321 @@
import { describe, it, expect } from 'vitest'
import {
blankEntry,
splitList,
buildEntryPayload,
availableEntryTypes,
availableDetectionMethods,
targetingGates,
targetingHint,
scopeSummary,
describeEntry,
detectionMethodHint,
ENTRY_TYPES,
DETECTION_METHODS,
} from './entryForm.js'
// These specs pin the deterministic logic that turns the manifest editor form
// into an API body, and the various display helpers. This is the code most
// likely to silently regress (numeric coercion, dropped-vs-kept fields, the
// appid-must-never-be-a-manifest-scalar rule).
describe('splitList', () => {
it('trims, drops empties, and returns an array', () => {
expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c'])
})
it('returns an empty array for null/empty', () => {
expect(splitList('')).toEqual([])
expect(splitList(null)).toEqual([])
expect(splitList(undefined)).toEqual([])
})
})
describe('buildEntryPayload - appid handling', () => {
it('always emits appid as a top-level key, defaulting to null', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI' })
expect(out.appid).toBeNull()
expect('appid' in out).toBe(true)
})
it('passes a linked appid through unchanged', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 })
expect(out.appid).toBe(41)
})
it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => {
// appid lives at the top level as metadata; it must not appear nested in
// any manifest sub-structure. Guard against a future refactor smuggling it
// into e.g. InUseCheck or a detection block.
const out = buildEntryPayload({
Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1',
DetectionMethod: 'Registry', inuseBehavior: 'ForceClose',
inuseProcesses: [{ name: 'p', exepath: '', timeout: null }],
})
// Only the top-level appid key carries it.
const serialized = JSON.stringify({ ...out, appid: undefined })
expect(serialized.includes('appid')).toBe(false)
expect(serialized.toLowerCase().includes('"appid"')).toBe(false)
})
})
describe('buildEntryPayload - scalar filtering', () => {
it('drops empty-string, null, and undefined scalars', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null,
Script: undefined, LogFile: 'setup.log',
})
expect('Installer' in out).toBe(false)
expect('InstallArgs' in out).toBe(false)
expect('Script' in out).toBe(false)
expect(out.LogFile).toBe('setup.log')
})
it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note',
})
expect(out._CmmVersion).toBe('2019')
expect(out._comment).toBe('note')
})
it('only emits DetectionMethod when set', () => {
expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false)
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion')
})
it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => {
expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false)
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300)
})
})
describe('buildEntryPayload - RegValue coercion', () => {
it('coerces DWord to a Number', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' })
expect(out.RegValue).toBe(1)
expect(typeof out.RegValue).toBe('number')
})
it('coerces QWord to a Number', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' })
expect(out.RegValue).toBe(42)
})
it('leaves String reg values as strings', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' })
expect(out.RegValue).toBe('hello')
expect(typeof out.RegValue).toBe('string')
})
it('does not emit RegValue for a non-Registry type', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' })
expect('RegValue' in out).toBe(false)
})
it('drops an empty RegValue even for Registry', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' })
expect('RegValue' in out).toBe(false)
})
})
describe('buildEntryPayload - comma lists to arrays', () => {
it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI',
PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections',
TargetHostnames: 'host-a, host-b',
TargetMachineNumbers: '0101, 0102',
})
expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections'])
expect(out.TargetHostnames).toEqual(['host-a', 'host-b'])
expect(out.TargetMachineNumbers).toEqual(['0101', '0102'])
})
it('omits the array keys when the list is empty', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' })
expect('PCTypes' in out).toBe(false)
expect('TargetHostnames' in out).toBe(false)
})
})
describe('buildEntryPayload - preinstall flags', () => {
it('emits only the truthy flags', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI',
PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true,
})
expect(out.PreEnrollment).toBe(true)
expect(out.PCTypesStrict).toBe(true)
expect('KillAfterDetection' in out).toBe(false)
})
})
describe('buildEntryPayload - InUseCheck processes', () => {
it('carries Name, optional ExePath, and a numeric timeout per process', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen',
inuseProcesses: [
{ name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 },
{ name: 'nodmis', exepath: '', timeout: null },
],
})
expect(out.InUseCheck.Behavior).toBe('CloseAndReopen')
expect(out.InUseCheck.Processes).toEqual([
{ Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 },
{ Name: 'nodmis' },
])
})
it('drops processes with no name', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose',
inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }],
})
expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }])
})
it('coerces a string timeout to a number', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'Defer',
inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }],
})
expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15)
})
it('emits no InUseCheck when there is no behavior', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' })
expect('InUseCheck' in out).toBe(false)
})
})
describe('buildEntryPayload - a blank new entry round-trips', () => {
it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => {
const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' })
// Type MSI, appid null. RegType is default String but non-Registry, so it
// is still carried as a scalar (it is in the scalar list). Confirm the shape.
expect(out.Name).toBe('Fresh')
expect(out.Type).toBe('MSI')
expect(out.appid).toBeNull()
expect('InUseCheck' in out).toBe(false)
expect('PCTypes' in out).toBe(false)
})
})
describe('availableEntryTypes', () => {
it('returns the full list for a non-preinstall scope', () => {
expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES)
})
it('restricts to MSI/EXE for preinstall', () => {
expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE'])
})
it('keeps an out-of-set current value visible in preinstall', () => {
expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry'])
})
})
describe('availableDetectionMethods', () => {
it('returns the full list for a non-preinstall scope', () => {
expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS)
})
it('restricts to Registry/File for preinstall', () => {
expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File'])
})
it('keeps an out-of-set current value visible in preinstall', () => {
expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion'])
})
})
describe('targetingGates', () => {
it('defaults to pctypes-only when there is no scope', () => {
expect(targetingGates(null)).toEqual({
pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false,
})
})
it('shows pctypes for a fleetwide/common/preinstall scope', () => {
expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true)
expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true)
expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true)
})
it('shows the cmm gate for a cmm-named scope', () => {
expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true)
})
it('does not name-match machinenumbers (data-driven only)', () => {
// 'nocollections' must not trip a loose name match; machinenumbers is purely
// data-driven off the entries.
const gates = targetingGates({ scopename: 'nocollections', entries: [] })
expect(gates.machinenumbers).toBe(false)
})
it('surfaces gates from entry data', () => {
const gates = targetingGates({
scopename: 'per-type', entries: [
{ PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] },
],
})
expect(gates.pctypes).toBe(true)
expect(gates.machinenumbers).toBe(true)
expect(gates.hostnames).toBe(true)
})
})
describe('targetingHint', () => {
it('is empty with no scope', () => {
expect(targetingHint(null)).toBe('')
})
it('describes a fleet-wide common manifest', () => {
expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/)
})
it('describes a preinstall manifest', () => {
expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/)
})
it('names a per-type scope', () => {
expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/)
})
})
describe('scopeSummary', () => {
it('is empty with no entries', () => {
expect(scopeSummary({ entries: [] })).toBe('')
expect(scopeSummary(null)).toBe('')
})
it('lists installer names and the entry count', () => {
const summary = scopeSummary({
entries: [
{ Type: 'MSI', Name: 'eDNC' },
{ Type: 'Registry', Name: 'reg' },
],
})
expect(summary).toBe('Installs eDNC. 2 entries; runs after common.')
})
it('truncates past six installers with a +N more', () => {
const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` }))
const summary = scopeSummary({ entries })
expect(summary).toMatch(/\+2 more/)
})
})
describe('describeEntry', () => {
it('describes an MSI with FileVersion detection using the expected version', () => {
const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' })
expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1')
})
it('says runs every cycle for Always / no detection', () => {
expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle')
expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle')
})
it('appends a CMM gate note', () => {
const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' })
expect(text).toMatch(/CMM 2016 bays only/)
})
it('appends the tracked app name when present', () => {
const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' })
expect(text).toMatch(/tracked: PC-DMIS/)
})
it('falls back to Applies for an unknown type', () => {
expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/)
})
})
describe('detectionMethodHint', () => {
it('has a non-empty hint for every detection method', () => {
for (const method of DETECTION_METHODS) {
expect(detectionMethodHint(method).length).toBeGreaterThan(0)
}
})
it('describes the no-detection case for empty/undefined', () => {
expect(detectionMethodHint('')).toMatch(/every cycle/)
expect(detectionMethodHint(undefined)).toMatch(/every cycle/)
})
it('ties FileVersion to the compliance panel', () => {
expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/)
})
it('returns empty string for an unknown method', () => {
expect(detectionMethodHint('Nonsense')).toBe('')
})
})

View File

@@ -111,15 +111,15 @@
import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const perPage = ref(20)
const totalPages = ref(1)
const search = ref('')
const topicFilter = ref('')
const sort = ref('clicks')
const order = ref('desc')
@@ -177,19 +177,19 @@ async function loadStats() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadArticles()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadArticles()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadArticles()
}

View File

@@ -72,6 +72,7 @@
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
@@ -137,6 +138,35 @@
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ machine.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Machine Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
@@ -170,34 +200,6 @@
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ machine.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="machine.assetid" />
@@ -206,7 +208,7 @@
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
<!-- All relationships (dualpath, controls, ...) -->
<AssetRelationships v-if="machine.assetid" :assetId="machine.assetid" />
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
<!-- Notes -->
<div class="section-card" v-if="machine.notes">

View File

@@ -347,7 +347,7 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
@@ -463,11 +463,8 @@ onMounted(async () => {
// Load relationship types separately
try {
const relRes = await fetch('/api/assets/relationshiptypes')
if (relRes.ok) {
const relData = await relRes.json()
relationshipTypes.value = relData.data || []
}
const relRes = await relationshipTypesApi.list()
relationshipTypes.value = relRes.data.data || []
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]

View File

@@ -36,7 +36,7 @@
</tr>
</thead>
<tbody>
<tr v-for="item in machines" :key="item.assetid">
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
</td>
@@ -50,7 +50,7 @@
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<td class="actions" @click.stop>
<router-link
:to="`/machines/${item.machine?.machineid || item.assetid}`"
class="btn btn-secondary btn-sm"
@@ -87,11 +87,11 @@ import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const machines = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
const totalPages = ref(1)
const perPage = ref(20)
@@ -123,19 +123,19 @@ async function loadMachines() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadMachines()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadMachines()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadMachines()
}
</script>

View File

@@ -48,6 +48,7 @@
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
@@ -139,7 +140,7 @@
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
<!-- All relationships (partof, connectedto, ...) -->
<AssetRelationships v-if="tool.assetid" :assetId="tool.assetid" />
<AssetRelationships v-if="tool.assetid" :assetid="tool.assetid" />
<!-- Notes -->
<div class="section-card" v-if="tool.notes">

View File

@@ -49,7 +49,7 @@
</tr>
</thead>
<tbody>
<tr v-for="item in tools" :key="item.assetid">
<tr v-for="item in tools" :key="item.assetid" class="clickable-row" @click="$router.push(`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`)">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
@@ -61,7 +61,7 @@
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<td class="actions" @click.stop>
<router-link
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
class="btn btn-secondary btn-sm"
@@ -96,14 +96,14 @@ import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const tools = ref([])
const types = ref([])
const loading = ref(true)
const search = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
const typeid = ref('')
const calibrationstatus = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
@@ -142,23 +142,26 @@ async function loadTools() {
}
function reload() {
page.value = 1
setPage(1)
loadTools()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(reload, 300)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadTools()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadTools()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadTools()
}
</script>

View File

@@ -59,56 +59,12 @@
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<div class="content-grid">
<div class="content-column">
<!-- Network Info -->
<!-- Identity -->
<div class="section-card">
<h3 class="section-title">Network Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" :items="warranties" />
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ device.notes }}</div>
</div>
</div>
<div class="content-column">
<!-- Asset Info -->
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
@@ -138,36 +94,73 @@
<span class="info-label">Device Type</span>
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
</div>
</div>
</div>
<!-- Network Information (type-specific) -->
<div class="section-card">
<h3 class="section-title">Network Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</span>
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware Version</span>
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Port Count</span>
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Rack Unit</span>
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">PoE Capable</span>
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">Managed Device</span>
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">{{ device.locationname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ device.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" :items="warranties" />
<!-- Relationships -->
<AssetRelationships
v-if="device.assetid"
:assetId="device.assetid"
:assetid="device.assetid"
/>
<!-- Audit Info -->
<div class="section-card audit-card">
<h3 class="section-title">Record Info</h3>
<div class="info-list">
<div class="info-row" v-if="device.datecreated">
<span class="info-label">Created</span>
<span class="info-value">{{ formatDate(device.datecreated) }}</span>
</div>
<div class="info-row" v-if="device.datemodified">
<span class="info-label">Last Modified</span>
<span class="info-value">{{ formatDate(device.datemodified) }}</span>
</div>
</div>
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ device.notes }}</div>
</div>
</div>
</div>
@@ -184,6 +177,12 @@
Delete Device
</button>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(device.createddate) }}<template v-if="device.createdby"> by {{ device.createdby }}</template></span>
<span>Modified {{ formatDate(device.modifieddate) }}<template v-if="device.modifiedby"> by {{ device.modifiedby }}</template></span>
</div>
</div>
<div v-else-if="loading" class="loading-container">

View File

@@ -1,347 +1,347 @@
<template>
<div>
<div class="page-header">
<h2>Network Devices</h2>
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div>
<!-- Type Tabs -->
<div class="type-tabs">
<button
:class="{ active: selectedType === null }"
@click="selectType(null)"
>
All ({{ totalCount }})
</button>
<button
v-for="t in deviceTypes"
:key="t.networkdevicetypeid"
:class="{ active: selectedType === t.networkdevicetypeid }"
@click="selectType(t.networkdevicetypeid)"
>
{{ t.networkdevicetype }} ({{ t.count || 0 }})
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
<select v-model="locationFilter" class="form-control" @change="loadDevices">
<option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Vendor</th>
<th>Features</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid">
<td>{{ device.assetnumber }}</td>
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
<td class="mono">{{ device.serialnumber || '-' }}</td>
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
<td class="features">
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td>
<td>
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</td>
<td>{{ device.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/network/${device.networkdevice?.networkdeviceid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
const totalCount = ref(0)
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadDeviceTypes(),
loadVendors(),
loadLocations()
])
await loadDevices()
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ perpage: 100 })
deviceTypes.value = response.data.data || []
// Get counts for each type
await updateTypeCounts()
} catch (error) {
console.error('Error loading device types:', error)
}
}
async function updateTypeCounts() {
// Get summary for type counts
try {
const response = await networkApi.dashboardSummary()
const byType = response.data.data?.bytype || response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0
// Map counts to types
deviceTypes.value = deviceTypes.value.map(t => {
const found = byType.find(bt => bt.type === t.networkdevicetype)
return { ...t, count: found?.count || 0 }
})
} catch (error) {
console.error('Error loading type counts:', error)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (error) {
console.error('Error loading vendors:', error)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || []
} catch (error) {
console.error('Error loading locations:', error)
}
}
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (selectedType.value) params.typeid = selectedType.value
if (vendorFilter.value) params.vendorid = vendorFilter.value
if (locationFilter.value) params.locationid = locationFilter.value
const response = await networkApi.list(params)
devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
} finally {
loading.value = false
}
}
function selectType(typeId) {
selectedType.value = typeId
page.value = 1
loadDevices()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadDevices()
}, 300)
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value) {
page.value = p
loadDevices()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadDevices()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.type-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.type-tabs button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.type-tabs button:hover {
background: var(--bg);
border-color: var(--primary);
}
.type-tabs button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
.filters select.form-control {
flex: 0 0 auto;
width: auto;
min-width: 150px;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.features {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.feature-tag {
display: inline-block;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.poe {
background: #d4edda;
color: #155724;
}
.feature-tag.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-tag.ports {
background: var(--bg);
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-tag.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-tag.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>
<template>
<div>
<div class="page-header">
<h2>Network Devices</h2>
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div>
<!-- Type Tabs -->
<div class="type-tabs">
<button
:class="{ active: selectedType === null }"
@click="selectType(null)"
>
All ({{ totalCount }})
</button>
<button
v-for="t in deviceTypes"
:key="t.networkdevicetypeid"
:class="{ active: selectedType === t.networkdevicetypeid }"
@click="selectType(t.networkdevicetypeid)"
>
{{ t.networkdevicetype }} ({{ t.count || 0 }})
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
<select v-model="locationFilter" class="form-control" @change="loadDevices">
<option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Vendor</th>
<th>Features</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid" class="clickable-row" @click="$router.push(`/network/${device.networkdevice?.networkdeviceid || device.assetid}`)">
<td>{{ device.assetnumber }}</td>
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
<td class="mono">{{ device.serialnumber || '-' }}</td>
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
<td class="features">
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td>
<td>
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</td>
<td>{{ device.locationname || '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/network/${device.networkdevice?.networkdeviceid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
const totalPages = ref(1)
const perPage = ref(25)
const totalCount = ref(0)
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadDeviceTypes(),
loadVendors(),
loadLocations()
])
await loadDevices()
})
async function loadDeviceTypes() {
try {
const response = await networkApi.types.list({ perpage: 100 })
deviceTypes.value = response.data.data || []
// Get counts for each type
await updateTypeCounts()
} catch (error) {
console.error('Error loading device types:', error)
}
}
async function updateTypeCounts() {
// Get summary for type counts
try {
const response = await networkApi.dashboardSummary()
const byType = response.data.data?.bytype || response.data.data?.by_type || []
totalCount.value = response.data.data?.total || 0
// Map counts to types
deviceTypes.value = deviceTypes.value.map(t => {
const found = byType.find(bt => bt.type === t.networkdevicetype)
return { ...t, count: found?.count || 0 }
})
} catch (error) {
console.error('Error loading type counts:', error)
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (error) {
console.error('Error loading vendors:', error)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || []
} catch (error) {
console.error('Error loading locations:', error)
}
}
async function loadDevices() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (selectedType.value) params.typeid = selectedType.value
if (vendorFilter.value) params.vendorid = vendorFilter.value
if (locationFilter.value) params.locationid = locationFilter.value
const response = await networkApi.list(params)
devices.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
} finally {
loading.value = false
}
}
function selectType(typeId) {
selectedType.value = typeId
setPage(1)
loadDevices()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadDevices()
}, 300)
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value) {
setPage(p)
loadDevices()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadDevices()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.type-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.type-tabs button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.type-tabs button:hover {
background: var(--bg);
border-color: var(--primary);
}
.type-tabs button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
.filters select.form-control {
flex: 0 0 auto;
width: auto;
min-width: 150px;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.features {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.feature-tag {
display: inline-block;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.poe {
background: #d4edda;
color: #155724;
}
.feature-tag.managed {
background: #e3f2fd;
color: #1565c0;
}
.feature-tag.ports {
background: var(--bg);
color: var(--text-light);
}
@media (prefers-color-scheme: dark) {
.feature-tag.poe {
background: #1e3a29;
color: #4ade80;
}
.feature-tag.managed {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div>
<div class="page-header">
<h1>Network</h1>
</div>
<div class="hub-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="hub-tab"
:class="{ active: active === tab.key }"
@click="setTab(tab.key)"
>{{ tab.label }}</button>
</div>
<component :is="current" />
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import NetworkDevicesList from './NetworkDevicesList.vue'
import SubnetsBrowse from './SubnetsBrowse.vue'
const route = useRoute()
const router = useRouter()
// VLANs are a layer under a subnet (each Networks row shows its VLAN); VLAN
// naming lives in Settings, so the hub is just Devices + Networks.
const tabs = [
{ key: 'devices', label: 'Devices', comp: NetworkDevicesList },
{ key: 'networks', label: 'Networks', comp: SubnetsBrowse },
]
const active = ref(tabs.some(t => t.key === route.query.tab) ? route.query.tab : 'devices')
const current = computed(() => (tabs.find(t => t.key === active.value) || tabs[0]).comp)
function setTab(key) {
active.value = key
router.replace({ query: { ...route.query, tab: key } })
}
watch(() => route.query.tab, (value) => {
if (value && value !== active.value && tabs.some(t => t.key === value)) {
active.value = value
}
})
</script>
<style scoped>
.hub-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.25rem;
}
.hub-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.6rem 1rem;
cursor: pointer;
color: var(--text-light);
font-size: 0.95rem;
font-weight: 500;
}
.hub-tab:hover { color: var(--text); }
.hub-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="!subnet" class="empty-state"><p>Network not found.</p></div>
<template v-else>
<div class="page-header">
<h1>{{ subnet.name || subnet.cidr }}</h1>
<router-link to="/networks" class="btn btn-secondary">Back to Networks</router-link>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Network</h3>
<div class="info-list">
<div class="info-row"><span class="info-label">CIDR</span><span class="info-value mono">{{ subnet.cidr }}</span></div>
<div class="info-row"><span class="info-label">Network Address</span><span class="info-value mono">{{ subnet.networkaddress || '-' }}</span></div>
<div class="info-row"><span class="info-label">Type</span><span class="info-value">{{ subnet.subnettype || '-' }}</span></div>
<div class="info-row"><span class="info-label">VLAN</span><span class="info-value">{{ subnet.vlannumber || subnet.vlanid || '-' }}</span></div>
<div class="info-row" v-if="subnet.gatewayip"><span class="info-label">Gateway</span><span class="info-value mono">{{ subnet.gatewayip }}</span></div>
</div>
</div>
<div class="section-card" v-if="subnet.description">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ subnet.description }}</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Devices on this network ({{ devices.length }})</h3>
<div v-if="devices.length === 0" class="empty-state"><p>No devices found in this range.</p></div>
<div v-else class="table-container">
<table>
<thead>
<tr><th>Device</th><th>IP</th><th>Type</th></tr>
</thead>
<tbody>
<tr v-for="dev in devices" :key="dev.assetid">
<td>
<router-link v-if="dev.url" :to="dev.url">{{ dev.name || dev.assetnumber }}</router-link>
<span v-else>{{ dev.name || dev.assetnumber }}</span>
</td>
<td class="mono">{{ dev.ipaddress }}</td>
<td>{{ typeLabel(dev.assettype) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi } from '@/api'
const route = useRoute()
const subnet = ref(null)
const loading = ref(true)
const devices = computed(() => subnet.value?.devices || [])
const TYPE_LABELS = {
computer: 'PC', network_device: 'Network Device', printer: 'Printer',
machine: 'Machine', measuring_tool: 'Measuring Tool',
}
function typeLabel(type) {
return TYPE_LABELS[type] || type || '-'
}
async function load() {
loading.value = true
try {
const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div>
<div class="filters">
<input v-model="search" type="text" placeholder="Search networks..." class="form-control" />
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="filtered.length === 0" class="empty-state"><p>No networks found.</p></div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>CIDR</th>
<th>Type</th>
<th>VLAN</th>
<th>Notes</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="subnet in filtered" :key="subnet.subnetid" class="clickable-row"
@click="$router.push(`/networks/${subnet.subnetid}`)">
<td>{{ subnet.name || '-' }}</td>
<td class="mono">{{ subnet.cidr }}</td>
<td>{{ subnet.subnettype || '-' }}</td>
<td>{{ subnet.vlannumber || subnet.vlanid || '-' }}</td>
<td class="cell-truncate" :title="subnet.description">{{ subnet.description || '-' }}</td>
<td class="actions">
<router-link :to="`/networks/${subnet.subnetid}`" class="btn btn-sm btn-secondary" @click.stop>View</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi } from '@/api'
const subnets = ref([])
const loading = ref(true)
const search = ref('')
const filtered = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return subnets.value
return subnets.value.filter(s =>
(s.name || '').toLowerCase().includes(term) ||
(s.cidr || '').toLowerCase().includes(term) ||
(s.subnettype || '').toLowerCase().includes(term) ||
(s.description || '').toLowerCase().includes(term))
})
async function load() {
loading.value = true
try {
const response = await networkApi.subnets.list({ per_page: 1000 })
subnets.value = response.data?.data || response.data || []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.clickable-row { cursor: pointer; }
.clickable-row:hover { background: var(--bg); }
</style>

View File

@@ -90,14 +90,14 @@
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const notifications = ref([])
const types = ref([])
const loading = ref(true)
const searchQuery = ref('')
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
const selectedType = ref('')
const currentFilter = ref('')
const page = ref(1)
const perPage = ref(20)
const total = ref(0)
const totalPages = ref(1)
@@ -152,19 +152,19 @@ async function loadNotifications() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(searchQuery.value)
loadNotifications()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadNotifications()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadNotifications()
}

View File

@@ -49,6 +49,7 @@
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
@@ -118,6 +119,25 @@
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
</div>
</div>
<!-- Status / Check-in -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
@@ -140,28 +160,9 @@
<p v-else class="muted">No network addresses on record</p>
</div>
<!-- Status / Check-in -->
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
<!-- Location -->
<div class="section-card">
<h3 class="section-title">Location</h3>
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
@@ -191,15 +192,15 @@
<router-link
v-for="app in installedApps"
:key="app.id"
:to="`/applications/${app.application?.appid}`"
:to="`/applications/${app.appid}`"
class="app-item"
>
<div class="app-info">
<span class="app-name">{{ app.application?.appname }}</span>
<span class="app-version" v-if="app.version">v{{ app.version }}</span>
<span class="app-name">{{ app.appname }}</span>
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
</div>
<div class="app-desc" v-if="app.application?.appdescription">
{{ app.application.appdescription }}
<div class="app-desc" v-if="app.appdescription">
{{ app.appdescription }}
</div>
</router-link>
</div>
@@ -212,7 +213,7 @@
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetId="computer.assetid" />
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">

View File

@@ -1,211 +1,211 @@
<template>
<div>
<div class="page-header">
<h2>Computers</h2>
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search computers..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Remote Access</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in computers" :key="item.assetid">
<td>{{ item.assetnumber }}</td>
<td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features">
<template v-for="a in (item.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
</template>
<span v-if="!(item.accessmethods || []).length">-</span>
</td>
<td>
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
const computers = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadComputers()
})
async function loadComputers() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await computersApi.list(params)
computers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadComputers()
}, 300)
}
function goToPage(p) {
page.value = p
loadComputers()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadComputers()
}
</script>
<style scoped>
.access-link {
display: inline-block;
padding: 2px 10px;
margin: 0 4px 3px 0;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
offsets the row. lay tags out inline instead. */
.features {
white-space: nowrap;
}
.feature-tag {
display: inline-block;
margin-right: 0.375rem;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag:last-child {
margin-right: 0;
}
/* the global .actions rule is inline-flex, which also breaks table-cell
alignment when applied straight on a <td>; pin it back to a cell here. */
td.actions {
display: table-cell;
vertical-align: middle;
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>
<template>
<div>
<div class="page-header">
<h2>Computers</h2>
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search computers..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Remote Access</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
<td>{{ item.assetnumber }}</td>
<td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features">
<template v-for="a in (item.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
</template>
<span v-if="!(item.accessmethods || []).length">-</span>
</td>
<td>
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
const computers = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadComputers()
})
async function loadComputers() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await computersApi.list(params)
computers.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadComputers()
}, 300)
}
function goToPage(p) {
setPage(p)
loadComputers()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadComputers()
}
</script>
<style scoped>
.access-link {
display: inline-block;
padding: 2px 10px;
margin: 0 4px 3px 0;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
offsets the row. lay tags out inline instead. */
.features {
white-space: nowrap;
}
.feature-tag {
display: inline-block;
margin-right: 0.375rem;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag:last-child {
margin-right: 0;
}
/* the global .actions rule is inline-flex, which also breaks table-cell
alignment when applied straight on a <td>; pin it back to a cell here. */
td.actions {
display: table-cell;
vertical-align: middle;
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -33,13 +33,36 @@
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
This tool has no location; the label falls back to the asset page.
</p>
<div class="control-row">
<label>Output
<select v-model="outputMode">
<option value="single">Single label (standalone)</option>
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
</select>
</label>
</div>
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
<span class="cell-picker-label">Sheet cell</span>
<div class="cell-grid">
<button
v-for="cellNum in 6"
:key="cellNum"
type="button"
class="cell-btn"
:class="{ active: cellNum === sheetCell }"
@click="sheetCell = cellNum"
>{{ cellNum }}</button>
</div>
</div>
<button class="print-btn" @click="print">Print</button>
</template>
</div>
</div>
<!-- Printable area -->
<div v-if="asset" class="label-sheet">
<!-- Printable area: standalone single label (unchanged default). -->
<div v-if="asset && outputMode === 'single'" class="label-sheet">
<div class="asset-label" :class="style">
<template v-if="style === 'card'">
<div class="label-title">{{ cardTitle }}</div>
@@ -61,6 +84,44 @@
</div>
</div>
</div>
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
<div v-else-if="asset" class="sheets-container">
<div class="print-sheet">
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label"
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
>
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
<template v-if="style === 'card'">
<div class="label-title">{{ cardTitle }}</div>
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
<div class="label-fields">
<div v-for="field in identityFields" :key="field.label" class="label-field">
<span class="field-label">{{ field.label }}</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
</template>
<div class="code-area">
<template v-if="codeText">
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
<div class="code-caption">{{ caption }}</div>
</template>
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -90,6 +151,10 @@ const codeText = ref('')
const style = ref('card')
const codetype = ref('qr')
const encodes = ref('assetpage')
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
// label at sheetCell (1-6) of a ULINE 6-up sheet.
const outputMode = ref('single')
const sheetCell = ref(1)
const hasLocation = computed(() => hasLocationType(assettype))
@@ -168,7 +233,9 @@ onMounted(async () => {
}
})
watch([style, codetype, encodes], renderCode)
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
function print() {
window.print()
@@ -176,7 +243,11 @@ function print() {
</script>
<style scoped>
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
/* pages let each render its own size (only one branch renders at a time). */
@page { size: 2.13in 3.38in; margin: 0; }
@page uline { size: letter; margin: 0; }
.print-sheet { page: uline; }
.no-print { padding: 20px; }
.controls {
@@ -193,6 +264,33 @@ function print() {
.control-row select { padding: 6px; font-size: 0.875rem; }
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
.cell-picker { margin-bottom: 12px; }
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
.cell-grid {
display: grid;
grid-template-columns: repeat(2, 3rem);
grid-auto-rows: 3rem;
gap: 6px;
}
.cell-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
cursor: pointer;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
}
.cell-btn:hover { border-color: var(--primary); }
.cell-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.print-btn {
padding: 10px 30px;
font-size: 16px;
@@ -264,10 +362,61 @@ function print() {
padding: 0.3in 0.1in;
}
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
position: relative;
border: 1px solid #ccc;
}
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
.label .asset-label.in-cell {
width: auto;
min-height: 0;
border: none;
padding: 0;
justify-content: center;
}
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
@media print {
.no-print { display: none !important; }
.label-sheet { padding: 0; }
.asset-label { border: none; }
.sheets-container { padding: 0; }
.print-sheet { border: none; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
body, .asset-label, .code-qr, .code-barcode {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;

View File

@@ -30,6 +30,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
import JsBarcode from 'jsbarcode'
const route = useRoute()
@@ -37,7 +38,7 @@ const loading = ref(true)
const machine = ref(null)
const barcodeEl = ref(null)
const geLogo = ref('/ge-aerospace-logo.svg')
const geLogo = ref(withBase('/ge-aerospace-logo.svg'))
const isInspection = computed(() => {
if (!machine.value) return false

View File

@@ -0,0 +1,207 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
<p>
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
the item code, scannable at the parts kiosk.
</p>
<div v-if="loading" class="loading-msg">Loading parts...</div>
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
<div v-else class="parts-grid">
<div
v-for="item in items"
:key="item.printeditemid"
class="part-item"
:class="{ selected: isSelected(item) }"
@click="toggleItem(item)"
>
<input type="checkbox" :checked="isSelected(item)" @click.stop />
<label>
<strong><code>{{ item.itemcode }}</code></strong>
<div class="alias">{{ item.itemname }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedItems.length }}</span> labels
<label class="copies-label">Copies each:
<input v-model.number="copies" type="number" min="1" max="10" />
</label>
</div>
<button class="print-btn" :disabled="selectedItems.length === 0"
@click="print">Print Labels</button>
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
</div>
</div>
<div class="labels-container">
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
<div class="bin-code">{{ label.itemcode }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import JsBarcode from 'jsbarcode'
import { printedpartsApi } from '../../api'
const items = ref([])
const selectedItems = ref([])
const copies = ref(1)
const loading = ref(true)
const barcodeElements = ref({})
onMounted(async () => {
try {
const response = await printedpartsApi.list({ perpage: 500 })
items.value = response.data.data || []
// ?item=<id> preselects one part (the Detail-page print button)
const preselect = new URLSearchParams(window.location.search).get('item')
if (preselect) {
const match = items.value.find(
candidate => String(candidate.printeditemid) === preselect)
if (match) selectedItems.value = [match]
}
} catch (error) {
console.error('Error loading parts:', error)
} finally {
loading.value = false
}
})
const printLabels = computed(() => {
const labels = []
for (const item of selectedItems.value) {
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
labels.push(item)
}
}
return labels
})
function isSelected(item) {
return selectedItems.value.some(
candidate => candidate.printeditemid === item.printeditemid)
}
function toggleItem(item) {
if (isSelected(item)) {
selectedItems.value = selectedItems.value.filter(
candidate => candidate.printeditemid !== item.printeditemid)
} else {
selectedItems.value = [...selectedItems.value, item]
}
}
function setBarcodeElement(element, index) {
if (element) barcodeElements.value[index] = element
}
watch(printLabels, async labels => {
await nextTick()
labels.forEach((label, index) => {
const element = barcodeElements.value[index]
if (element) {
// CODE128 of the short item code fits 1x0.5in with comfortable
// scanner tolerance; a QR at this size would be marginal.
JsBarcode(element, label.itemcode, {
format: 'CODE128',
displayValue: false,
width: 1.4,
height: 26,
margin: 0
})
}
})
}, { deep: true })
function print() {
window.print()
}
</script>
<style scoped>
.controls {
max-width: 46rem;
margin: 1rem auto;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.5rem;
max-height: 20rem;
overflow-y: auto;
margin: 1rem 0;
}
.part-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.35rem;
cursor: pointer;
}
.part-item.selected { border-color: var(--primary); }
.alias { color: var(--text-light); font-size: 0.85rem; }
.selected-count { margin: 0.75rem 0; }
.copies-label { margin-left: 1.25rem; }
.copies-label input { width: 4rem; padding: 0.25rem; }
.print-btn, .clear-btn, .select-all-btn {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
.loading-msg { color: var(--text-light); padding: 1rem; }
/* screen preview of the labels */
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
.bin-label {
width: 1in;
height: 0.5in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
outline: 1px dashed #bbb;
}
.bin-barcode { width: 0.92in; height: 0.3in; }
.bin-code {
font-size: 6.5pt;
font-family: monospace;
color: #000;
line-height: 1;
}
/* 1in x 0.5in roll stock: one label per page */
@media print {
.no-print { display: none; }
.labels-container { display: block; padding: 0; gap: 0; }
.bin-label {
outline: none;
page-break-after: always;
break-after: page;
}
}
</style>
<style>
@media print {
@page { size: 1in 0.5in; margin: 0; }
body { margin: 0; }
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="parts-kiosk" @click="focusWedge">
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
always-focused input; whichever step is active consumes the scan -->
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
<header class="kiosk-header">
<h1>3D Printed Parts</h1>
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
Start over
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!-- step 1: scan the bin -->
<section v-if="step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<p class="kiosk-hint">
No scanner?
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
</p>
<div v-if="manualEntry" class="manual-row">
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
@keydown.enter="lookupItem(manualCode)" />
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
</div>
</section>
<!-- step 2: badge -->
<section v-else-if="step === 'badge'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">Scan your badge</p>
<div class="manual-row">
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
@keydown.enter="acceptBadge(manualBadge)" />
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
</div>
</section>
<!-- step 3: quantity -->
<section v-else-if="step === 'quantity'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="quantity-display">{{ quantity || '0' }}</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-success">Done - {{ doneMessage }}</p>
<p class="kiosk-hint">Starting over in a few seconds...</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import TouchKeypad from '../../components/TouchKeypad.vue'
const step = ref('item')
const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
const doneMessage = ref('')
const submitting = ref(false)
const manualEntry = ref(false)
const manualCode = ref('')
const manualBadge = ref('')
const wedgeInput = ref(null)
const wedgeBuffer = ref('')
let resetTimer = null
onMounted(focusWedge)
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusWedge() {
wedgeInput.value?.focus()
}
function onWedgeEnter() {
const scanned = wedgeBuffer.value.trim()
wedgeBuffer.value = ''
if (!scanned) return
if (step.value === 'item') lookupItem(scanned)
else if (step.value === 'badge') acceptBadge(scanned)
}
async function lookupItem(itemcode) {
error.value = ''
if (!itemcode) return
try {
const response = await printedpartsApi.kioskItem(itemcode.trim())
item.value = response.data.data
step.value = 'badge'
manualEntry.value = false
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
}
focusWedge()
}
function acceptBadge(value) {
error.value = ''
const scanned = (value || '').trim()
if (!scanned) return
badge.value = scanned
manualBadge.value = ''
step.value = 'quantity'
focusWedge()
}
async function submitTake() {
submitting.value = true
error.value = ''
try {
const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode,
badge: badge.value,
quantity: parseInt(quantity.value, 10)
})
doneMessage.value = response.data.message
step.value = 'done'
resetTimer = setTimeout(reset, 4000)
} catch (takeError) {
error.value = takeError.response?.data?.data?.error?.message ||
'Could not complete - see the parts team'
if (takeError.response?.status === 422) {
// badge problem: go back a step so the next scan retries cleanly
step.value = 'badge'
}
} finally {
submitting.value = false
}
}
function reset() {
clearTimeout(resetTimer)
step.value = 'item'
item.value = null
badge.value = ''
quantity.value = ''
error.value = ''
doneMessage.value = ''
focusWedge()
}
</script>
<style scoped>
.parts-kiosk {
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.wedge-input {
position: absolute;
opacity: 0;
height: 1px;
width: 1px;
}
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
.kiosk-hint { color: var(--text-light); }
.kiosk-error {
background: var(--danger);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
}
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
.item-card {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.5rem;
width: 100%;
}
.item-photo {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: 0.4rem;
}
.quantity-display {
font-size: 3rem;
font-weight: 700;
min-width: 8rem;
text-align: center;
border-bottom: 3px solid var(--primary);
}
.take-button {
font-size: 1.5rem;
padding: 0.9rem 3.5rem;
}
.manual-row { display: flex; gap: 0.6rem; }
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="item">
<div class="hero-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
:alt="item.itemname" class="hero-image" />
<div class="hero-content">
<h2 class="hero-title">{{ item.itemname }}</h2>
<div class="hero-meta">
<span class="badge badge-secondary">{{ item.itemcode }}</span>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }} on hand
</span>
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
</div>
<div class="hero-details">
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
</div>
<div class="hero-actions">
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
Restock
</button>
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
Adjust
</button>
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
class="btn btn-secondary btn-sm">Edit</router-link>
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
class="btn btn-secondary btn-sm">Bin Label</router-link>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Details</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Item code</span>
<span class="info-value">{{ item.itemcode }}</span>
</div>
<div class="info-row">
<span class="info-label">Bin location</span>
<span class="info-value">{{ item.binlocation || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Quantity on hand</span>
<span class="info-value">{{ item.quantityonhand }}</span>
</div>
<div class="info-row">
<span class="info-label">Low-stock threshold</span>
<span class="info-value">{{ item.lowstockthreshold }}</span>
</div>
<div class="info-row" v-if="item.printnotes">
<span class="info-label">Print notes</span>
<span class="info-value">{{ item.printnotes }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Recent transactions</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Qty</th>
<th>Who</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in item.recenttransactions"
:key="transaction.transactionid">
<td>{{ formatDate(transaction.transactiondate) }}</td>
<td>{{ transaction.transactiontype }}</td>
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
</td>
<td>{{ transaction.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td>
</tr>
<tr v-if="!item.recenttransactions?.length">
<td colspan="5" class="empty-state">No transactions yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="audit-footer">
Created {{ formatDate(item.createddate) }} -
Modified {{ formatDate(item.modifieddate) }}
</div>
</template>
<div v-else class="card">Item not found</div>
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
<div class="form-group">
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
</div>
<div v-if="ledgerMode === 'adjust'" class="form-group">
<label>Reason *</label>
<input v-model="ledgerReason" type="text" class="form-control"
placeholder="e.g., damaged parts scrapped, recount" />
</div>
<div class="form-group">
<label>Your badge / SSO *</label>
<input v-model="ledgerBadge" type="text" class="form-control"
placeholder="Scan badge or type SSO" />
</div>
<template #footer>
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
</button>
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import Modal from '../../components/Modal.vue'
const route = useRoute()
const item = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
} catch (loadError) {
console.error('Error loading printed item:', loadError)
} finally {
loading.value = false
}
})
const ledgerOpen = ref(false)
const ledgerMode = ref('restock')
const ledgerQuantity = ref(null)
const ledgerReason = ref('')
const ledgerBadge = ref('')
const ledgerSaving = ref(false)
const ledgerError = ref('')
function openLedger(mode) {
ledgerMode.value = mode
ledgerQuantity.value = null
ledgerReason.value = ''
ledgerBadge.value = ''
ledgerError.value = ''
ledgerOpen.value = true
}
async function submitLedger() {
ledgerSaving.value = true
ledgerError.value = ''
try {
if (ledgerMode.value === 'restock') {
await printedpartsApi.restock(item.value.printeditemid, {
quantity: ledgerQuantity.value, badge: ledgerBadge.value
})
} else {
await printedpartsApi.adjust(item.value.printeditemid, {
quantitychange: ledgerQuantity.value,
reason: ledgerReason.value,
badge: ledgerBadge.value
})
}
ledgerOpen.value = false
const response = await printedpartsApi.get(item.value.printeditemid)
item.value = response.data.data
} catch (submitError) {
ledgerError.value =
submitError.response?.data?.data?.error?.message ||
submitError.response?.data?.error?.message || 'Submit failed'
} finally {
ledgerSaving.value = false
}
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
</script>
<style scoped>
.hero-actions { margin-top: 0.75rem; }
.qty-out { color: var(--danger); }
.qty-in { color: var(--success); }
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
</div>
<div class="card form-card">
<div v-if="error" class="error-message">{{ error }}</div>
<form @submit.prevent="save">
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input v-model="form.itemname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Bin location</label>
<input v-model="form.binlocation" type="text" class="form-control"
placeholder="e.g., Bin A3" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="form.itemdescription" type="text" class="form-control"
maxlength="500" placeholder="Brief description shown on the storefront" />
</div>
<div class="form-row">
<div class="form-group">
<label>Low-stock threshold</label>
<input v-model.number="form.lowstockthreshold" type="number" min="0"
class="form-control" />
</div>
<div v-if="isEdit" class="form-group">
<label>Item code</label>
<input :value="itemcode" type="text" class="form-control" disabled />
</div>
</div>
<div class="form-group">
<label>Print notes</label>
<textarea v-model="form.printnotes" class="form-control" rows="3"
placeholder="Material, print time, slicer file path"></textarea>
</div>
<div v-if="isEdit" class="form-group">
<label>Photo</label>
<div class="image-row">
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
<input type="file" accept="image/*" @change="onImagePicked" />
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
@click="removeImage">Remove photo</button>
</div>
</div>
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const cancelTarget = computed(() =>
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
const form = ref({
itemname: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
const itemcode = ref('')
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (!isEdit.value) return
try {
const response = await printedpartsApi.get(route.params.id)
const item = response.data.data
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
itemcode.value = item.itemcode
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
console.error(loadError)
}
})
async function save() {
saving.value = true
error.value = ''
try {
const payload = { ...form.value }
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)
printeditemid = route.params.id
} else {
const response = await printedpartsApi.create(payload)
printeditemid = response.data.data.printeditemid
}
router.push(`/printedparts/${printeditemid}`)
} catch (saveError) {
error.value = saveError.response?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
try {
const response = await printedpartsApi.uploadImage(route.params.id, file)
imageurl.value = response.data.data.imageurl
} catch (uploadError) {
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
}
}
async function removeImage() {
try {
await printedpartsApi.deleteImage(route.params.id)
imageurl.value = null
} catch (deleteError) {
error.value = 'Could not remove the image'
console.error(deleteError)
}
}
</script>
<style scoped>
.image-row {
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview {
width: 6rem;
height: 6rem;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid var(--border);
}
.form-hint { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<div class="header-actions">
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
Print Labels
</router-link>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</div>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search code, name, description, bin..."
@input="debouncedSearch"
/>
<label class="lowstock-filter">
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
Low stock only
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th></th>
<th>Code</th>
<th>Name</th>
<th>Quantity</th>
<th>Bin</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.printeditemid"
class="clickable-row"
@click="$router.push(`/printedparts/${item.printeditemid}`)"
>
<td class="thumb-cell">
<img
v-if="item.imageurl"
:src="withBase(item.imageurl)"
:alt="item.itemname"
class="item-thumb"
/>
</td>
<td>{{ item.itemcode || '-' }}</td>
<td>{{ item.itemname }}</td>
<td>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }}
</span>
</td>
<td>{{ item.binlocation || '-' }}</td>
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" class="empty-state">No printed parts found</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:total-pages="totalPages"
@change="setPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printedpartsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { withBase } from '../../utils/basePath'
const items = ref([])
const loading = ref(true)
const lowstockOnly = ref(false)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (lowstockOnly.value) params.lowstock = 'true'
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printed parts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => setSearch(search.value), 300)
}
</script>
<style scoped>
.item-thumb {
width: 2.2rem;
height: 2.2rem;
object-fit: cover;
border-radius: 0.25rem;
}
.thumb-cell { width: 3rem; }
.truncate-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions { display: flex; gap: 0.5rem; }
.lowstock-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-light);
cursor: pointer;
}
</style>

View File

@@ -57,6 +57,7 @@
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
@@ -126,28 +127,29 @@
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="printer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
<!-- All relationships (defaultprinter, connectedto, ...) -->
<AssetRelationships v-if="printer.assetid" :assetId="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ printer.notes }}</p>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location -->
<!-- Network -->
<div class="section-card" v-if="printer.communications?.length">
<h3 class="section-title">Network</h3>
<div class="network-list">
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address">{{ comm.macaddress }}</span>
</div>
</div>
</div>
</div>
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location</h3>
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Map Location</span>
@@ -170,20 +172,19 @@
</div>
</div>
<!-- Network -->
<div class="section-card" v-if="printer.communications?.length">
<h3 class="section-title">Network</h3>
<div class="network-list">
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address">{{ comm.macaddress }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="printer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
<!-- All relationships (defaultprinter, connectedto, ...) -->
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ printer.notes }}</p>
</div>
</div>
</div>
@@ -261,6 +262,12 @@
</table>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">

View File

@@ -44,7 +44,7 @@
</tr>
</thead>
<tbody>
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid" class="clickable-row" @click="$router.push(`/printers/${printer.printer?.printerid || printer.assetid}`)">
<td>{{ printer.assetnumber }}</td>
<td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
<td>{{ printer.businessunitname || '-' }}</td>
@@ -55,7 +55,7 @@
{{ printer.statusname || 'Active' }}
</span>
</td>
<td class="actions">
<td class="actions" @click.stop>
<router-link
:to="`/printers/${printer.printer?.printerid || printer.assetid}`"
class="btn btn-secondary btn-sm"
@@ -90,13 +90,13 @@
import { ref, onMounted } from 'vue'
import { printersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
const search = ref('')
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
const totalPages = ref(1)
const perPage = ref(20)
@@ -135,24 +135,24 @@ async function loadPrinters() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadPrinters()
}, 300)
}
function onFilterChange() {
page.value = 1
setPage(1)
loadPrinters()
}
function goToPage(p) {
page.value = p
setPage(p)
loadPrinters()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadPrinters()
}

View File

@@ -220,6 +220,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const router = useRouter()
const route = useRoute()
@@ -418,7 +419,7 @@ async function runReport(report) {
function exportCSV() {
if (!currentReport.value) return
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
}
function clearReport() {

View File

@@ -103,11 +103,12 @@ import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const { page, setPage } = useListQuery({ onChange: loadData })
const totalPages = ref(1)
const perPage = ref(20)
@@ -136,11 +137,11 @@ async function loadData() {
}
}
function goToPage(p) { page.value = p; loadData() }
function goToPage(p) { setPage(p); loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadData()
}

View File

@@ -32,6 +32,7 @@
<th>Type</th>
<th>On Detail</th>
<th>On Form</th>
<th>Search</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
@@ -47,6 +48,7 @@
</td>
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
<td>{{ f.showonform ? 'yes' : '-' }}</td>
<td>{{ f.searchable ? 'yes' : '-' }}</td>
<td>{{ f.sortorder }}</td>
<td>
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
@@ -57,7 +59,7 @@
</td>
</tr>
<tr v-if="visibleFields.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
<td colspan="9" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
</tr>
</tbody>
</table>
@@ -92,6 +94,10 @@
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
</div>
<div class="form-row">
<label class="checkbox-label"><input type="checkbox" v-model="form.searchable" /> Searchable</label>
<span class="hint">Include this field's values in global search.</span>
</div>
<div class="form-row">
<div class="form-group">
<label>Sort Order</label>
@@ -131,7 +137,7 @@ const error = ref('')
const form = ref(blankForm())
function blankForm() {
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true }
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, searchable: false, sortorder: 0, isactive: true }
}
function typeLabel(t) {
@@ -174,6 +180,7 @@ function openModal(item = null) {
options: (item.options || []).join('\n'),
showondetail: item.showondetail !== false,
showonform: item.showonform !== false,
searchable: item.searchable === true,
sortorder: item.sortorder || 0,
isactive: item.isactive !== false,
}

View File

@@ -213,14 +213,14 @@ import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadLocations })
const totalPages = ref(1)
const perPage = ref(20)
@@ -286,19 +286,19 @@ async function loadLocations() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadLocations()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadLocations()
}

View File

@@ -147,11 +147,12 @@ import { modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const modelTypes = ref([])
const loading = ref(true)
const page = ref(1)
const { page, setPage } = useListQuery({ onChange: loadTypes })
const totalPages = ref(1)
const perPage = ref(20)
@@ -192,13 +193,13 @@ async function loadTypes() {
}
function goToPage(p) {
page.value = p
setPage(p)
loadTypes()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadTypes()
}

View File

@@ -209,15 +209,15 @@ import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const models = ref([])
const vendors = ref([])
const modelTypes = ref([])
const loading = ref(true)
const search = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadModels })
const vendorFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
@@ -290,19 +290,19 @@ async function loadModelTypes() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadModels()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadModels()
}

View File

@@ -121,11 +121,12 @@ import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const { page, setPage } = useListQuery({ onChange: loadData })
const totalPages = ref(1)
const perPage = ref(20)
@@ -154,11 +155,11 @@ async function loadData() {
}
}
function goToPage(p) { page.value = p; loadData() }
function goToPage(p) { setPage(p); loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadData()
}

View File

@@ -1,58 +1,30 @@
<template>
<div>
<div class="page-header">
<h2>Collector PC Types</h2>
<h2>Collector PC Types (retired)</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
When the collector ingests a PC, its imaging pc-type (from
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
Adjust the mapping per site.
This page has been retired. Imaging pc-type handling now lives in
GE-Enforce, where each imaging PC type is a manifest scope with its own
Computer Type. Manage it from the GE-Enforce section instead.
</p>
<div class="table-container" v-if="pcTypeMappings.length">
<table class="identifier-matrix">
<thead>
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
</thead>
<tbody>
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
<td class="identifier-name">{{ row.pxetype }}</td>
<td>
<select
:value="row.computertype"
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
:disabled="saving"
>
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="setting-description">No collector pc-type mappings are configured yet.</p>
<p class="setting-description">
The collector still applies the built-in pc-type to Computer Type
defaults, so existing enrollment keeps working; there is nothing to
configure here anymore.
</p>
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
saving, error, success, pcTypeMappings, computerTypes,
loadSettings, loadComputerTypes, changePcTypeMapping,
} = useSystemSettings()
onMounted(() => {
loadSettings()
loadComputerTypes()
})
// Deprecated page. Kept only so the /settings/pctypemapping route and old
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
// collector pc-type mapping UI. See ADR-012.
import { RouterLink } from 'vue-router'
</script>

View File

@@ -24,7 +24,7 @@
</thead>
<tbody>
<tr v-for="p in plugins" :key="p.name">
<td>{{ p.name }}</td>
<td>{{ p.displayname || p.name }}</td>
<td class="mono">{{ p.version }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>

View File

@@ -0,0 +1,109 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Item code prefix</label>
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
Changing it does not rename existing items.
</p>
</div>
<div class="form-group">
<label>Default low-stock threshold</label>
<input v-model.number="values.printedparts_default_threshold"
type="number" min="0" class="form-control" />
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
<option value="deny">Deny - refuse badges with no directory match</option>
<option value="allow">Allow - record the SSO with no name</option>
</select>
</div>
<div class="form-group">
<label>Low-stock alert recipients</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email). Alerts fire once when an item
crosses its threshold; restocking above re-arms.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: ''
})
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printedparts' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
</style>

View File

@@ -4,7 +4,7 @@
Pick a section from the left, or choose one below.
</p>
<section v-for="group in groups" :key="group.title" class="landing-section">
<section v-for="group in visibleGroups" :key="group.title" class="landing-section">
<h2 class="section-heading">{{ group.title }}</h2>
<div class="landing-grid">
<router-link
@@ -25,10 +25,15 @@
</template>
<script setup>
import { computed } from 'vue'
import { useSettingsCatalog } from '../../composables/settingsCatalog'
// Core settings groups merged with plugin-contributed cards (ADR-010).
const { groups } = useSettingsCatalog()
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
// disabled) so a bare section heading never renders.
const visibleGroups = computed(() => groups.value.filter(g => g.cards.length))
</script>
<style scoped>

View File

@@ -10,17 +10,27 @@
<nav class="rail-nav">
<template v-for="group in visibleGroups" :key="group.title">
<div class="rail-group-heading">{{ group.title }}</div>
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
<button
type="button"
class="rail-group-heading"
:class="{ collapsed: !isExpanded(group.title) }"
@click="toggleGroup(group.title)"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
<span class="rail-chevron" aria-hidden="true"></span>
{{ group.title }}
</button>
<template v-if="isExpanded(group.title)">
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
</template>
</template>
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
</nav>
@@ -48,7 +58,9 @@ const search = ref('')
// Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return groups.value
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
// disabled) so a bare heading never renders.
if (!term) return groups.value.filter(g => g.cards.length)
return groups.value
.map(g => ({
title: g.title,
@@ -65,6 +77,25 @@ function isActive(to) {
const base = to.split('?')[0]
return route.path === base
}
// Collapsible groups so the 13-section rail fits without scrolling: only the
// group containing the current page is open; the user can toggle others.
// Searching forces every matching group open.
const groupOfActive = computed(() => {
for (const g of groups.value) {
if (g.cards.some(c => isActive(c.to))) return g.title
}
return groups.value[0]?.title
})
const manualToggles = ref({})
function toggleGroup(title) {
manualToggles.value = { ...manualToggles.value, [title]: !isExpanded(title) }
}
function isExpanded(title) {
if (search.value.trim()) return true
if (title in manualToggles.value) return manualToggles.value[title]
return title === groupOfActive.value
}
</script>
<style scoped>
@@ -82,11 +113,11 @@ function isActive(to) {
overflow-y: auto;
}
.rail-title {
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.rail-search {
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
.search-input {
width: 100%;
@@ -99,26 +130,52 @@ function isActive(to) {
}
.rail-group-heading {
margin: 1rem 0 0.3rem 0;
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
margin: 0.35rem 0 0.15rem 0;
padding: 0.2rem 0;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.rail-group-heading:hover {
color: var(--text);
}
.rail-group-heading:first-child {
margin-top: 0;
}
/* CSS-drawn caret (ASCII-only source): a right-pointing triangle that
rotates down when the group is expanded. */
.rail-chevron {
width: 0;
height: 0;
flex: 0 0 auto;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
transition: transform 0.12s ease;
}
.rail-group-heading:not(.collapsed) .rail-chevron {
transform: rotate(90deg);
}
.rail-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
padding: 0.25rem 0.55rem;
border-radius: 6px;
text-decoration: none;
color: var(--text);
font-size: 0.88rem;
font-size: 0.86rem;
border-left: 2px solid transparent;
}
.rail-link:hover {

View File

@@ -141,11 +141,12 @@ import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const statuses = ref([])
const loading = ref(true)
const page = ref(1)
const { page, setPage } = useListQuery({ onChange: loadStatuses })
const totalPages = ref(1)
const perPage = ref(20)
@@ -186,13 +187,13 @@ async function loadStatuses() {
}
function goToPage(p) {
page.value = p
setPage(p)
loadStatuses()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadStatuses()
}

View File

@@ -287,6 +287,7 @@ import { networkApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const route = useRoute()
@@ -295,10 +296,9 @@ const subnets = ref([])
const vlans = ref([])
const locations = ref([])
const loading = ref(true)
const search = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadSubnets })
const vlanFilter = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
@@ -383,19 +383,19 @@ async function loadSubnets() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadSubnets()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadSubnets()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadSubnets()
}

View File

@@ -189,13 +189,13 @@ import { networkApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVLANs })
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
@@ -243,19 +243,19 @@ async function loadVLANs() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadVLANs()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadVLANs()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadVLANs()
}

View File

@@ -26,13 +26,15 @@ export const settingsGroups = [
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
// devices all run an OS), so it lives in General Reference, not under PCs.
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
],
},
{
title: 'PCs',
cards: [
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
@@ -50,9 +52,16 @@ export const settingsGroups = [
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
],
},
// The "Measuring Tools" group is contributed by the measuringtools plugin via
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog),
// not hardcoded here.
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
// This empty placeholder fixes the group's position among the asset groups
// (right after Machines); the merger fills it in place instead of appending a
// new group at the end. When the plugin is disabled the group stays empty and
// the rail/landing drop it (empty groups never render).
{
title: 'Measuring Tools',
cards: [],
},
{
title: 'Locations & Organization',
cards: [
@@ -83,7 +92,6 @@ export const settingsGroups = [
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
{ to: '/settings/pctypemapping', icon: Laptop, title: 'Collector PC Types', description: 'Map collector enrollment imaging pc-type (shopfloor) to a Computer Type' },
],
},
{

View File

@@ -149,12 +149,12 @@ import EmployeeSearch from '../../components/EmployeeSearch.vue'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const devices = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
const totalPages = ref(1)
const perPage = ref(20)
const showAvailableOnly = ref(false)
@@ -195,19 +195,19 @@ async function loadDevices() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadDevices()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadDevices()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadDevices()
}

View File

@@ -188,12 +188,12 @@ import { vendorsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
const totalPages = ref(1)
const perPage = ref(20)
@@ -243,19 +243,19 @@ async function loadVendors() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
setSearch(search.value)
loadVendors()
}, 300)
}
function goToPage(p) {
page.value = p
setPage(p)
loadVendors()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
setPage(1)
loadVendors()
}

View File

@@ -51,7 +51,7 @@
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<span v-if="!w.assets.length" class="muted">-</span>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip" :title="a.name || a.assetnumber">{{ a.assetnumber }}</router-link>
</td>
<td class="actions">
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
@@ -82,16 +82,21 @@
<div class="form-row">
<div class="form-group">
<label>Vendor *</label>
<input v-model="form.vendor" type="text" class="form-control" maxlength="100" required />
<select v-model="form.vendor" class="form-control" required>
<option value="" disabled>Select a vendor...</option>
<option v-for="name in vendorOptions" :key="name" :value="name">{{ name }}</option>
</select>
<small class="muted">Who backs the warranty. Manage the list under Vendors.</small>
</div>
<div class="form-group">
<label>Provider</label>
<label>Lookup source</label>
<select v-model="form.provider" class="form-control">
<option value="manual">Manual</option>
<option value="dell">Dell</option>
<option value="lenovo">Lenovo</option>
<option value="hp">HP</option>
<option value="manual">Manual entry</option>
<option value="dell">Dell (auto-refresh)</option>
<option value="lenovo">Lenovo (auto-refresh)</option>
<option value="hp">HP (auto-refresh)</option>
</select>
<small class="muted">Where coverage data comes from. Non-manual sources can be refreshed from the maker's warranty API.</small>
</div>
</div>
<div class="form-row">
@@ -155,7 +160,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
import { warrantyApi, assetsApi, vendorsApi } from '../../api'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
@@ -196,6 +201,15 @@ const form = ref(blankForm())
const selectedAssets = ref([])
const assetQuery = ref('')
const assetResults = ref([])
// Vendor dropdown options, pulled from the site vendor catalog.
const vendorNames = ref([])
// Keep an existing warranty's vendor selectable even if it is not (or no longer)
// in the catalog, so editing never silently blanks it.
const vendorOptions = computed(() => {
const current = (form.value.vendor || '').trim()
if (current && !vendorNames.value.includes(current)) return [current, ...vendorNames.value]
return vendorNames.value
})
function blankForm() {
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
@@ -215,6 +229,7 @@ function assetLink(a) {
onMounted(async () => {
await loadData()
loadVendors()
// Deep-link from an asset detail page: open the add modal pre-linked to it.
const addfor = route.query.addfor
if (addfor) {
@@ -229,6 +244,18 @@ onMounted(async () => {
}
})
async function loadVendors() {
// Best-effort: the combobox still accepts free text if this fails.
try {
const response = await vendorsApi.list({ per_page: 1000 })
const rows = response.data.data
const list = Array.isArray(rows) ? rows : (rows.items || [])
vendorNames.value = list.map(v => v.vendor || v.name).filter(Boolean).sort()
} catch (err) {
vendorNames.value = []
}
}
async function loadData() {
loading.value = true
try {
@@ -350,6 +377,9 @@ async function refresh(w) {
.muted { color: var(--text-light); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.filters { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
/* Keep the "Status" label + its select on one line so it aligns with the
single-line search box next to it. */
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
.filters .form-control { max-width: 320px; }
.result-count { color: var(--text-light); font-size: 0.85rem; }
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -3,6 +3,10 @@ import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
// Mount path of the built app. Default '/' (own IIS site / dev). Set
// VITE_BASE_PATH='/ops/' (trailing slash) to build for a subpath mount under
// Default Web Site, e.g. `VITE_BASE_PATH=/ops/ npm run build`.
base: process.env.VITE_BASE_PATH || '/',
plugins: [vue()],
resolve: {
alias: {

19
frontend/vitest.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// Standalone vitest config so the unit runner stays independent of vite.config.js.
// jsdom gives component specs a DOM; the '@' alias mirrors the app build.
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.spec.js'],
},
})

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