115 Commits

Author SHA1 Message Date
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
cproudlock
cced6fe96e Release 0.7.0
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Cuts the large post-0.6.0 pile as a pinnable release: the machines
rename (ADR-011), the API import surface, personal/scoped/collector
API tokens and the get_permissions plugin hook, the ADR-010 frontend
hook contract, per-plugin migrations, model/employee photos, the
dualpath single-machine toggle and relationship propagation, support
teams, email sending, and the shared asset label generator. Plugin
contract moved 0.6.0 -> 0.10.0 over this range (distinct series per
ADR-007).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:00:58 -04:00
cproudlock
55b42fed00 Fix sidebar footer overflow from the change-password link
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Adding the Change Password action turned the user menu into a
full-width-button row that overflowed the fixed sidebar. The footer is
now an identity row (avatar + truncating name) above a compact
icon-button actions row (Password / Logout), both within bounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:28:08 -04:00
cproudlock
4b20c2eb56 Add batch ULINE label printing for all asset types
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
A multi-select batch sheet at /print/asset-label-batch/<type> lays
selected assets onto ULINE label pages (6-up 3x3 or dense 72-up mini),
with code-type and encode toggles matching the single label, plus a
start-cell offset for partial sheets. Print Labels buttons on all five
asset list pages. The per-type config and encode resolution are
extracted to a shared print/assetLabel.js used by both the single and
batch views. Measuring-tool batches encode each tool inspection-
operation code (decode-verified 0615).

808 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:08:22 -04:00
cproudlock
a846587f39 Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 11:58:30 -04:00
cproudlock
7d309aabeb Final-pass polish: support contact UX, audit tooltip, doc refresh
All checks were successful
CI / backend (push) Successful in 1m21s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Support teams: contact management moved from a row expander to a modal
(Contacts (N) button per team); application detail Support card and the
modal show Email (mailto) and Teams chat buttons for contacts with an
SSO, derived as sso@ + a new contact_email_domain site setting
(default geaerospace.com, blank hides the buttons).

Audit log: hovering a user SSO shows the full name, resolved
best-effort from the employee directory in either mode.

Docs/hygiene from a standards review: CLAUDE.md active-state,
CONTRACT-STABILITY.md and README brought to contract 0.10.0 / 11
plugins / migration head 7d22; get_asset_panels endpoint path fixed in
the hook docstring; leftover debug console.logs removed.

781 tests pass; contacts modal, action-button hrefs, and the audit
tooltip verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 11:22:01 -04:00
cproudlock
1c5128a5c8 Fit the audit-log table; drop the collector token preset button
All checks were successful
CI / backend (push) Successful in 1m20s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Audit Logs now uses a fixed table layout with tuned column widths:
every column fits the card without horizontal scrolling, long entity
names truncate with a hover tooltip, timestamps show a compact
no-seconds form (full value on hover), and the 9-digit user SSO renders
fully.

Removed the Collector service token quick-preset from the create modal
per user preference - the collector.ingest checkbox in the scope grid
is the path now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 09:57:02 -04:00
cproudlock
12da0429b0 Preserve destination on session-expiry login; compact the tokens table
All checks were successful
CI / backend (push) Successful in 1m20s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
The 401 interceptor (session expired) redirected to bare /login,
losing the page the user was on - the router guard already preserved
it but the expiry path bypassed the guard. The interceptor now carries
?redirect= like the guard does, and Login returns there.

The API tokens tables overflowed the card: the token column no longer
repeats the shopdb_pat_ prefix per row (short prefix shown, full form
on hover) - the token itself was already never displayed after
creation.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 09:29:55 -04:00
cproudlock
12175169e4 Accept managed collector service tokens on the collector API
All checks were successful
CI / backend (push) Successful in 1m21s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
A token scoped to the new collector.ingest permission is a collector
service token: the collector endpoints accept it via X-API-Key or
Bearer alongside the env fleet keys (which remain the fallback), giving
the fleet credential rotation, revocation, and last-used visibility
from the API Tokens page. Containment holds both ways: a collector
token authorizes nothing else, and no other credential gains collector
access. Shared token validation refactored out of the auth shim; a
Collector service token quick-preset in the create modal; integration
guide documents minting, rotation via site-config.json, and the
service-identity pattern.

765 tests pass; live acceptance matrix verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 09:13:33 -04:00
cproudlock
848a8fb34f Add optional permission scopes to API tokens
All checks were successful
CI / backend (push) Successful in 1m19s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
A token may carry a scopes list: it then grants only those permissions,
intersected with what the owner holds at use time, with the admin role
bypass suspended and role-gated routes denied - a scoped token from an
admin account is genuinely limited. Scope ceiling enforced at
create/update too (only permissions the owner holds; 400 lists
violations) and the picker only offers what you hold. Token management
itself now requires the new apitokens.create permission (admin by
default, grantable via roles). Unscoped tokens keep the exact prior
act-as-owner behavior; imports need an unscoped admin token.
Migration 7d22.

756 tests pass; live-verified scoped 201/403 matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:58:31 -04:00
cproudlock
688ff6646d Add the missing changelog entry for the integration-gap fixes
All checks were successful
CI / backend (push) Successful in 1m16s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:33:47 -04:00
cproudlock
da86b3ae0c Add personal API tokens; wire measuring tools into remaining surfaces
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
API tokens: any user mints named, optionally-expiring tokens
(shopdb_pat_..., sha256-stored, secret shown once) at Settings > API
Tokens; a before-request shim swaps a valid PAT for a request-scoped
JWT of its owner, so the entire existing auth/authz/import-mode stack
works unchanged and revoked/expired tokens 401 cleanly. Built for
long-running scripts - the legacy import no longer dies when a login
JWT expires. Migration 7d21_apitokens; create/revoke audit-logged.

Audited integration gaps fixed: Asset.to_dict serializes measuring
tools (typedata + pluginid - relationship links to tools resolve); map
subtype filter/colors and MapEditor include them; dashboard totals
count them; warranty links use a new by-asset route; the measuringtools
ADR-010 hooks are real (corrected presentation token, implemented
map-overlay endpoint); the login avatar resolves through the
employee-photo helper.

737 tests pass; naming green; frontend builds; both features verified
live end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:33:02 -04:00
cproudlock
64a5abdb08 Wire measuring tools into identifiers and global search
All checks were successful
CI / backend (push) Successful in 1m14s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
The measuringtools plugin was missing from two cross-cutting surfaces:
the asset-identifier matrix (no measuring_tool column or per-type
keys - gauge lab reference is their primary identifier) and global
search (results fell to a generic URL and gaugelabreference was never
searched). Measuring tools now have identifier toggles, gated
gauge-lab and maintenance-reference fields on their form and detail,
a search domain toggle, gage-tag search, and proper labels, routes,
and filter chips in search results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:11:39 -04:00
cproudlock
31f5e07294 Keep the settings rail alive across child navigation
All checks were successful
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Keying the outer router-view on route.path made every settings click
remount the whole settings shell, resetting the rail scroll to the
top. The key now treats /settings/* as one unit so the shell (and its
scroll position) persists while child pages swap; detail-to-detail
remounts elsewhere are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:56:39 -04:00
cproudlock
9fc5aca63b Group Site and Facility settings by concept
All checks were successful
CI / backend (push) Successful in 1m14s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Fields rendered in arbitrary key order (dualpath toggle first,
facility name buried, setup flag mid-list). Now grouped with headers:
Identity, Behavior, Naming and Patterns, Data Sources, System - with
unknown future keys falling into Other at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:53:45 -04:00
cproudlock
1e93b3d570 Dissolve System Settings into individual settings pages
Some checks failed
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 1s
CI / frontend (push) Has been cancelled
The monolithic tab page competed with the settings rail as a second
navigation system, and its Integrations tab was a dumping ground. Each
section is now its own routed rail page (ServiceNow, Zabbix Supplies,
Dell Warranty, Collector PC Types, Branding, Floor Map, Printing and
Labels, Email/SMTP, Audit, Authentication, Asset Identifiers, Global
Search), thin over a shared useSystemSettings composable, grouped
logically in the rail with system groups clustered last. Old
/settings/system?tab= URLs redirect to the right page.

Also fixes the post-login redirect: the auth guard now remembers the
intended destination and Login returns there (same-site paths only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:52:21 -04:00
cproudlock
5393846b8d Polish the Site and Facility settings page
All checks were successful
CI / backend (push) Successful in 1m12s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Booleans render as checkbox toggles, the employee/USB directory-mode
settings as selfhosted/external dropdowns, and every field has a label
and help text (raw keys and type-true/false text boxes are gone).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:39:11 -04:00
cproudlock
b130ef43f3 Add the dualpath-as-single-machine site toggle
All checks were successful
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Most facilities consider a Dualpath pair one physical dual-bay machine.
New site setting dualpath_single_machine (default on): the machines
list, dashboard counts, machines-by-type report, and floor map collapse
each pair to its primary bay (lower assetnumber), with combined
2007 / 2008 labels; pagination totals stay honest. Detail pages remain
per-bay and always show a dual-bay sibling banner linking the partner.
Pair resolution lives in core services and joins the plugin contract
surface (0.8.0 -> 0.9.0).

On the WJ dataset: 31 pairs collapse, machine counts 262 -> 231, map
470 assets. Toggle verified live in both states, left on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:23:39 -04:00
cproudlock
5a192f3100 Sync System Settings tabs with the URL query
All checks were successful
CI / backend (push) Successful in 1m12s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
activeTab read ?tab= only once at mount, so settings-rail links that
change just the query (Branding, Floor Map) updated the URL without
switching the panel. The query param is now the source of truth: a
watcher applies rail/back/forward navigation and tab clicks write the
query via router.replace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:12:58 -04:00
cproudlock
4fd110a33d Wire relationship directionality and dualpath controls propagation
All checks were successful
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Symmetric relationship types (isdirectional flag, migration 7d19) show
one entry per peer on the relationships card - a Dualpath pair no
longer lists its partner twice - and directional types read naturally
instead of Outgoing/Incoming. Deleting a collapsed entry removes every
underlying direction row.

Propagation is now real (migration 7d20): relationship types declare
propagation-through pairs in relationshiptypepropagations (M:N,
replacing the never-consumed single column); creating a controls link
on either bay of a Dualpath pair auto-creates it on the partner,
mirrored across both endpoints because live data stores controls as
bay -> PC. flask relationships propagate backfills existing data (29
rows fanned out on the WJ dataset, idempotent).

This also completes the tree that commit 1d21bf0 accidentally split
(core/models/__init__ imported RelationshipTypePropagation ahead of the
file that defines it), returning CI to green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:07:36 -04:00
cproudlock
1d21bf0206 Add photo management for models and employees; fix stale detail navigation
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Model photos: upload/replace/delete on /api/models/<id>/image (admin),
stored under instance/modelimages/ with a public serve route; thumbnail
plus Upload/Replace/Remove controls in the Models settings modal; the
URL field remains as a manual alternative.

Employee photos, mode-aware: self-hosted directory employees get
upload/replace/delete (photo-<sso> under instance/employeephotos/,
employees plugin migration 0002); external directory mode passes the
HR-supplied picture URL through read-only (writes 409). One resolver
feeds both consumers - the shopfloor recognition/recert kiosk cards and
the employee detail hero - in either mode.

Navigation fix: router-view is keyed on route path, so following a
relationship link between two assets of the same type (machine ->
dualpath machine) reloads the page instead of showing stale content;
query-only URL changes still avoid a remount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:00:37 -04:00
cproudlock
7dae281993 Add application support teams with contacts
All checks were successful
CI / backend (push) Successful in 1m7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Replaces the legacy supportteams/appowners pair: supportteams
(teamname unique, teamurl ServiceNow link) + supportteamcontacts
(multiple named contacts with SSO per team, the people you reach out
to), applications.supportteamid intact. Migration 7d18 migrates each
legacy team owner into a contact, drops appowners, and has a validated
downgrade. New /api/supportteams CRUD (admin writes, import-mode
timestamps, teamname lookup), Support card on application detail,
contacts column on the list, and a settings management page.
IMPORT-API.md mapping updated to the concrete endpoints.

658 tests pass; live dev migration applied (24 teams / 24 contacts);
fresh-install and downgrade round-trips verified on scratch DBs.

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

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

635 tests pass; naming green; frontend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:10:46 -04:00
cproudlock
f8e5109255 Pin selfhosted directory modes in the authz sweep
All checks were successful
CI / backend (push) Successful in 1m2s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 7s
The sweep exercises usb/employees handlers, which default to EXTERNAL
directory mode when no setting row exists - green on the dev box where
the external MySQL databases happen to exist, red in CI where they do
not. Seed selfhosted mode in an autouse fixture so the guard is
deterministic everywhere. Verified by running the suite with the
external DB hosts pointed at an unreachable address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:56:24 -04:00
cproudlock
24f67d6ac5 Accept + implement ADR-010 frontend plugin hooks (contract 0.7.0)
Some checks failed
CI / backend (push) Failing after 1m2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Four data-only hooks on BasePlugin (get_settings_cards,
get_asset_panels, get_map_overlays, get_asset_presentation) with a
GET-only /api/pluginui consumer surface copying the dashboard-widgets
semantics. Pilots: warranty declares its asset panel; measuringtools
supplies its settings card, presentation, and calibration overlay -
the last hardcoded settings-nav entry is now hook-sourced. Generic
renderers for panels/overlays/presentation deferred per the ADR's
incremental adoption plan (documented in CONTRACT-STABILITY.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:38:32 -04:00
cproudlock
9eed3745fc Add parametrized authz sweep guard over all mutating routes
Every non-exempt POST/PUT/PATCH/DELETE must 403 a role-less member
and pass authz for admin; exemptions (auth, collector, setup wizard,
kiosk click-through, admin-or-self user update) are documented in
the test. Any future unguarded mutation fails CI as its own case.
Sweep confirmed existing gating complete: zero routes needed fixes;
lockout already implemented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:35:57 -04:00
312 changed files with 29646 additions and 5942 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,343 @@ 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
- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`;
`smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings
settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and
is a graceful no-op (logs a warning, returns False) when email is disabled or
the host is unset. The SMTP password is never logged. Three flows use it:
(1) New-user welcome + forced first-login password change. Admin-created users
(POST `/api/users`) are flagged `mustchangepassword` (new `users` column,
migration `7d23_user_mustchangepassword`, default false) and sent a
best-effort welcome email with the facility name, username, temporary
password, and sign-in link; the account is created even if mail fails
(response carries a `warning`). Login returns `mustchangepassword`; the
frontend forces the user through a new `/change-password` view (POST
`/api/auth/change-password`, jwt-guarded) before the app, and changing the
password clears the flag and resets lockout counters. A self-service "Change
password" entry is also available from the user menu.
(2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe
and surfaces any SMTP error with the password scrubbed; wires up the Email /
SMTP settings page "Send Test Email" button.
(3) On-demand alert/report delivery. POST `/api/reports/email`
(reports.export) mails `{subject, columns, rows}` as an HTML table to a
supplied recipient or the site `alert_recipients`; an "Email report" button on
the Warranty and Toner report pages posts the loaded rows. There is no
scheduler: automation is an external cron hitting the endpoint with a scoped
API token (PAT). Documented in `docs/CONFIG.md`.
- Shared asset label/code generator: a single `/print/asset-label/<assettype>/<id>`
page (public, like the other `/print/*` routes) that any asset detail page
opens via a "Print Label" button (machines, computers, printers, network
devices, measuring tools). A no-print controls panel toggles the layout
(`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode),
and what the code encodes: the asset page link, asset number, serial number,
a per-type custom target template, or - for measuring tools by default - the
tool's inspection location code so every tool at one operation shares one
code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New
`printing` settings seed and surface on the Printing & Labels settings page:
`qr_target_machine`, `qr_target_computer`, `qr_target_network_device`,
`qr_target_measuring_tool`; `label_default_style` (default `card`) and
`label_default_codetype` (default `qr`); and a per-asset-type default for what
the code encodes, `label_default_encodes_<type>` (machines default to their
machine number, measuring tools to their inspection location code, the rest to
a page link), all overridable on the label page itself. When the chosen field
has no value (e.g. serial number on an asset with none), the label states so
instead of rendering an empty code. Asset payloads now carry a derived
`locationcode` (leading token of the resolved own/inherited location name).
- Batch label sheets: a "Print Labels" button on each asset list page opens
`/print/asset-label-batch/<assettype>`, a multi-select sheet that lays the
chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up
mini-label format), with a start-cell offset to reuse partial sheets. Shares
the same code-type and encode settings/defaults as the single label, so a
batch of measuring tools encodes each tool's inspection location code just
like the single label does. Restores the ULINE batch printing the legacy
shopdb had, generalized across all asset types.
- Support-team contact UX: the settings Support Teams page now manages each
team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
Add/Edit/Delete) instead of an inline row expander, and the application
detail Support card renders Email (`mailto:`) and Microsoft Teams chat
(`teams.microsoft.com/l/chat`) action buttons for every contact that has an
SSO. Both link targets derive as `sso@<domain>` from a new `site` setting
`contact_email_domain` (default `geaerospace.com`; blank hides the buttons),
surfaced in Site & Facility settings under Naming & Patterns and read on the
frontend via `getContactEmailDomain()`.
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
permissions its own routes enforce, instead of core accumulating every
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core
sets (assets, applications, reports, settings, users, audit, apitokens,
collector); the 36 permissions for machines, computers, printers, network,
knowledgebase, notifications, usb, warranty, and measuringtools moved into
each owning plugin's hook. New core helper `full_permission_catalog()` merges
core plus every ENABLED plugin's permissions and backs all three consumers:
`flask seed permissions`, the role grid (`GET /api/users/permissions`), and
API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install
and enable seed the plugin's own permissions idempotently. A disabled plugin
drops out of the catalog (no new scope grants or role assignments), but its
existing `Permission` rows and role links persist so current roles keep
working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md`
permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md`
hooks table row.
- Personal API tokens (PATs) so scripts and integrations authenticate without
the hourly-expiring login JWT (immediate consumer: long legacy-import runs
that die when the JWT expires mid-run). New core `apitokens` table + migration
`7d21_apitokens` (stores only the sha256 hash of each secret; the full secret
`shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint
`/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate;
revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode
by a before_request shim that mints a request-scoped JWT for the token's
owner, so the entire existing auth+authz stack (jwt_required,
require_permission, require_role, import mode, current_user) authenticates the
PAT as its owner with zero decorator changes; an invalid, revoked, or expired
PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one
write per 60s). Any authenticated user manages their own tokens; admins may
list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`)
with a create modal that reveals the secret once (copy button) and an admin
All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to
recommend a PAT for imports. Core feature; no plugin-contract change.
- Optional permission scopes on personal API tokens. A token MAY carry a scopes
list (permission names, migration `7d22_apitokens_scopes` adds the nullable
`apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its
owner). A scoped token grants ONLY the listed permissions, intersected with
what the owner actually holds at use time, and SUSPENDS the admin-role bypass,
so a scoped token minted by an admin is genuinely limited: it is denied on
role-gated (`require_role`) endpoints and gets no import mode. The shim mints
the request JWT with a `patscopes` claim that `require_permission`,
`require_role`, and `import_mode_active` read; normal login JWTs carry no such
claim and are unaffected (zero regression). Scopes are validated at write time
against the token OWNER's permissions (the scope ceiling - a token can never
grant more than its owner holds; when an admin edits another user's token the
ceiling is that owner's permissions), rejecting unknown or unheld names 400.
Minting/managing tokens now requires the new `apitokens.create` permission
(category `apitokens`; admins hold it by default, grantable via the roles UI)
rather than being open to any authenticated user. The Settings > API Tokens
create/edit modals gain a "Restrict permissions" section (a category-grouped
checkbox grid limited to the permissions the creator holds) and the token
lists show a full-access / N-permissions access chip.
- Managed collector service tokens: the collector ingest API
(`/api/collector/<plugin>` + the legacy `/pc` `/apps` `/heartbeat` `/bulk`
`/status` endpoints) now ALSO accepts a managed API token scoped to the new
`collector.ingest` permission (category `collector`), on top of the existing
`COLLECTOR_API_KEY[_<PLUGIN>]` env keys (which stay supported as a
bootstrap/legacy fallback - nothing breaks). The token may be presented in
`X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer`
token; both transports validate the PAT the same way the login shim does
(hash lookup, active, unexpired, active owner) via a shared
`resolve_api_token` helper refactored out of `apitoken_auth.py`, require
`collector.ingest` in the token's scope list AND that the owner holds it, and
stamp `lastusedat` (same 60s throttle). A token scoped to ONLY
`collector.ingest` is a collector service token: it authorizes the collector
API and NOTHING else - the existing scoped-token machinery denies it on every
permission- and role-gated route and on import mode, so a leaked collector
token cannot touch the regular API. Recommended flow (documented): an admin
mints the scoped token (the scope suspends the admin bypass, containing it);
rotate by minting a new one, deploying via `site-config.json`, watching
`lastusedat`, then revoking the old. The Settings > API Tokens create modal
gains a "Collector service token" quick-preset (pre-selects only
`collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed
collector tokens" section) and `docs/CONFIG.md`. Core feature; no
plugin-contract change.
- Vendor-model photos on asset detail heroes: computers and printers now
surface the linked model's `imageurl` in their extension payloads (the
field machines already exposed), and the machine, PC, printer, network
device, and measuring tool detail pages render the photo in the hero card
when present (hidden cleanly when absent). Network devices and measuring
tools have no model link yet, so their heroes stay photo-less until one
is added.
- Dualpath "single machine" site toggle (`dualpath_single_machine`, default
on). A Dualpath relationship pair is one physical dual-bay machine (single
controller, bay-selector switch); when on, the machines list, dashboard and
machines-by-type counts, and the floor map collapse each pair to one entry
(the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden)
and show a combined `2007 / 2008` label. The data model is unchanged (both
bay records always exist); detail pages stay per-bay and always show a
sibling-bay banner regardless of the toggle. Contract surface (plugin
contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers
`resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by
the machines plugin to collapse pairs contract-purely.
- Relationship propagation, wired and data-driven: relationship types
declare propagation-through pairs (relationshiptypepropagations M:N,
replacing the never-consumed single column); creating a controls link on
one Dualpath bay auto-creates it on the partner bay, and
`flask relationships propagate` backfills existing data.
- Employee photos, mode-aware: self-hosted directory employees support
upload/replace/delete (admin), served publicly for kiosk cards; external
directory mode passes the HR-supplied picture URL through read-only. One
resolver feeds the shopfloor recognition/recert cards and the employee
detail hero in either mode.
- Vendor-model photo management. New admin-gated core endpoints
`POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
one image per model, replace semantics) and
`DELETE /api/models/<modelid>/image`, plus the public
`GET /api/models/image/<filename>` serve route. Uploads land in
`instance/modelimages/` (survives upgrades, backed up with the rest of
`instance/`) and set `models.imageurl` to the served URL; the manual Image URL
field still accepts external URLs and the shipped `/images/models/*` assets
(upload is additive). Delete only removes files we own under the instance dir.
The Models settings page grows a thumbnail, Upload/Replace, and Remove
controls in the edit modal. Asset hero images (e.g. the machine badge) read
`imageurl` unchanged, so uploaded photos render with no consumer changes.
- Application support teams with contacts, replacing the legacy
supportteams/appowners pair. New core `supportteamcontacts` table (multiple
named contacts per team, ordered by `sortorder`); `supportteams` keeps
`teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and
sheds the single-owner `appownerid` FK. New core blueprint at
`/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname`
exact-match lookup for import; delete a team 409s while any application still
references it). Migration `7d18_supportteamcontacts` migrates each legacy
team's app owner into one contact. Application payloads now flatten
`supportteamname`, `teamurl`, and the team's active `contacts`; a Support
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 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`,
`parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is
an admin AND sends header `X-Import-Mode: true`, create/update endpoints
accept optional `createddate` / `modifieddate` (ISO or legacy
`YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now.
Non-admin or missing header: the fields are ignored exactly as before.
Wired into every timestamped import target: assets (all five type plugins),
vendors, models, modeltypes, businessunits, locations, operating systems,
applications, knowledge base, USB devices, and asset relationships.
- Natural-key exact-match lookup filters for the documented
lookup-then-upsert idempotency recipe: `assetnumber` on all five asset
plugin list endpoints; `vendor`, `modelnumber`, `modeltype`,
`businessunit`, `locationname`, `osname`/`osversion`, `appname`,
knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`,
and notification `ticketnumber`.
- Backdated event history: in import mode the selfhosted USB checkout/checkin
endpoints accept optional `checkouttime` / `checkintime` overrides so
migrated `usbcheckouts` rows keep their real event times.
- New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch`
legacy schema: order of operations, a full table-by-table mapping, honest
no-target list with dispositions, a worked idempotent Python importer, and
row-count parity checks.
### Changed
- System Settings is no longer one tabbed page. The inner tab bar is gone and
each section is its own routed settings page reached through the settings
rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
(the former Integrations dumping ground, now split three-plus ways), plus
Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
these under Site & Facility, Integrations, Communication, Search & Identity,
and Access & Security. Shared load/save/upload plumbing moved into a
`useSystemSettings` composable so the pages stay thin. Old bookmarks keep
working: `/settings/system` and every `/settings/system?tab=<key>` redirect
to the matching new page.
### Fixed
- 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 (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.
- Measuring tools wired into the remaining cross-cutting surfaces an audit
found them missing from: generic asset serialization (typedata + pluginid,
which also fixes relationship-card links to tools), map subtype
filtering/coloring and the MapEditor filter, dashboard totals, warranty
asset links (via a new by-asset detail route), and the two ADR-010 hook
declarations (presentation route token corrected; the calibration
map-overlay endpoint now actually exists). The login avatar also resolves
through the employee-photo helper, so self-hosted uploads show.
- Measuring tools are now wired into the asset-identifier matrix and global
search. The Settings identifier matrix gains a Measuring Tool column and the
gauge-lab and maintenance reference inputs/rows on the measuring-tool form
and detail pages honor those per-type toggles (a maintenance-reference field
was added, matching the other asset types). Global search gains a Measuring
Tools domain toggle and filter chip, routes measuring-tool hits to
`/measuringtools/<id>` (previously the generic `/assets/<id>` fallback), and
matches on `gaugelabreference` so a gage-tag lookup resolves the tool.
- Site & Facility settings page renders booleans as toggles and the
directory-mode settings as dropdowns, with labels and help text for every
field (no more raw keys or type-true/false boxes).
- System Settings tabs follow the URL: clicking a settings-rail link that
only changes the ?tab= query (Branding, Floor Map) now switches the right
panel, tab clicks update the URL, and browser back/forward restore tabs.
- Following a relationship link between two assets of the same type now loads
the destination page instead of stale content (router-view keyed on path;
query-only URL changes still avoid a remount).
- Asset relationships card no longer lists a symmetric peer twice. Relationship
types gain `relationshiptypes.isdirectional` (migration
`7d19_relationshiptype_directional`; seeded false for the connection-like
types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet,
USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/
defaultprinter). The card now collapses every stored direction row of a
symmetric type into one direction-blind "Connected" entry per peer (deleting
it removes all collapsed rows), while directional types drop the
Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer`
phrasing. The type CRUD and the per-asset relationships endpoint carry
`isdirectional`; the Relationship Types settings page gains a Directional
toggle.
## [0.6.0] - 2026-07-11
### Added
@@ -104,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).
@@ -137,5 +474,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
integration that passed the key as a query parameter. See
`docs/COLLECTOR-INTEGRATION.md`.
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...HEAD
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...HEAD
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0

View File

@@ -16,14 +16,20 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
- ADR-004: Deployment topology (per-site instances, not multi-tenant) - ACCEPTED
- ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED
- ADR-006: Plugin collector contract pattern - ACCEPTED
- ADR-007: Product versioning and releases - ACCEPTED
- ADR-008: Plugin migration ownership (per-plugin chains) - ACCEPTED
- ADR-009: Frontend plugin route gating - ACCEPTED
- ADR-010: Frontend plugin hook contract - ACCEPTED
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
- ADR-012: GE-Enforce manifest ownership in shopdb - ACCEPTED
## 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-10)
## Current state (as of 2026-07-13)
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progress.
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely complete; the last big milestone is the legacy-data import + a production pilot.
### Phases done
@@ -36,10 +42,12 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
### Active state
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
- 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.11.0 (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
@@ -47,8 +55,9 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
- Frontend hook contract for asset-detail, map markers, search results
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet)
- (DONE) Frontend plugin hook contract (ADR-010): get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation shipped; generic renderers for panels/overlays land incrementally.
- (DONE) Per-plugin Alembic chains (ADR-008): every bundled plugin carries its own chain; no plugin uses db.create_all().
- Legacy ASP data import against the renamed schema (unblocked; run via docs/IMPORT-API.md) + a production pilot deployment.
## Quick start
@@ -126,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,16 +7,22 @@ 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.6+ database
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
- JWT authentication
- Plugin architecture for extensibility
@@ -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,30 +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 ten 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
- **machine** - CNC, CMM, and other shop-floor machines
- **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

@@ -18,6 +18,8 @@ app pointing at floor plans and logos that no longer exist.
|------|----------|-----|
| Database | MySQL `shopdb_flask` | All application data. |
| `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. |
| `instance/modelimages/` | repo `instance/` dir | Uploaded vendor-model photos. |
| `instance/employeephotos/` | repo `instance/` dir | Uploaded self-hosted employee photos (external mode serves photos from the HR database instead). |
| `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. |
| `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. |
| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. |

View File

@@ -30,6 +30,80 @@ a real caller (the GE-Enforce fleet agent) to it.
HTTP 500 `Collector API key not configured` and rejects every request. An
unconfigured server never silently accepts unauthenticated data.
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
- In addition to the env keys, a managed API token scoped to `collector.ingest`
is accepted as a collector credential on every collector endpoint. See
"Managed collector tokens" below; env keys remain the fallback.
### Managed collector tokens (recommended)
Alongside the env keys, every collector endpoint (`/api/collector/<plugin>`,
`/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`) also accepts a **managed API
token** (PAT) scoped to the `collector.ingest` permission. The env keys stay
supported as a bootstrap/legacy fallback - nothing breaks - but a managed token
is the preferred credential because it can be minted, rotated, and revoked from
the UI (Settings > API Tokens) and its use shows up in `lastusedat` and the
audit log.
What makes a token a collector service token: it is scoped to ONLY
`collector.ingest`. That scope authorizes the collector ingest API and NOTHING
else. The existing scoped-token machinery contains it automatically - a scoped
token passes `require_permission` only for its listed permissions and is denied
on every role-gated (`require_role`) endpoint and on import mode, and
`collector.ingest` gates no normal route. So a collector token that leaks cannot
be used to read or write anything through the regular API; it can only submit
collector payloads.
Both wire transports are accepted (send whichever is convenient; GE-Enforce
sends `X-API-Key` today, so that stays ergonomic):
```
POST /api/collector/computers
X-API-Key: shopdb_pat_<40 hex>
```
or
```
POST /api/collector/computers
Authorization: Bearer shopdb_pat_<40 hex>
```
An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector
token and is rejected (401) - only `collector.ingest` in the scope list counts.
A revoked or expired token is rejected (401) on both transports.
#### How to mint one (admin flow)
The simplest contained flow: an **admin** mints the token, scoped to
`collector.ingest`. Because the token is scoped, the admin-role bypass is
suspended for it, so the token is contained to the collector API even though its
owner is an admin - it cannot act with admin authority anywhere.
1. Settings > API Tokens > New Token.
2. Click the **Collector service token** preset (pre-selects only
`collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an
expiry, Create.
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
same way as the env key: the `collectorApiKey` field in per-site
`site-config.json` (see "Delivering the API key to clients" below). The
client sends it in `X-API-Key` exactly as it sends an env key today - no
client code change.
Service identity (documented, not built): if you prefer a non-admin owner,
create a dedicated low-privilege user (e.g. `svc-collector`) whose role holds
only `collector.ingest`, plus `apitokens.create` if that user is to mint its own
token. The scope ceiling then caps any token it mints at `collector.ingest`.
The admin-minted route above is simpler and equally contained, so it is the
recommended default.
#### Rotation
Managed tokens rotate without a fleet re-image:
1. Mint a new collector token (steps above).
2. Deploy it via `site-config.json` (`collectorApiKey`) - update the one per-site
value.
3. Confirm the new token is in use: watch its `lastusedat` climb in Settings >
API Tokens (and the old token's `lastusedat` go stale).
4. Revoke the old token once traffic has moved. Revocation is immediate.
### Generic endpoint contract: `POST /api/collector/<plugin>`
@@ -135,14 +209,42 @@ 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
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
`pctypemap_<pxetype>` settings (Settings > Collector PC Types).
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
failure.
@@ -256,6 +358,13 @@ collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
field name below was checked against `get_collector_schema` in
`plugins/computers/plugin.py`.
The `X-API-Key` value can be EITHER a `COLLECTOR_API_KEY[_COMPUTERS]` env key OR
a managed token scoped to `collector.ingest` (a `shopdb_pat_...` secret; see
"Managed collector tokens"). The script is identical for both - it just carries
whatever `collectorApiKey` the site-config supplies - so switching a site from an
env key to a managed token (and rotating it) is a config change, not a script
change.
```powershell
# Send-ShopdbCollectorReport.ps1
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
@@ -406,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

@@ -59,9 +59,17 @@ limit is approximate across multiple gunicorn workers).
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. |
| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset and no managed token is presented. Sent as the `X-API-Key` header. |
| `COLLECTOR_API_KEY_<PLUGIN>` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. |
The collector endpoints ALSO accept a managed API token (PAT) scoped to the
`collector.ingest` permission, sent in `X-API-Key` or as an
`Authorization: Bearer` token. Env keys stay supported as a bootstrap/legacy
fallback; a managed token is preferred because it is minted, rotated, and
revoked from Settings > API Tokens with `lastusedat` visibility. A
collector-scoped token is contained to the collector API and nothing else. See
`docs/COLLECTOR-INTEGRATION.md` (Managed collector tokens).
### Zabbix (printer supply monitoring)
| Variable | Required | Default | Notes |
@@ -135,6 +143,8 @@ read back through the API.
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
| `contact_email_domain` | `geaerospace.com` | Email domain appended to a support contact's SSO to build email (`sso@domain`) and Teams-chat links. Blank hides the contact action buttons. |
| `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. |
### branding
@@ -160,12 +170,27 @@ them under `instance/branding/`.
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_measuring_tool` | (empty) | Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`, `{locationcode}`, `{locationname}`. |
| `label_default_style` | `card` | Default asset-label layout used when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). |
| `label_default_codetype` | `qr` | Default asset-label code type used when a label first opens: `qr` (QR code) or `barcode` (CODE128). |
| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. |
| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. |
| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. |
| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. |
| `label_default_encodes_measuring_tool` | `location` | What a measuring-tool label encodes by default. Values across these five: `assetpage`, `assetnumber`, `serialnumber`, `location` (measuring tools only), or `custom`. Overridable on the label page. |
The shared asset-label generator lives at `/print/asset-label/<assettype>/<id>` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_<type>` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page.
The batch generator at `/print/asset-label-batch/<assettype>` (reached from the "Print Labels" button on each asset list page) lays a multi-selection of one type onto ULINE label sheets: a 6-up 3 in x 3 in format or a dense 72-up mini-label format, with a start-cell offset for reusing partial sheets. It reuses the same code-type and `label_default_encodes_<type>` defaults as the single label.
### map
| Key | Default | Notes |
|-----|---------|-------|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. |
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. |
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
| `map_width` | `3300` | Blueprint native width in pixels. |
| `map_height` | `2550` | Blueprint native height in pixels. |
@@ -200,7 +225,48 @@ them under `instance/branding/`.
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
| `smtp_from_address` | (empty) | From address for outgoing email. |
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). |
#### Email flows and delivery model
The mail service (`shopdb/utils/mailer.py`, stdlib `smtplib`/`ssl`/`email`
only) reads the keys above settings-first via the cached settings map, with an
environment-variable fallback (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
`SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`,
`SMTP_ALERT_RECIPIENTS`, `SMTP_ENABLED`) applied only when any `SMTP_*` env var
is present. When `smtp_enabled` is false or `smtp_host` is blank, every send is
a graceful no-op that logs a warning and returns without error, so an
unconfigured site never crashes. The SMTP password is never logged.
Three flows use it:
- Welcome email. When an admin creates a user (POST `/api/users`), the account
is flagged `mustchangepassword` and a best-effort welcome email is sent with
the facility name (`facility_name`), the username, the temporary password,
and the sign-in link (`site_base_url` + `/login`). Mail is best-effort: the
user is created even if the send fails (the response carries a `warning`). On
first login the API returns `mustchangepassword: true`; the frontend forces
the user through `/change-password` (POST `/api/auth/change-password`) before
the app. Changing the password clears the flag and resets lockout counters.
Set `sendwelcome: false` or `mustchangepassword: false` in the create body to
opt out.
- Test email. POST `/api/settings/test-email` (settings.edit) sends a probe to
the supplied `to` (or `alert_recipients`). The Email / SMTP settings page
"Send Test Email" button calls it and shows the result; a real SMTP error is
surfaced with the password scrubbed out.
- Alerts and report delivery (on-demand). POST `/api/reports/email`
(reports.export) takes `{subject, columns, rows, intro?, to?}` and mails the
rows as an HTML table. Recipients default to `alert_recipients` when `to` is
omitted, so the same endpoint serves both report delivery and alerts. Report
pages (Warranty, Toner) carry an "Email report" button that posts the rows
they already loaded.
There is NO scheduler in this app: sending is on-demand. To automate a
recurring send (e.g. a nightly warranty digest), point an external cron job
at `/api/reports/email` using an API token (PAT) scoped to `reports.export`.
See `docs/IMPORT-API.md` for the token model.
### audit
@@ -220,6 +286,25 @@ them under `instance/branding/`.
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. |
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. |
**Personal API tokens.** Besides login JWTs and SAML, a user may create
personal API tokens (PATs) for scripts and integrations, from Settings > API
Tokens (or `POST /api/apitokens`). A PAT is sent like a JWT
(`Authorization: Bearer shopdb_pat_...`), authenticates as its owning user
across the whole API, and does not carry the hourly `JWT_ACCESS_TOKEN_EXPIRES`
limit (it never expires unless an explicit expiry is set). Only the sha256 hash
is stored; the secret is shown once at creation. This is the recommended
credential for long-running imports (see `docs/IMPORT-API.md`). There is no env
var to configure; PATs are managed entirely through the API/UI.
Creating or managing a PAT requires the `apitokens.create` permission (admins
hold it by default; grant it to other roles from Settings > Users & Roles). By
default a PAT is unscoped and acts with the full authority of its owner. A PAT
may optionally carry a scopes list (a subset of the owner's permissions, capped
at what the owner actually holds): a scoped token grants ONLY those permissions,
intersected with the owner's live permissions at use time, and suspends the
admin bypass, so it is denied on role-gated (admin-only) endpoints and on import
mode. Use an unscoped token for admin-only work and imports.
### identifiers (dynamic)
One boolean key per asset identifier per asset type, keyed
@@ -234,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

@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
## Current version
The plugin contract is at **0.6.0**, declared in `shopdb/__init__.py` as
The plugin contract is at **0.10.0**, declared in `shopdb/__init__.py` as
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
bump is allowed to break the contract, and this project has used that latitude.
@@ -25,8 +25,11 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) |
| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction |
| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) |
| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) |
| 0.9.0 | Exposed the dualpath pair-resolution helpers on `shopdb.api` for the machines plugin | additive surface (minor) |
| 0.10.0 | Added the `get_permissions` hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins | additive optional hook (minor) |
The source comment block documents 0.3.0, 0.4.0, and 0.6.0. Earlier points
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, and 0.10.0. Earlier points
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline.
@@ -49,6 +52,8 @@ land with a new or amended ADR.
| `get_navigation_items` | Sidebar menu entries |
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) |
| `get_permissions` | Plugin RBAC permissions, merged into the catalog for roles and token scopes (added 0.10.0) |
| Frontend-contribution hooks | `get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`, consumed by `/api/pluginui/*` (added 0.7.0, [ADR-010](adr/ADR-010-frontend-plugin-hooks.md)) |
| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) |
| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |
| `get_provisioning_note` | Setup-wizard transparency note for extra tables |
@@ -65,7 +70,7 @@ Known-unstable areas. Building on these means expecting rework.
| Area | Status | Reference |
|------|--------|-----------|
| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) proposes the path: data-only declarative hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`) rendered by generic core components, with build-time glob discovery deferred for real components. | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (PROPOSED) |
| Frontend renderers (residual) | The four data-only hooks and their `/api/pluginui/*` consumers are settled (0.7.0). The generic core renderers are landing incrementally: the settings-cards rail/landing renderer ships with 0.7.0; the asset-panel, map-overlay, and search-presentation renderers are wired opt-in per the ADR adoption plan. Real component-backed panels (bespoke charts, custom overlays) remain deferred to Option C (build-time glob discovery). | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (ACCEPTED) |
| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) |
| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) |

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.

527
docs/IMPORT-API.md Normal file
View File

@@ -0,0 +1,527 @@
# Import API: migrating the classic ASP shopdb through HTTP alone
This is the operator manual for importing the legacy Classic-ASP shopdb database
(`prodscratch` on the dev MySQL container) into shopdb-flask using ONLY the HTTP
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.
A plain Python script can run the whole migration from this document.
Contents:
1. [Prerequisites](#1-prerequisites)
2. [Order of operations](#2-order-of-operations)
3. [Full table-by-table mapping](#3-full-table-by-table-mapping)
4. [Tables with no target yet](#4-tables-with-no-target-yet)
5. [Idempotency recipe and a worked importer](#5-idempotency-recipe-and-a-worked-importer)
6. [Verification: row-count parity](#6-verification-row-count-parity)
---
## 1. Prerequisites
### Dev URLs
- Flask API: `http://localhost:5001`
- All import calls target `/api/...` on that host.
### Admin token
Every write needs authentication, and import mode additionally needs an admin.
A large import can outlast a login JWT: `access_token` expires after one hour,
so a long run dies mid-import with 401s. Use a **personal API token (PAT)**
instead. A PAT never expires (unless you set an expiry), acts as the user that
created it, and is sent exactly like a JWT. Create one as an admin (via the
Settings > API Tokens page, or the API):
```bash
# Bootstrap: a short login JWT is fine just to mint the long-lived PAT.
JWT=$(curl -s http://localhost:5001/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"<admin>","password":"<password>"}' | jq -r '.data.access_token')
# The full secret (shopdb_pat_...) is returned ONCE. Save it now.
curl -s http://localhost:5001/api/apitokens \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"legacy import runner"}' | jq -r '.data.secret'
```
Send the PAT on every request as `Authorization: Bearer shopdb_pat_...`. It
authenticates the whole import surface (every create/update/delete plus import
mode) as its owning admin, exactly as a login JWT would, but without the hourly
expiry. Revoke it from the same Settings page (or `DELETE /api/apitokens/<id>`)
when the import is done.
Use an **unscoped** token for imports. A token may optionally carry a scopes
list that limits it to specific permissions; a scoped token suspends the admin
bypass and is denied on role-gated endpoints AND on import mode, so it cannot
run an import. Leave the "Restrict permissions" option off (the default) so the
token acts with the full authority of its admin owner. Minting a token itself
requires the `apitokens.create` permission (admins have it by default).
A short-lived login JWT still works for quick one-off calls if you prefer.
### Import mode: the `X-Import-Mode` header
By default the server stamps `createddate`/`modifieddate` to "now" on every
create and update, which would erase a migrated row's real history. To preserve
it, send the request header:
```
X-Import-Mode: true
```
When (and only when) the caller is an admin AND that header is present:
- create/update endpoints on timestamped entities accept optional
`createddate` and `modifieddate` fields in the JSON body and store them
verbatim (naive UTC). Both `2020-01-05T12:00:00` (ISO) and the legacy
`2020-01-05 12:00:00` (MySQL) forms are parsed. A bare `2020-01-05` works too.
- the selfhosted USB checkout/checkin endpoints accept optional `checkouttime`
and `checkintime` overrides so historical events keep their real timestamps.
Without the header, or for a non-admin caller, those fields are silently ignored
and the server behaves exactly as it does normally. This is enforced centrally
by `shopdb/utils/import_mode.py` (`import_mode_active`, `apply_import_timestamps`,
`parse_import_datetime`), exposed on the plugin contract surface `shopdb.api`.
Timestamped entities that honor `createddate`/`modifieddate`: assets (all five
type plugins), vendors, models, modeltypes, businessunits, locations, operating
systems, applications, knowledge base, USB devices, asset relationships.
Entities that carry history in domain fields instead (createddate passthrough is
a no-op there, by design): notifications (`starttime`/`endtime`), warranties
(`startdate`/`enddate`/`lastcheckeddate`). Set those fields directly in the
payload; they are already accepted.
### Reference data seed
Before importing, seed the reference tables that have no CRUD endpoint of their
own (communication types such as IP/Serial/USB, default statuses, canonical
relationship types, permissions, settings):
```bash
flask seed permissions
flask seed settings
flask seed reference-data
```
`communicationtypes` (the target of legacy `comstypes`) is populated here, so
the primary-IP mapping below can resolve `comtype='IP'`.
---
## 2. Order of operations
Import in dependency order so foreign keys always resolve. Each step is a
lookup-then-upsert loop (see section 5); rerunning any step is safe.
1. **Reference / lookup data first**
1. Vendors (`vendors`)
2. Model types (`modeltypes`) - from legacy `machinetypes`
3. Models (`models`) - needs vendors + model types
4. Business units (`businessunits`)
5. Location types, then Locations (`locations/types`, `locations`) - the
legacy LocationOnly machines land here, not as assets
6. Operating systems (`operatingsystems`)
7. Asset statuses (`assets/statuses`) - from legacy `machinestatus`
8. Relationship types (`assets/relationshiptypes`) - from legacy
`relationshiptypes`
9. Notification types (`notifications/types`)
10. Per-plugin subtypes: computer types (from `pctype`), machine types,
printer types, network device types, measuring-tool types
11. Support teams + support-team contacts (`supportteams`,
`supportteams/{id}/contacts` - see section 3.3); import these BEFORE
applications because `applications.supportteamid` points at them
12. Applications (`applications`) and their versions; import legacy `topics`
as applications too (KB links to applications, section 3)
2. **Assets, per type** (each creates the core Asset row plus its extension):
computers, machines, printers, network devices, measuring tools, and USB
devices. Fan out the legacy `machines` table by category (section 3).
3. **Communications**: the primary IP is set through the asset payload's
`ipaddress` field during step 2. There is no bulk-communications endpoint;
see the mapping note.
4. **Relationships** (`assets/relationships`): needs both endpoint assets and
the relationship types to already exist.
5. **Installed applications**: attach apps to computers
(`computers/{id}/apps`), needs computers + applications.
6. **Knowledge base, notifications, warranties, USB checkouts** (including
backdated history).
7. **Custom fields**: for any legacy column with no home in the target schema,
define a custom field for the asset type and store the value per asset.
---
## 3. Full table-by-table mapping
Legend: `->` maps to. Endpoints are relative to `http://localhost:5001`. "NK"
is the natural key used for the idempotent lookup (section 5).
### 3.1 The `machines` hub fans out into the asset plugins
`machines` (885 rows) is the central legacy asset table. Two columns drive the
fan-out: `machinetypeid` (what the asset physically is) and `pctypeid` (a
computer's sub-type). Route each row by `machinetypeid`:
| legacy `machinetypeid` | `machinetypes.machinetype` | target plugin | subtype source |
|---|---|---|---|
| 1 | LocationOnly (also `islocationonly=1`) | core **Locations** (NOT an asset) | `locationtype` |
| 33 | PC | **computers** | `computertype` <- `pctype.typename` via `machines.pctypeid` |
| 20 | Server | **computers** | computer type "Server" |
| 15 | Printer | **printers** | `printertype` |
| 16 Access Point / 17 IDF / 18 Camera / 19 Switch / 46 Firewall | | **network** | `networkdevicetype` |
| 44 | USB Device | **usb** | (usb device) |
| 23 Measuring Machine / 3 CMM / 48 Spline Checker / 8 Eddy Current / 47 Inspection | | **measuringtools** (gage-lab judgment call; ADR-005) | `measuringtooltype` |
| 2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45 (lathes, mills, welders, grinders, ...) | | **machines** | `machinetype` |
This mapping is a recommended default, not a hard rule; a site may re-route a
`machinetypeid` (for example send CMM to `machines` rather than
`measuringtools`). Decide the routing table once, up front.
Common `machines` columns -> core Asset fields (same for every target plugin):
| legacy column | target field | notes |
|---|---|---|
| `machinenumber` | `assetnumber` | the business identifier / NK |
| `alias` or `hostname` | `name` | layperson label |
| `serialnumber` | `serialnumber` | |
| `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses |
| `businessunitid` | `businessunitid` | remap via imported business units |
| `mapleft` | `mapx` | |
| `maptop` | `mapy` | |
| `machinenotes` | `notes` | |
| `dateadded` | `createddate` | import mode only |
| `lastupdated` | `modifieddate` | import mode only |
Per-plugin extension fields:
- **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`,
`osid` <- remapped `machines.osid`, `computertypeid` <- computer type from
`pctype`, `loggedinuser`, `lastboottime`, `vendorid`, `modelnumberid`,
`ipaddress` <- `machines.ipaddress1` (primary IP). NK: `assetnumber`.
- **machines** (`POST /api/machines`): `machinetypeid`, `vendorid`,
`modelnumberid`, `controllervendorid`/`controllermodelid` (from
`controllertypes` remapped to vendors/models), `requiresmanualconfig` <-
`requires_manual_machine_config`, `islocationonly`. NK: `assetnumber`.
- **printers** (`POST /api/printers`): see 3.2 (authoritative source is the
legacy `printers` table).
- **network** (`POST /api/network`): `networkdevicetypeid`, `hostname`,
`vendorid`, `ipaddress` <- `machines.ipaddress1`. NK: `assetnumber`.
- **measuringtools** (`POST /api/measuringtools`): `measuringtooltypeid`,
calibration fields where known. NK: `assetnumber`.
### 3.2 Reference and lookup tables
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
| `vendors` | `POST /api/vendors` | `vendor` -> `vendor` | `vendor` |
| `machinetypes` | `POST /api/modeltypes` | `machinetype` -> `modeltype`; set `category` (Equipment/Computer/...) | `modeltype` |
| `models` | `POST /api/models` | `modelnumber`, `vendorid` (remapped), `machinetypeid` -> `modeltypeid`, `notes`, `image` -> `imageurl`, `documentationpath` -> `documentationurl` | `modelnumber` + `vendor` |
`imageurl` imports as a plain URL string (an external URL or a legacy
`/images/models/*` path). Binary photos are not part of the import payload;
upload them after import via `POST /api/models/<modelid>/image` (multipart
`file`), which stores the file under `instance/modelimages/` and rewrites
`imageurl` to the served URL.
| `businessunits` | `POST /api/businessunits` | `businessunit` -> `businessunit` | `businessunit` |
| `operatingsystems` | `POST /api/operatingsystems` | `operatingsystem` -> `osname` | `osname` (+`osversion`) |
| `machinestatus` | `POST /api/assets/statuses` | `machinestatus` -> `status` | `status` |
| `relationshiptypes` | `POST /api/assets/relationshiptypes` | `relationshiptype` -> `relationshiptype`, `description`, `isdirectional` (bool, default true; false = symmetric connection) | `relationshiptype` |
| `notificationtypes` | `POST /api/notifications/types` | `typename`, `typedescription`, `typecolor` | `typename` |
| `pctype` | `POST /api/computers/types` | `typename` -> `computertype`, `description` | `computertype` |
| `subnettypes` | (see subnets) | used as `subnettype` string on subnets | - |
| `subnets` | `POST /api/network/subnets` | `cidr`, `description` -> `name`/`description`, `vlan` -> create VLAN first (`POST /api/network/vlans`) then `vlanid`, `subnettypeid` -> `subnettype` name | `cidr` |
| `dashboarddefaults` | `POST /api/dashboarddefaults` | `ipaddress` -> `ipaddress`, `businessunitid` (remapped), `description` | `ipaddress` |
| `controllertypes` | remap into `vendors` + `models` | e.g. "Fanuc" -> a Vendor; the controller model -> a Model; then set `controllervendorid`/`controllermodelid` on the machine | - |
| `comstypes` | `communicationtypes` (seeded, no API) | ensure `flask seed reference-data` created IP/Serial/USB/... before importing comms | - |
Note on communication types: the classic `comstypes.typename` values
(IP, Serial, Network_Interface, USB, Parallel, VNC, FTP, DNC) correspond to the
seeded `communicationtypes.comtype`. They are created by the reference-data seed,
not imported per-row.
### 3.3 Support teams, applications, topics, installed apps
Support teams and their contacts import BEFORE applications, because
`applications.supportteamid` references a team. The legacy `appowners` table
is folded into contacts: each legacy `supportteams` row carries one
`appownerid`, so import that owner as ONE contact on the team (legacy
`appowner` -> `name`, `sso` -> `sso`).
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
| `supportteams` | `POST /api/supportteams` | `teamname`, `teamurl` (ServiceNow group deep link) | `teamname` |
| `appowners` (via each team's `appownerid`) | `POST /api/supportteams/{supportteamid}/contacts` | `appowner` -> `name`, `sso` -> `sso`, `sortorder` (default 0) | (supportteamid, name) |
| `applications` | `POST /api/applications` | `appname`, `appdescription`, `supportteamid` (remap by team `teamname`, GET `/api/supportteams?teamname=...`), `isinstallable`, `applicationnotes`, `installpath`, `applicationlink`, `documentationpath`, `ishidden`, `isprinter`, `islicenced`, `image` | `appname` |
| `appversions` | `POST /api/applications/{appid}/versions` | `version`, `releasedate`, `notes` | `version` (per app) |
| `topics` | `POST /api/applications` | `topics` is a near-clone of `applications` and `knowledgebase.appid` points at it; import each distinct topic as an Application (`appname` = topic name), so KB links resolve against `applications` | `appname` |
| `installedapps` | `POST /api/computers/{computerid}/apps` | body `{appid, appversionid}`; resolve `machineid` -> the imported computer, `appid`/`appversionid` -> imported app + version | (computerid, appid) |
`installedapps` only makes sense for computer-class assets; skip rows whose
`machineid` did not map to a computer.
### 3.4 Communications
| legacy table | target | field mapping | notes |
|---|---|---|---|
| `communications` (comstypeid=1, isprimary) | asset `ipaddress` on create/update | `address` -> `ipaddress` | Sets the primary IP communication for the asset. |
| `communications` (other comstypeids / secondary rows) | none yet | | No bulk-communication create endpoint exists. Import the primary IP only; capture extra interfaces as custom fields, or defer. |
### 3.5 Relationships
| legacy table | target endpoint | field mapping |
|---|---|---|
| `machinerelationships` | `POST /api/assets/relationships` | `machineid` -> `sourceassetid` (the imported asset id), `related_machineid` -> `targetassetid`, `relationshiptypeid` -> `relationshiptypeid` remapped by name, `relationship_notes` -> `notes` |
Suggested legacy-name -> target relationship-type mapping (create these types
first, or map onto the canonical `partof`/`controls`/`connectedto`):
| legacy `relationshiptype` | recommended target |
|---|---|
| Controls | Controls |
| Controlled By | Controls (reverse the source/target) |
| Dualpath | Dualpath (or `connectedto` with label "dualpath") |
| Cluster Member | partof |
| Backup For | Backup For |
| Master-Slave | Controls |
| Contains | partof |
| Stored At | Stored At |
| Connected To | connectedto |
Resolve each machine id to the asset id you got back when you created that
asset (keep a `legacy_machineid -> assetid` map as you import).
### 3.6 Knowledge base, notifications, warranties, USB
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
| `knowledgebase` | `POST /api/knowledgebase` | `shortdescription`, `linkurl`, `keywords`, `appid` (remapped to the imported application/topic); `lastupdated` -> `modifieddate` in import mode | `linkurl` (fallback `shortdescription`) |
| `notifications` | `POST /api/notifications` | `notification`, `notificationtypeid` (remapped), `businessunitid` (remapped), `starttime`, `endtime`, `ticketnumber`, `link`, `isshopfloor`, `employeesso`; note legacy `endtime` sentinel `2099-00-03 09:52:32` is invalid - drop or clamp it | `ticketnumber` when set, else append-only |
| `warranties` | `POST /api/warranty` | `warrantyname`/`servicelevel` -> `servicelevel`, `enddate` -> `enddate`, link the covered asset via `assetids: [assetid]`; set `vendor` (required) from the source or "Dell"; `servicetag` if known | `servicetag` + `vendor` |
| `usbcheckouts` | see below | historical checkout/checkin events | - |
USB devices and their history:
1. Create each USB device (legacy `machines` rows with `machinetypeid=44`, or a
dedicated device list) via `POST /api/usb` in selfhosted mode with body
`{device_id: <serial>, device_desc, locker_location}`. NK: `device_id`.
2. Replay each `usbcheckouts` row as a checkout then (if returned) a checkin,
with import-mode backdating:
- `POST /api/usb/{device_id}/checkout` body
`{badge: <sso>, reason: <checkout_reason>, checkouttime: <checkout_time>}`
- if `checkin_time` is set:
`POST /api/usb/{device_id}/checkin` body
`{badge: <sso>, sanitized: <was_wiped>, notes: <checkin_notes>, checkintime: <checkin_time>}`
The `checkouttime`/`checkintime` overrides are honored only in import mode.
Employee directory (people): only self-hosted mode (`employee_directory_mode =
selfhosted`) owns people in this app; import them via the directory bulk-upsert
`POST /api/employees/directory/import` (CSV headers `SSO,First_Name,Last_Name,
Team,Role,Picture`) or per-person `POST /api/employees/directory`. Photos:
- External mode: the photo is a URL/relative path supplied by the HR database
(`Picture` column); it is a read-only pass-through and cannot be uploaded here.
- Self-hosted mode: the `Picture` CSV field is a legacy text label and does not
drive the displayed photo. Upload the real photo after import via
`POST /api/employees/<sso>/photo` (multipart `file`, png/jpg/jpeg/gif/webp),
which stores it under `instance/employeephotos/` and serves it publicly.
### 3.7 Anything unmappable -> custom fields
For a legacy column with no target field (for example `machines.logicmonitorurl`,
`machines.fqdn`, `printers.printerpin`), define a custom field on the asset type
and store the value per asset:
- `POST /api/customfields` body `{assettypeid, label, datatype}` (once per field)
- `PUT /api/customfields/asset/{assetid}` body `{values: {<fieldid>: <value>}}`
Custom-field values are not timestamped, so they carry no history.
---
## 4. Tables with no target yet
These legacy tables have no import target in the current schema. The
dispositions below are DECIDED, not open questions.
### DECIDED: not migrated
- **`dncconfig` and `commconfig`** - intentionally NOT migrated. DNC
communication settings drift constantly, so a one-shot import of stale data
has little value. The plan is a future DNC feature fed live by the GE-Enforce
collector/reporting tool rather than a historical import. When that DNC
support is eventually built, the expected ingestion pattern is one attribute
at a time across the whole facility (for example, sweep every machine's baud
rate in one pass, then ports, and so on) via the GE-Enforce collector, so the
future design should favor per-field fleet-wide updates over per-machine
full-record imports.
### DECIDED: skip (structure only or low value)
- **`compliance`, `compliancescans`** - 0 rows in `prodscratch`. No data to
migrate; a future compliance plugin would own them. Skip.
- **`ednc_installations`, `ednc_logs`** - 0 rows, and they belong to the eDNC
tooling rather than the asset catalog. Skip.
- **`distributiongroups`** (2 rows) - email distribution lists referenced by
`businessunits.distributiongroupid`. No target; skip, or attach as a business
unit custom field if a site needs it.
- **`functionalaccounts`** (7 rows) - service-account concept referenced by
`pctype`/`machinetypes`; no equivalent in the new schema. Skip, or capture as
a computer-type custom field.
- **`skilllevels`** (2 rows) - orphaned lookup (no FK from `machines`). Skip.
---
## 5. Idempotency recipe and a worked importer
The endpoints are NOT upserts. The idempotent unit is a two-step recipe that
composes with import mode:
1. **Look up** the row by its natural key using the exact-match list filter.
2. If found, **PUT** to update it; if not, **POST** to create it.
Each import-relevant list endpoint has an exact-match filter for its natural key
(added for exactly this purpose):
| entity | lookup |
|---|---|
| assets (all 5 plugins) | `GET /api/{plugin}?assetnumber=<n>` |
| vendors | `GET /api/vendors?vendor=<name>` |
| models | `GET /api/models?modelnumber=<m>&vendor=<vendorid>` |
| model types | `GET /api/modeltypes?modeltype=<name>` |
| business units | `GET /api/businessunits?businessunit=<name>` |
| locations | `GET /api/locations?locationname=<name>` |
| operating systems | `GET /api/operatingsystems?osname=<name>` |
| applications | `GET /api/applications?appname=<name>` |
| knowledge base | `GET /api/knowledgebase?linkurl=<url>` |
| warranties | `GET /api/warranty?servicetag=<tag>&vendor=<name>` |
| notifications | `GET /api/notifications?ticketnumber=<t>` |
| USB devices | `GET /api/usb/{device_id}` (exact by id) |
### Worked example
A small, dependency-free importer (`requests`) that authenticates with a PAT
(so a multi-hour run cannot expire mid-import), does the lookup-then-upsert loop
in import mode, supports a `--dry-run` flag, and reports errors without aborting
the whole run:
```python
import argparse
import os
import requests
BASE = "http://localhost:5001"
class ImportClient:
def __init__(self, token=None, dryrun=False):
self.session = requests.Session()
self.dryrun = dryrun
# A personal API token (shopdb_pat_...) does not expire like a login
# JWT, so it survives a long import. See section 1 to mint one.
token = token or os.environ["SHOPDB_TOKEN"]
# X-Import-Mode makes createddate/modifieddate passthrough take effect.
self.session.headers.update({
"Authorization": f"Bearer {token}",
"X-Import-Mode": "true",
})
def lookup(self, path, params):
"""Return the first matching row, or None."""
resp = self.session.get(f"{BASE}{path}", params=params)
resp.raise_for_status()
rows = resp.json().get("data") or []
return rows[0] if rows else None
def upsert(self, path, idfield, lookupparams, payload):
"""Lookup by natural key; PUT if found, else POST. Returns the row."""
existing = self.lookup(path, lookupparams)
if self.dryrun:
verb = "PUT" if existing else "POST"
print(f"[dry-run] {verb} {path} {lookupparams}")
return existing or payload
if existing:
rowid = existing[idfield]
resp = self.session.put(f"{BASE}{path}/{rowid}", json=payload)
else:
resp = self.session.post(f"{BASE}{path}", json=payload)
if resp.status_code >= 400:
# report and keep going; a single bad row must not abort the run
print(f"ERROR {resp.status_code} {path}: {resp.text[:200]}")
return None
return resp.json()["data"]
def import_vendors(client, legacyrows):
for row in legacyrows:
client.upsert(
"/api/vendors",
idfield="vendorid",
lookupparams={"vendor": row["vendor"]},
payload={
"vendor": row["vendor"],
# legacy history preserved because X-Import-Mode is set
"createddate": row.get("dateadded"),
"modifieddate": row.get("lastupdated"),
},
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
# PAT from the SHOPDB_TOKEN env var, or pass --token explicitly.
parser.add_argument("--token", default=None)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
client = ImportClient(args.token, dryrun=args.dry_run)
# read legacy rows from prodscratch (read-only) and call the import_* fns
# in the order of section 2, keeping a legacy-id -> new-id map as you go.
```
Keep a `legacy_id -> new_id` dictionary for every entity as you import it; you
need it to remap foreign keys (a machine's `businessunitid`, a checkout's
`machineid`, a relationship's `machineid`/`related_machineid`, and so on).
---
## 6. Verification: row-count parity
After each phase, compare counts. Legacy side (read-only), for example:
```bash
docker exec dev-mysql mysql -uroot -prootpassword prodscratch \
-e "SELECT COUNT(*) FROM vendors;"
```
New side, via the API pagination metadata (`meta.pagination.total`):
```bash
curl -s "http://localhost:5001/api/vendors?per_page=1" \
-H "Authorization: Bearer <token>" | jq '.meta.pagination.total'
```
Suggested parity checks:
| entity | legacy count | new count |
|---|---|---|
| vendors | `SELECT COUNT(*) FROM vendors` | `GET /api/vendors` total |
| models | `SELECT COUNT(*) FROM models` | `GET /api/models` total |
| business units | `SELECT COUNT(*) FROM businessunits` | `GET /api/businessunits` total |
| applications | `SELECT COUNT(*) FROM applications` | `GET /api/applications?showhidden=true` total |
| knowledge base | `SELECT COUNT(*) FROM knowledgebase WHERE isactive` | `GET /api/knowledgebase` total |
| computers | `SELECT COUNT(*) FROM machines WHERE machinetypeid IN (33,20)` | `GET /api/computers` total |
| machines | `SELECT COUNT(*) FROM machines WHERE machinetypeid IN (2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45)` | `GET /api/machines` total |
| printers | `SELECT COUNT(*) FROM printers WHERE isactive` | `GET /api/printers` total |
| network devices | `SELECT COUNT(*) FROM machines WHERE machinetypeid IN (16,17,18,19,46)` | `GET /api/network` total |
| USB devices | `SELECT COUNT(*) FROM machines WHERE machinetypeid=44` | `GET /api/usb` total |
| relationships | `SELECT COUNT(*) FROM machinerelationships WHERE isactive` | per-asset `GET /api/assets/{id}/relationships` |
| USB checkouts | `SELECT COUNT(*) FROM usbcheckouts` | `GET /api/usb/checkouts` |
Exact counts will differ where the fan-out routing table (section 3.1) sends a
`machinetypeid` to a different plugin than the example above; adjust the legacy
`WHERE` clause to match the routing you chose. Investigate any gap beyond that.

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

@@ -330,20 +330,27 @@ def create_tool():
...
```
The `measuringtools.*` permissions are seeded exactly the way warranty seeds its
own, by adding them to `Permission.PERMISSIONS` in `shopdb/core/models/user.py`:
The `measuringtools.*` permissions belong to the plugin, not to core. The plugin
declares them from the `get_permissions` hook (contract 0.10.0) so core never edits
its catalog to accommodate a plugin:
```python
# Measuring tools
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
class MeasuringToolsPlugin(BasePlugin):
def get_permissions(self):
return [
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
]
```
`flask seed permissions` is idempotent, so re-running it just adds the four new
rows. The `admin` role bypasses every permission check, so an admin can operate the
plugin before anyone grants the granular permissions.
Installing or enabling the plugin seeds these rows automatically, and
`flask seed permissions` (which now seeds core plus every enabled plugin) is
idempotent, so re-running it just adds any missing rows. The `admin` role bypasses
every permission check, so an admin can operate the plugin before anyone grants the
granular permissions. See `get_permissions` in `docs/PLUGIN-HOOKS.md` for the
disabled-plugin edge case.
**Responses use the framework helpers.** `success_response`, `error_response` (with
`ErrorCodes`), and `paginated_response` produce the standard envelope
@@ -525,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
@@ -666,7 +672,7 @@ When you build a plugin, confirm all of this before you call it done:
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green).
- [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and
pagination helpers; audit logs on writes.
- [ ] Permissions added to `Permission.PERMISSIONS`; `flask seed permissions` run.
- [ ] Permissions declared from the `get_permissions` hook; install/enable (or `flask seed permissions`) seeds them.
- [ ] `on_install` seeds the asset type and any reference data, idempotently.
- [ ] Hooks: navigation, reports, models implemented; config schema and collector
implemented or consciously skipped with a reason.

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.6.0'
__contract_version__ = '0.11.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -216,6 +216,139 @@ Consumed by `GET /api/reports`, which merges plugin cards after the static core
reports sorted into category groups by the frontend (disabled plugins are
skipped; a broken plugin is isolated in prod, re-raised in dev/test).
### `get_permissions() -> List`
Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A
plugin declares the permission names its own routes enforce via
`require_permission`, instead of core accumulating every plugin's permissions in
one catalog (plugin-is-the-product).
Each entry is a `(name, description, category)` tuple, matching the core
permission catalog shape (dicts with those keys are also accepted). Names follow
the naming convention (lowercase dotted, e.g. `machines.edit`).
```python
class MachinesPlugin(BasePlugin):
def get_permissions(self):
return [
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]
```
Consumed by the core helper `full_permission_catalog()` (core permissions plus
every ENABLED plugin's `get_permissions()`), which backs three consumers:
- `flask seed permissions` seeds the full catalog.
- The role-management grid (`GET /api/users/permissions`) lists it, grouped by
category.
- API-token scope validation (`ApiToken.unknown_scope_names`) accepts a plugin
permission as a scope only while that plugin is enabled.
Plugin install and enable also seed the plugin's own permissions idempotently,
so enabling a fresh plugin creates its `Permission` rows without a separate seed
pass.
Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its
permissions are no longer offered for new scope grants or new role assignments.
The `Permission` ROWS already in the database are NOT deleted, so roles that
already reference them keep working until an admin edits the role. A broken
plugin is isolated in prod and re-raised in dev/test.
### `get_settings_cards() -> List[Dict]`
Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010).
Each card is merged into the settings rail and landing overview without the
plugin hand-editing the core `settingsNav.js` catalog. `icon` is a string key
mapped to a Lucide component core-side, exactly like `get_navigation_items`.
```python
class MeasuringToolsPlugin(BasePlugin):
def get_settings_cards(self):
return [{
'group': 'Measuring Tools', # rail group title (created if new)
'to': '/settings/measuringtooltypes',
'icon': 'ruler', # string key, mapped core-side
'title': 'Measuring Tool Types',
'description': 'Manage measuring-tool subtypes + map colors',
'position': 22, # order within the group
}]
```
Consumed by `GET /api/pluginui/settings-cards`, which merges enabled plugins'
cards into the core catalog (disabled plugins are skipped; a broken plugin is
isolated in prod, re-raised in dev/test).
### `get_asset_panels() -> List[Dict]`
Returns asset-detail extension-panel definitions. Added in contract 0.7.0
(ADR-010). A generic core `AssetPanel` component renders each panel on the
matching detail pages, fetching the panel's `endpoint`. This replaces
hand-composing a plugin panel component into each detail view.
```python
class WarrantyPlugin(BasePlugin):
def get_asset_panels(self):
return [{
'id': 'warranty',
'title': 'Warranty',
'assettypes': ['*'], # detail pages it appears on; ['*'] = all
'endpoint': '/api/warranty/asset/{assetid}',
'render': 'table', # 'keyvalue' | 'table' | 'badge'
'position': 30,
}]
```
Consumed by `GET /api/pluginui/asset-panels?assetid=<id>`, which returns the
panels whose `assettypes` match that asset's type (disabled plugins skipped;
broken plugin isolated in prod, re-raised in dev/test). A panel that needs
bespoke UI (a chart) is out of scope for this data-only hook.
### `get_map_overlays() -> List[Dict]`
Returns shop-floor map overlay/decoration definitions. Added in contract 0.7.0
(ADR-010). The map stays data-driven off asset types + positions; an overlay
adds decoration data (a badge or ring) plus an optional legend entry, with no
plugin-side map code.
```python
class MeasuringToolsPlugin(BasePlugin):
def get_map_overlays(self):
return [{
'id': 'calibration-due',
'label': 'Calibration due', # legend label
'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
'style': 'badge', # 'badge' | 'ring'
'legend': True,
}]
```
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
plugin isolated in prod, re-raised in dev/test).
### `get_asset_presentation() -> List[Dict]`
Returns asset-type presentation/routing definitions. Added in contract 0.7.0
(ADR-010). Declares how a plugin-owned asset type renders in global-search rows
and cross-links (which icon, which detail route), so core never hardcodes a
plugin's route or icon.
```python
class MeasuringToolsPlugin(BasePlugin):
def get_asset_presentation(self):
return [{
'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns
'icon': 'ruler',
'label': 'Measuring Tool',
'route': '/measuringtools/{assetid}',
}]
```
Consumed by `GET /api/pluginui/asset-presentation` (disabled plugins skipped;
broken plugin isolated in prod, re-raised in dev/test).
### `get_provisioning_note() -> Optional[Dict]`
Transparency note the setup wizard shows the moment a site checks this plugin
@@ -336,7 +469,15 @@ What `shopdb.api` exposes:
- Responses: `success_response`, `error_response`, `paginated_response`,
`ErrorCodes`
- Pagination: `get_pagination_params`, `paginate_query`
- Helpers: `audit_log`, `resolve_asset_position`
- 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`
```python
@@ -387,6 +528,55 @@ position = resolve_asset_position(asset)
See [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm.
### Dualpath single-machine collapse
A Dualpath relationship pair is one physical dual-bay machine recorded as two
asset rows. When the site setting `dualpath_single_machine` is on (default), the
machines list, dashboard/report counts, and the floor map show the pair as a
single machine (the SECONDARY bay is hidden); the data model always keeps both
rows and detail pages stay per-bay.
```python
from shopdb.api import resolve_dualpath_pairs, dualpath_single_machine_enabled
collapse = resolve_dualpath_pairs()
# collapse.secondaryassetids: set of the non-primary bay asset ids to hide
# collapse.partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for every
# pair member (primary and secondary), for banners
if dualpath_single_machine_enabled():
# exclude the hidden bays and annotate the visible (primary) bay
...
```
PRIMARY is the pair member with the lower natural-sort assetnumber.
`resolve_dualpath_pairs` ignores the toggle (so a detail-page sibling banner can
show always); gate the collapse itself on `dualpath_single_machine_enabled()`.
### Import mode (legacy timestamp passthrough)
Bulk imports from the classic ASP shopdb need to preserve each row's original
`createddate` / `modifieddate` instead of stamping "now". `apply_import_timestamps`
does this, gated so it never affects normal traffic: it only acts when the
caller is an admin AND sent the `X-Import-Mode: true` request header.
```python
from shopdb.api import apply_import_timestamps
asset = Asset(assetnumber=data['assetnumber'], ...)
db.session.add(asset)
# In import mode, stamp legacy createddate/modifieddate from the payload.
# No-op for normal callers, or when the payload omits the fields.
apply_import_timestamps(asset, data)
db.session.commit()
```
`import_mode_active()` returns the same admin-plus-header predicate, for guarding
other backdated behavior (for example accepting a historical `checkouttime`).
`parse_import_datetime(value)` parses both ISO `2020-01-05T12:00:00` and legacy
`YYYY-MM-DD HH:MM:SS` into naive UTC. See [docs/IMPORT-API.md](IMPORT-API.md) for
the full migration operator manual.
## Removed hooks
The following hooks existed in early drafts and have been removed for v1:

View File

@@ -0,0 +1,237 @@
# Plugin lab: build the printedparts plugin yourself
A guided, milestone-based exercise: build the 3D-printed-parts storefront +
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`. Each
milestone lists what to build, which existing code to imitate, and a
checkpoint that proves you are done. Read the spec first, keep it open.
Prerequisites: a working dev environment (README quick start), the three
plugin docs skimmed once - `PLUGIN-QUICKSTART.md` (mechanics),
`PLUGIN-GUIDE.md` (the measuringtools walkthrough - your narrative reference),
`PLUGIN-HOOKS.md` (hook reference). Naming rules: `CONTRIBUTING.md` - the
pre-commit hook enforces them, read it before naming anything.
Ground rules
- Import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`). The contract
test fails your build otherwise.
- DB columns: lowercase concatenated (`quantityonhand`, not quantity_on_hand).
- Run `bash scripts/check-naming-and-style.sh` and the test suite at every
checkpoint.
- Commit once per milestone. Working on a branch is fine; so is a fork.
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` (api client), the router, and
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is
normal for all 12 bundled plugins - external-plugin UI packaging does not
exist yet - so do not be confused when a "plugin" touches core.
- This plugin deliberately DIVERGES from the scaffold in three places, each
a teaching point you will hit in order: (1) it is NOT an asset type, so the
scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL
baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk
take endpoint is the product's first UNauthenticated write - read the
decision record in the proposal before building it (M4).
- Instructor option: keep a solution branch with one commit per milestone
(tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to
see exactly what a milestone changes.
---
## Milestone 1 - skeleton, models, migration (backend exists)
Build
1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`.
2. The scaffold assumes an Asset-extension plugin; ours is standalone.
In `plugin.py` strip the AssetType seeding from `on_install` (imitate
`plugins/knowledgebase/plugin.py` instead of the template).
3. Replace the scaffold model with the two spec tables: `PrintedItem`,
`PrintedItemTransaction` (`models/printeditem.py`). Use `BaseModel` +
`AuditMixin` from `shopdb.api`. Itemcode: leave generation to the API
layer (M2), column just `unique=True, index=True`.
4. Register both tables in `PLUGIN_TABLE_OWNERS`
(`shopdb/plugins/alembic_template.py`).
5. Create `plugins/printedparts/migrations/` with the 3-line `env.py` +
`script.py.mako` (copy from measuringtools) and a REAL baseline
`versions/0001_printedparts_baseline.py` - hand-written
`op.create_table(...)` for both tables (see
`plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py`).
6. Manifest: api_prefix `/api/printedparts`, `dependencies: ["employees"]`,
`default_enabled: false`.
Checkpoint
```
flask plugin install printedparts && flask plugin enable printedparts
flask plugin upgrade-all # applies your 0001
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SELECT * FROM alembic_version_printedparts; -- your revision id
pytest tests/ -q # nothing broken, contract tests green
```
## Milestone 2 - CRUD API + permissions + itemcode
Build
1. `api/routes.py`: list/detail/create/update/soft-delete per the spec table.
Imitate a clean plugin blueprint (`plugins/measuringtools/api/routes.py`)
for pagination (`perpage`, `dir`), search, and the shared
`success_response`/`error_response` helpers from `shopdb.api`.
2. Itemcode on create: `<prefix>-<id zero-padded to 4>`; prefix from Setting
`printedparts_code_prefix` (read via `self.get_setting` or Setting model
through `shopdb.api`). Two-step: insert, flush to get the id, set code.
3. `get_permissions()` on the plugin class: view/create/edit/delete/restock
(tuples, category `printedparts` - copy shape from
`plugins/usb/plugin.py`). Gate mutations with `@jwt_required()` +
`@require_permission(...)`; reads are `@jwt_required(optional=True)`.
4. Restock + adjust endpoints: both write a LEDGER row and move
`quantityonhand` in the same commit. Adjust requires `reason`, rejects a
result below zero. Both record the operator: accept `badge` in the body
and resolve it (M4 extracts the resolver - for now digits=SSO is enough).
5. Seed the 3 settings in `on_install` (Setting.set pattern - see how other
plugins seed in `on_install`; category `printedparts`).
Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead
and wire just the api client + router entry + a bare `PrintedItemsList.vue`
(first two steps of M3), seed two rows by hand, and look at your parts in the
browser. Everything before that moment is invisible; seeing the table makes
the rest of the lab concrete. Then come back and finish the mutations here.
Checkpoint
```
pytest plugins/printedparts/tests/ -q # write tests as you go:
# - create mints 3DP-0001 style codes
# - restock/adjust move both ledger and cache atomically
# - adjust below zero -> 400/422
# - permission gates: anonymous create -> 401, wrong-perm user -> 403
curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK
```
## Milestone 3 - management frontend + images
Build
1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters
and a router file; rename/build them into `PrintedItemsList/Detail/Form`
per the spec. Master templates: `PrintersList.vue` (list),
`PrinterDetail.vue` (detail). Global CSS only; CSS variables for colors
(frontend/CLAUDE.md rules).
2. Register the API client in `frontend/src/api/index.js` (paste the
generated `frontend-api-snippet.js`, extend with restock/adjust/image
calls).
3. Low-stock highlighting on the list (`quantityonhand <= lowstockthreshold`
-> danger badge). Filters: search + low-stock-only checkbox.
4. Image upload: replicate the models-image trio - upload/serve/delete -
from `shopdb/core/api/models.py` INTO the plugin blueprint
(`instance/printedpartsimages/`, public GET serve, imageurl column,
prefix-guarded delete). Wire the Form upload + Detail hero image.
5. Nav: `get_navigation_items()` -> "3D Parts" (usb plugin shape). Router
meta: list/detail plugin-gated, new/edit `requiresAuth` (see
`frontend/src/router/routes/usb.js`).
Checkpoint: create an item with a photo in the UI; thumbnail on list, hero on
detail; restock from detail updates qty + shows in history; frontend build +
`npx vitest run` green; naming hook green.
## Milestone 4 - badge resolution + kiosk
Build
1. Badge resolver in the plugin (`services/badges.py`): copy the USB contract
- all-digits -> SSO; `^0(\d+)BZ$` case-insensitive -> PayNo; resolve
display name via the employees plugin directory the way
`plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside
the function, degrade gracefully when the plugin is absent). Policy
setting `printedparts_unknown_badge` (deny -> 422).
2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read
precedent): `GET /kiosk/item/<itemcode>` and `POST /kiosk/take`
{itemcode, badge, quantity}. Take: validate active item, 1 <= qty <=
onhand, resolve badge, single-commit ledger row + decrement. Clear error
strings - the kiosk displays them verbatim.
3. Kiosk view `/parts-kiosk`: top-level route, NO requiresAuth, outside
AppLayout (register beside `/shopfloor` in `frontend/src/router/index.js`).
Three-step flow per the spec. The scanner is a keyboard wedge: hidden
always-focused input, submit on Enter, route the scan to whichever step is
active. Build `TouchKeypad.vue` (3x4 grid of big buttons, emits digits/
clear/backspace) - net-new, nothing to copy, keep it dumb.
4. Manual fallback path (typed item search + badge entry) behind a small
"no scanner?" link.
Checkpoint: full kiosk walkthrough on a touchscreen (or browser): scan/type
an itemcode -> item card; badge `0123456BZ` and plain SSO both resolve; take 3
-> success screen, qty down 3, ledger row has your name; taking more than
onhand -> friendly error; unknown badge -> denied message. Backend tests for
the resolver shapes + take validation.
## Milestone 5 - labels (1in x 0.5in)
Build
1. Public print route `/print/printedparts-labels` + view (imitate
`USBLabelBatch.vue` - USB is the precedent for a plugin OWNING its label
page instead of joining TYPE_CONFIG).
2. New stock size: `@page { size: 1in 0.5in; margin: 0 }`, one label per page
(roll-fed label printers treat each page as one label). Layout: CODE128
via JsBarcode (~0.9in wide, displayValue false), itemcode text ~7pt under
it, optional truncated name. Offer QR as a variant but default barcode.
3. Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid
sheet fallback (mini72 pattern in `AssetLabelBatch.vue`).
4. Print buttons on Detail (single) and List (batch selected).
Checkpoint: print preview shows one 1x0.5 label per page; a printed (or
PDF-zoomed) barcode scans back into the kiosk and pulls up the right item.
That round trip - label printed from the catalog, scanned at the kiosk,
stock decremented with your name on it - is the demo moment; make it work
end to end before polishing.
## Milestone 6 - metrics, reports, widget
Build
1. `get_reports()` -> stock, consumption (date range), by-person; endpoints
in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via
the `generate_csv` helper pattern (`shopdb/core/api/reports.py` shows the
shape; a plugin report lives in the plugin and is merged into
`GET /api/reports` automatically when enabled).
2. Stock report includes the reconcile check: flag rows where cached
`quantityonhand` != SUM(ledger). Should always be empty; if not, you have
a non-atomic write path - find it.
3. OPTIONAL/deferred: `get_dashboard_widgets()` -> low-stock count. Caveat:
this hook predates the ADR-010 data-only renderers - the widget names a
frontend component that must already exist in core, so a plugin widget
only renders if you also add that component. Reports are the primary
monitoring surface; skip the widget unless you want the extra credit.
4. Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks +
weeks-to-empty). Plain SQL over the ledger.
Checkpoint: reports appear on /reports grouped under the plugin, CSV
downloads; widget renders on the dashboard; reconcile column all-clear after
a kiosk session.
## Milestone 7 - lifecycle + closeout
Build/verify
1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry,
routes, reports, and grantable permissions all disappear; enable restores.
2. Fresh-database proof: point DATABASE_URL at a scratch DB, `flask db
upgrade` + `flask plugin install/enable/upgrade-all` - everything works
with zero manual SQL.
3. Full suite: backend pytest, vitest, frontend build, naming hook.
4. End checklist from `PLUGIN-GUIDE.md` section 12.
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.
---
## 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/` report + `shopdb/core/api/reports.py` |
| Permissions declaration | `plugins/usb/plugin.py::get_permissions` |

View File

@@ -122,6 +122,11 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
| `get_navigation_items` | Plugin shows up in the sidebar nav |
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_reports` | Plugin's report cards appear on the Reports hub |
| `get_settings_cards` | Plugin's card joins the settings rail + landing (no `settingsNav.js` edit) |
| `get_permissions` | Plugin's RBAC permissions join the catalog, seeding, role grid, and token scopes |
| `get_asset_panels` | Plugin panel renders on matching asset-detail pages |
| `get_map_overlays` | Plugin decorates shop-floor map markers + adds a legend entry |
| `get_asset_presentation` | Plugin declares its asset type's search icon + detail route |
| `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/<name>` |
Each hook has a default that does nothing. Override only what your plugin needs.

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.11.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

@@ -97,7 +97,7 @@ removes those bundled PNGs and ships a generic placeholder SVG instead.
If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still
point at `/static/images/sitemap2025-*`, the map will 404 those images after the
upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor
upgrade. Re-upload your own floor plan in **Settings > Floor Map**. Uploaded floor
plans are stored under `instance/` and survive upgrades, so a site that already
uploaded its own plan is unaffected. Only instances still using the old bundled
default need to act.

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

@@ -1,7 +1,8 @@
# ADR-010: Frontend plugin hook contract
- **Status:** PROPOSED
- **Status:** ACCEPTED
- **Date:** 2026-07-11
- **Accepted:** 2026-07-11
- **Deciders:** cproudlock
- **Supersedes:** none
@@ -112,7 +113,7 @@ not pursued.
## Decision
**PROPOSED:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
the four presentation surfaces a generic core renderer can serve, and keep
**file-convention glob discovery** (Option C) as the deferred mechanism for the
residual cases where a real component is unavoidable. Do not pursue runtime

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

@@ -22,8 +22,9 @@ Each ADR captures a single architectural decision: the context, the decision its
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
| [008](ADR-008-plugin-migration-ownership.md) | Plugin migration ownership (per-plugin chains) | ACCEPTED |
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | PROPOSED |
| [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

@@ -1,12 +1,14 @@
{
"name": "shopdb-frontend",
"version": "0.5.0",
"version": "0.7.0",
"private": true,
"type": "module",
"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'
}
@@ -28,9 +31,17 @@ api.interceptors.response.use(
const hadToken = localStorage.getItem('token')
localStorage.removeItem('token')
localStorage.removeItem('user')
// Only redirect if user was previously logged in (session expired)
// Only redirect if user was previously logged in (session expired).
// Preserve the destination so login returns the user to this page.
if (hadToken) {
window.location.href = '/login'
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'
? loginPath + '?redirect=' + encodeURIComponent(here)
: loginPath
window.location.href = target
}
}
return Promise.reject(error)
@@ -55,6 +66,9 @@ export const authApi = {
return api.post('/auth/refresh', {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
})
},
changePassword(payload) {
return api.post('/auth/change-password', payload)
}
}
@@ -389,6 +403,15 @@ export const modelsApi = {
},
delete(id) {
return api.delete(`/models/${id}`)
},
uploadImage(id, file) {
// multipart photo upload; backend sets imageurl to the served URL
const form = new FormData()
form.append('file', file)
return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removeImage(id) {
return api.delete(`/models/${id}/image`)
}
}
@@ -470,20 +493,36 @@ export const applicationsApi = {
},
updateInstalledApp(machineId, appId, data) {
return api.put(`/applications/machines/${machineId}/${appId}`, data)
}
}
// Support Teams API (teams + nested contacts)
export const supportteamsApi = {
list(params = {}) {
return api.get('/supportteams', { params })
},
// Support teams
getSupportTeams() {
return api.get('/applications/supportteams')
get(id) {
return api.get(`/supportteams/${id}`)
},
createSupportTeam(data) {
return api.post('/applications/supportteams', data)
create(data) {
return api.post('/supportteams', data)
},
// App owners
getAppOwners() {
return api.get('/applications/appowners')
update(id, data) {
return api.put(`/supportteams/${id}`, data)
},
createAppOwner(data) {
return api.post('/applications/appowners', data)
remove(id) {
return api.delete(`/supportteams/${id}`)
},
contacts: {
add(teamId, data) {
return api.post(`/supportteams/${teamId}/contacts`, data)
},
update(teamId, contactId, data) {
return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data)
},
remove(teamId, contactId) {
return api.delete(`/supportteams/${teamId}/contacts/${contactId}`)
}
}
}
@@ -684,6 +723,11 @@ export const reportsApi = {
},
pcRelationships(params = {}) {
return api.get('/reports/pc-relationships', { params })
},
// On-demand report delivery: email the given rows as an HTML table.
// Recipients default to the site Alert Recipients when `to` is omitted.
email(payload) {
return api.post('/reports/email', payload)
}
}
@@ -714,6 +758,15 @@ export const employeesApi = {
},
importCsv(csv) {
return api.post('/employees/directory/import', { csv })
},
// multipart photo upload; backend sets photofilename + returns photourl
uploadPhoto(sso, file) {
const form = new FormData()
form.append('file', file)
return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removePhoto(sso) {
return api.delete(`/employees/${sso}/photo`)
}
}
}
@@ -783,6 +836,9 @@ export const settingsApi = {
update(key, value) {
return api.put(`/settings/${key}`, { value })
},
testEmail(to) {
return api.post('/settings/test-email', { to })
},
create(data) {
return api.post('/settings', data)
},
@@ -872,6 +928,24 @@ export const usersApi = {
}
}
// Personal API tokens: authenticate scripts/integrations as a user without
// the hourly-expiring login JWT. The secret is returned ONCE, on create.
export const apitokensApi = {
// all=true (admin) lists everyone's tokens; otherwise just the caller's.
list(params = {}) {
return api.get('/apitokens', { params })
},
create(data) {
return api.post('/apitokens', data)
},
update(id, data) {
return api.put(`/apitokens/${id}`, data)
},
remove(id) {
return api.delete(`/apitokens/${id}`)
}
}
// Network API (devices, subnets, and VLANs)
export const networkApi = {
// Network devices
@@ -1052,3 +1126,32 @@ 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`)
}
}

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 {
@@ -1083,6 +1098,212 @@ td.actions {
border-bottom: 1px solid var(--border);
}
/* Settings form controls - shared by the individual settings pages
(branding, printing, email, integrations, identifiers, search, map...). */
.setting-group {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.setting-group:first-of-type {
border-top: none;
padding-top: 0;
}
.setting-group h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--text);
}
.setting-description {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.setting-row {
margin-bottom: 1rem;
}
.setting-row.full-width {
grid-column: 1 / -1;
}
.setting-row label {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-row label span {
color: var(--text);
font-size: 0.9rem;
}
.setting-row input,
.setting-row select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 1rem;
max-width: 400px;
}
.setting-row input[type="number"] {
max-width: 120px;
}
.setting-row input:focus,
.setting-row select:focus {
outline: none;
border-color: var(--primary);
}
.input-hint {
color: var(--text-light);
font-size: 0.8rem;
}
.toggle-label {
flex-direction: row !important;
align-items: center;
justify-content: space-between;
max-width: 400px;
}
.toggle-hint {
display: block;
margin-top: -0.5rem;
margin-left: 0;
}
.toggle-btn {
position: relative;
width: 50px;
height: 26px;
border-radius: 13px;
border: none;
background: var(--secondary);
cursor: pointer;
transition: background 0.2s;
}
.toggle-btn.active {
background: var(--success);
}
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider {
transform: translateX(24px);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-light);
max-width: 400px;
margin-bottom: 1rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.inactive { background: var(--secondary); }
.status-dot.warning { background: var(--warning); }
.status-dot.pending { background: var(--primary); }
.status-dot.success { background: var(--success); }
.test-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.test-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.view-logs-link {
display: inline-block;
margin-top: 0.5rem;
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
.view-logs-link:hover {
text-decoration: underline;
}
.identifier-matrix {
width: 100%;
border-collapse: collapse;
}
.identifier-matrix th,
.identifier-matrix td {
padding: 0.6rem 0.75rem;
text-align: center;
border-bottom: 1px solid var(--border);
}
.identifier-matrix th:first-child,
.identifier-matrix td.identifier-name {
text-align: left;
}
.identifier-matrix th {
color: var(--text-light);
font-weight: 600;
font-size: 0.9rem;
}
.identifier-matrix .identifier-name {
color: var(--text);
}
.color-input-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-input-row input[type="color"] {
width: 48px;
height: 34px;
padding: 2px;
cursor: pointer;
}
.color-input-row input[type="text"] {
max-width: 160px;
}
.map-upload-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.4rem;
}
.map-thumb {
height: 40px;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
}
.map-thumb-dark { background: #222; }
.settings-success {
margin-top: 1rem;
padding: 0.75rem;
background: var(--success);
color: white;
border-radius: 4px;
}
/* Info List */
.info-list {
display: flex;
@@ -1114,6 +1335,24 @@ td.actions {
font-size: 13px;
}
/* Small email/Teams action buttons for support contacts */
.contact-action {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.4rem;
font-size: 12px;
line-height: 1.5;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--link);
background: var(--bg);
text-decoration: none;
}
.contact-action:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Feature Tags */
.feature-tag {
display: inline-block;
@@ -1260,7 +1499,7 @@ td.actions {
}
.content-grid {
grid-template-columns: 1fr;
column-count: 1;
}
.audit-footer {
@@ -1487,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 */
@@ -1512,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

@@ -18,30 +18,32 @@
</div>
<template v-else>
<!-- Outgoing relationships (this asset controls/connects to...) -->
<div v-if="outgoing.length > 0" class="relationship-group">
<h4 class="group-title">Outgoing</h4>
<!-- Symmetric connections: one direction-blind entry per peer -->
<div v-if="connectedItems.length > 0" class="relationship-group">
<h4 class="group-title">Connected</h4>
<div class="relationship-list">
<div
v-for="rel in outgoing"
:key="rel.relationshipid"
v-for="item in connectedItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
<div class="rel-line">
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
@@ -50,30 +52,42 @@
</div>
</div>
<!-- Incoming relationships (...controls/connects to this asset) -->
<div v-if="incoming.length > 0" class="relationship-group">
<h4 class="group-title">Incoming</h4>
<!-- Directional relationships: natural per-row phrasing, no jargon -->
<div v-if="directionalItems.length > 0" class="relationship-group">
<div class="relationship-list">
<div
v-for="rel in incoming"
:key="rel.relationshipid"
v-for="item in directionalItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
<div class="rel-line">
<template v-if="item.direction === 'outgoing'">
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-arrow">-&gt;</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
<template v-else>
<span class="rel-arrow">&lt;-</span>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-from">from</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteRelationship(rel.relationshipid)"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
@@ -182,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
}
@@ -225,6 +239,71 @@ let searchTimeout = null
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
// stored direction rows (both directions, plus any legacy duplicates) fold
// into one displayed entry; its rowIds carries every collapsed relationshipid
// so a delete removes them all.
const connectedItems = computed(() => {
const byPair = new Map()
const rows = [
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
]
for (const { rel, peer } of rows) {
if (rel.isdirectional !== false) continue
const selfid = resolvedAssetId.value
const peerid = peer?.assetid
const lo = Math.min(selfid, peerid)
const hi = Math.max(selfid, peerid)
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
const existing = byPair.get(key)
if (existing) {
existing.rowIds.push(rel.relationshipid)
if (!existing.notes && rel.notes) existing.notes = rel.notes
} else {
byPair.set(key, {
key,
rowIds: [rel.relationshipid],
peer,
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
}
return Array.from(byPair.values())
})
// Directional types keep one entry per stored row with arrow phrasing.
const directionalItems = computed(() => {
const items = []
for (const rel of outgoing.value) {
if (rel.isdirectional === false) continue
items.push({
key: `out-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.targetasset,
direction: 'outgoing',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
for (const rel of incoming.value) {
if (rel.isdirectional === false) continue
items.push({
key: `in-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.sourceasset,
direction: 'incoming',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
return items
})
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
@@ -236,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()
@@ -251,21 +330,20 @@ 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) {
console.log('Asset lookup failed for:', props.machineNumber)
resolvedAssetId.value = null
lookupFailed.value = true
loading.value = false
@@ -356,11 +434,12 @@ async function saveRelationship() {
}
}
async function deleteRelationship(relationshipId) {
// Delete every collapsed direction row behind a displayed entry.
async function deleteItem(item) {
if (!confirm('Remove this relationship?')) return
try {
await assetsApi.deleteRelationship(relationshipId)
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
await loadRelationships()
emit('updated')
} catch (error) {
@@ -423,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 {
@@ -499,6 +583,24 @@ function getAssetRoute(asset) {
text-decoration: underline;
}
.rel-line {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.rel-arrow {
font-family: monospace;
font-weight: 600;
color: var(--text-light);
}
.rel-from {
font-size: 0.8rem;
color: var(--text-light);
}
.rel-meta {
display: flex;
align-items: center;

View File

@@ -0,0 +1,53 @@
<template>
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
{{ sending ? 'Sending...' : 'Email report' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
import { reportsApi } from '../api'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
// On-demand report delivery. Emails the given rows as an HTML table to the
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
// out of scope for this app; point an external cron at POST /api/reports/email
// with an API token to automate.
const props = defineProps({
subject: { type: String, required: true },
columns: { type: Array, required: true },
rows: { type: Array, required: true },
intro: { type: String, default: '' },
to: { type: String, default: '' },
})
const toast = useToast()
const sending = ref(false)
async function emailReport() {
sending.value = true
try {
const payload = {
subject: props.subject,
columns: props.columns,
rows: props.rows,
intro: props.intro,
}
if (props.to) payload.to = props.to
const response = await reportsApi.email(payload)
const result = response.data?.data || {}
if (result.sent) {
toast.success('Report emailed.')
} else if (result.error) {
toast.error(`Report email failed: ${result.error}`)
} else {
toast.info(response.data?.message || 'Email is not configured.')
}
} catch (event) {
toast.error(apiError(event, 'Failed to email report'))
} finally {
sending.value = false
}
}
</script>

View File

@@ -132,7 +132,9 @@ const assetTypeColorsMap = {
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network device': '#FF9800', // Orange
'network_device': '#FF9800' // Orange (alternate key)
'network_device': '#FF9800', // Orange (alternate key)
'measuring_tool': '#9C27B0', // Purple
'measuring tool': '#9C27B0' // Purple (normalized key)
}
// Get asset type color with case-insensitive lookup
@@ -229,6 +231,7 @@ function getSubtypeId(asset) {
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
return null
}
@@ -384,6 +387,10 @@ function renderMarkers() {
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
if (item.dualpathpartner) {
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
}
detailRoute = getAssetDetailRoute(item)
} else {
// Legacy machine mode

View File

@@ -14,7 +14,7 @@ const state = reactive({
let inflight = null
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
function applySetting(key, value) {
const match = KEY_RE.exec(key)

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

@@ -0,0 +1,86 @@
// Settings catalog: the static core groups (settingsNav.js) merged with the
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
// (landing) read the merged catalog so plugins add settings cards with no core
// edit. Fetched once into a module-level ref and shared across callers.
import { ref } from 'vue'
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
import api from '../api'
import { settingsGroups } from '../views/settings/settingsNav'
// Icon string keys (as returned by the hook) mapped to Lucide components,
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
const iconMap = {
ruler: Ruler,
wrench: Wrench,
cog: Cog,
package: Package,
settings: Settings,
puzzle: Puzzle,
sliders: SlidersHorizontal,
palette: Palette,
bell: Bell,
network: Network,
printer: Printer,
droplets: Droplets,
}
function resolveIcon(key) {
return iconMap[key] || Puzzle
}
// Merge plugin cards into a fresh copy of the core groups. A card joins the
// group whose title matches its `group`; a new group is appended at the end.
function mergeCards(baseGroups, cards) {
const merged = baseGroups.map(group => ({
title: group.title,
cards: [...group.cards],
}))
const byTitle = new Map(merged.map(group => [group.title, group]))
for (const card of cards) {
const entry = {
to: card.to,
icon: resolveIcon(card.icon),
title: card.title,
description: card.description || '',
position: card.position ?? 99,
}
let group = byTitle.get(card.group)
if (!group) {
group = { title: card.group, cards: [] }
byTitle.set(card.group, group)
merged.push(group)
}
group.cards.push(entry)
}
// Order plugin cards within a group by position; core cards keep their order.
for (const group of merged) {
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
}
return merged
}
// Shared across component instances: seeded with the core groups, replaced with
// the merged catalog once the hook endpoint answers.
const groups = ref(settingsGroups)
let loaded = false
async function loadCatalog() {
try {
const response = await api.get('/pluginui/settings-cards')
groups.value = mergeCards(settingsGroups, response.data.data || [])
} catch (error) {
// Degrade to the core-only catalog; the rail still works without plugins.
groups.value = settingsGroups
}
}
export function useSettingsCatalog() {
if (!loaded) {
loaded = true
loadCatalog()
}
return { groups }
}

View File

@@ -0,0 +1,359 @@
// Shared plumbing for the individual system-settings pages.
// Each settings page calls useSystemSettings() in its own setup, so state is
// per-page (no cross-page leak). The composable owns the load-all/save/toggle/
// upload machinery plus the static display catalogs, keeping the pages thin.
// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages.
import { ref, reactive } from 'vue'
import { settingsApi, computersApi } from '../api'
import { setIdentifierFlag } from './identifierSettings'
import { apiError } from '../utils/apiError'
// Branding logo widgets. kind maps to the backend endpoint; key is the setting
// the resulting URL is stored under.
export const brandingLogos = [
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
]
// Asset identifier matrix: identifier x asset type. Keys follow
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
export const identifierRows = [
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
{ name: 'maintenancereference', label: 'Maintenance Reference' },
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
export const assetTypeCols = [
{ key: 'machine', label: 'Machine' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' },
{ key: 'measuring_tool', label: 'Measuring Tool' }
]
// Global-search domain toggles: keys follow search_<type>_enabled.
// Missing = enabled (default on).
export const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'employee', label: 'Employees' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },
{ key: 'printer', label: 'Printers' },
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' }
]
export function useSystemSettings() {
// Every settings key across all pages. A page binds only its own subset.
const settings = reactive({
// Zabbix
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
// Dell warranty
warranty_dell_enabled: false,
warranty_dell_clientid: '',
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// ServiceNow
servicenow_enabled: true,
servicenow_search_url: '',
servicenow_ticket_prefixes: '',
servicenow_incident_url: '',
servicenow_change_url: '',
// Branding
site_logo: '',
qr_logo: '',
badge_logo: '',
site_favicon: '',
brand_primary_color: '',
brand_primary_dark_color: '',
brand_accent_color: '',
brand_sidebar_color: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
qr_target_machine: '',
qr_target_computer: '',
qr_target_network_device: '',
qr_target_measuring_tool: '',
label_default_style: 'card',
label_default_codetype: 'qr',
label_default_encodes_machine: 'assetnumber',
label_default_encodes_computer: 'assetpage',
label_default_encodes_printer: 'assetpage',
label_default_encodes_network_device: 'assetpage',
label_default_encodes_measuring_tool: 'location',
// Email
smtp_enabled: false,
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_address: '',
smtp_from_name: 'ShopDB',
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',
saml_entity_id: '',
saml_acs_url: '',
saml_allow_local_login: true,
saml_auto_create_users: true,
saml_admin_group: ''
})
const identifierMatrix = reactive({})
const searchMatrix = reactive({})
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
const computerTypes = ref([]) // ComputerType names for the dropdown
const loading = ref(true)
const saving = ref(false)
const mapUploading = ref(false)
const brandingUploading = ref(false)
const error = ref('')
const success = ref('')
function flashSaved() {
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
}
function identifierKey(name, assettype) {
return `identifier_${name}_${assettype}_enabled`
}
function matrixValue(name, assettype) {
const key = identifierKey(name, assettype)
return key in identifierMatrix ? identifierMatrix[key] : true
}
function searchValue(domainKey) {
const key = `search_${domainKey}_enabled`
return key in searchMatrix ? searchMatrix[key] : true
}
// Load all settings and fan them into settings / matrices / pctype rows.
async function loadSettings() {
try {
loading.value = true
const { data } = await settingsApi.list()
const pctypeRows = []
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
} else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} else if (/^search_.+_enabled$/.test(setting.key)) {
searchMatrix[setting.key] = setting.value !== false
} else if (setting.key.startsWith('pctypemap_')) {
pctypeRows.push({
pxetype: setting.key.slice('pctypemap_'.length),
computertype: setting.value
})
}
}
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
} catch (e) {
error.value = 'Failed to load settings'
console.error(e)
} finally {
loading.value = false
}
}
// Computer type options for the pctype mapping dropdown (own request).
async function loadComputerTypes() {
try {
const typesResponse = await computersApi.types.list({ perpage: 100 })
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
} catch (typesError) {
console.error('Failed to load computer types', typesError)
}
}
async function saveSetting(key, value) {
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, value)
settings[key] = value
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSetting(key) {
await saveSetting(key, !settings[key])
}
// Toggle a per-type identifier flag. The key may not be seeded on older
// installs, so create it when the update returns 404.
async function toggleIdentifier(name, assettype) {
const key = identifierKey(name, assettype)
const newValue = !matrixValue(name, assettype)
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'identifiers',
description: `Show the ${name} identifier on ${assettype} assets`
})
} else {
throw e
}
}
identifierMatrix[key] = newValue
// Push into the shared composable so open asset views react without a
// full page reload (the composable otherwise fetches only once).
setIdentifierFlag(name, assettype, newValue)
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSearchDomain(domainKey) {
const key = `search_${domainKey}_enabled`
const newValue = !searchValue(domainKey)
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'search',
description: `Include ${label} in global search results`
})
} else {
throw e
}
}
searchMatrix[key] = newValue
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function changePcTypeMapping(pxetype, computertype) {
const key = `pctypemap_${pxetype}`
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, computertype)
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
if (row) row.computertype = computertype
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]
if (!file) return
mapUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
const url = data?.data?.value
if (theme === 'light') settings.map_blueprint_light = url
else settings.map_blueprint_dark = url
success.value = 'Blueprint uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
mapUploading.value = false
event.target.value = ''
}
}
async function uploadLogo(kind, key, event) {
const file = event.target.files[0]
if (!file) return
brandingUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
const url = data?.data?.value
if (url) settings[key] = url
success.value = 'Logo uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
brandingUploading.value = false
event.target.value = ''
}
}
return {
settings,
identifierMatrix, searchMatrix, pcTypeMappings, computerTypes,
loading, saving, mapUploading, brandingUploading, error, success,
loadSettings, loadComputerTypes,
saveSetting, toggleSetting,
identifierKey, matrixValue, toggleIdentifier,
searchValue, toggleSearchDomain,
changePcTypeMapping,
uploadBlueprint, uploadLogo,
}
}

View File

@@ -45,6 +45,13 @@ const routes = [
component: () => import('../views/Login.vue'),
meta: { guest: true }
},
// Forced/self-service password change (standalone, authenticated)
{
path: '/change-password',
name: 'change-password',
component: () => import('../views/ChangePassword.vue'),
meta: { requiresAuth: true }
},
// First-run setup wizard (standalone, admin-only)
{
path: '/setup',
@@ -70,6 +77,20 @@ const routes = [
name: 'print-machine-badge',
component: () => import('../views/print/MachineBadge.vue')
},
// Shared asset label/code generator for any asset type (public, like the
// other /print/* routes). assettype = machine|computer|printer|
// network_device|measuring_tool; id = the asset's plugin id.
{
path: '/print/asset-label/:assettype/:id',
name: 'print-asset-label',
component: () => import('../views/print/AssetLabel.vue')
},
// Batch label sheets (ULINE 6-up / mini 72-up) for one asset type.
{
path: '/print/asset-label-batch/:assettype',
name: 'print-asset-label-batch',
component: () => import('../views/print/AssetLabelBatch.vue')
},
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',
@@ -96,7 +117,7 @@ const routes = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
@@ -105,7 +126,8 @@ router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return next('/login')
// Remember where they were headed so login can send them back there.
return next({ path: '/login', query: { redirect: to.fullPath } })
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return next('/')
@@ -114,6 +136,13 @@ router.beforeEach(async (to, from, next) => {
return next('/')
}
// Forced password change: an admin-set temporary password must be replaced
// before the user reaches the rest of the app. Let them log out.
if (authStore.isAuthenticated && authStore.mustChangePassword
&& to.path !== '/change-password' && to.path !== '/login') {
return next('/change-password')
}
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
// The enabled list is fetched once and cached; fail-open on error so a blip
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).

View File

@@ -89,6 +89,12 @@ export default [
component: () => import('../../views/settings/BusinessUnitsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/supportteams',
name: 'supportteams',
component: () => import('../../views/settings/SupportTeamsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/dashboarddefaults',
name: 'dashboarddefaults',
@@ -155,10 +161,107 @@ export default [
component: () => import('../../views/settings/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
// The old monolithic System Settings tab page is split into one routed page
// per section (below). This redirect keeps old bookmarks working: bare
// /settings/system and every /settings/system?tab=<key> land on the right page.
{
path: 'settings/system',
name: 'system-settings',
component: () => import('../../views/settings/SystemSettings.vue'),
redirect: to => {
const tabRedirects = {
integrations: '/settings/servicenow',
branding: '/settings/branding',
printing: '/settings/printing',
email: '/settings/email',
audit: '/settings/audit',
auth: '/settings/authentication',
identifiers: '/settings/assetidentifiers',
search: '/settings/globalsearch',
map: '/settings/floormap',
pctype: '/settings/pctypemapping'
}
// vue-router carries the original ?tab= query onto the target; harmless
// since the new pages ignore it, and the path is what routes the view.
return tabRedirects[to.query.tab] || '/settings/servicenow'
},
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/servicenow',
name: 'servicenow-settings',
component: () => import('../../views/settings/ServiceNowSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/zabbix',
name: 'zabbix-settings',
component: () => import('../../views/settings/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/dellwarranty',
name: 'dell-warranty-settings',
component: () => import('../../views/settings/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/pctypemapping',
name: 'pctype-mapping-settings',
component: () => import('../../views/settings/PCTypeMappingSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/branding',
name: 'branding-settings',
component: () => import('../../views/settings/BrandingSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/printing',
name: 'printing-settings',
component: () => import('../../views/settings/PrintingSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/email',
name: 'email-settings',
component: () => import('../../views/settings/EmailSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/audit',
name: 'audit-settings',
component: () => import('../../views/settings/AuditSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/authentication',
name: 'authentication-settings',
component: () => import('../../views/settings/AuthenticationSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/apitokens',
name: 'apitokens-settings',
component: () => import('../../views/settings/ApiTokensList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/assetidentifiers',
name: 'asset-identifiers-settings',
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/globalsearch',
name: 'global-search-settings',
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/floormap',
name: 'floor-map-settings',
component: () => import('../../views/settings/FloorMapSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{

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

@@ -19,6 +19,14 @@ export default [
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
meta: { requiresAuth: true, plugin: 'measuringtools' }
},
{
// Resolve a tool from its core asset id (search rows / cross-links carry
// assetid, not the extension id). Shares the detail component.
path: 'measuringtools/by-asset/:assetid',
name: 'measuringtool-by-asset',
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
meta: { plugin: 'measuringtools' }
},
{
path: 'measuringtools/:id',
name: 'measuringtool-detail',

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,35 @@
/**
* 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' }
}
]

View File

@@ -13,11 +13,14 @@ export const useAuthStore = defineStore('auth', {
roles: (state) => state.user?.roles || [],
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
isAdmin: (state) => state.user?.roles?.includes('admin') || false,
// True when an admin-set temporary password must be changed before use.
mustChangePassword: (state) => !!state.user?.mustchangepassword,
// Full name from the employee directory (falls back to username/SSO).
displayName: (state) => state.user?.directoryname || state.user?.username || '',
// Employee photo URL if the directory has one for this SSO.
avatarUrl: (state) => state.user?.directorypicture
? `/static/employees/${state.user.directorypicture}` : null
// Employee photo URL if the directory has one for this SSO. The directory
// resolver already returns a usable URL (self-hosted upload or external HR
// path), so it is used as-is.
avatarUrl: (state) => state.user?.directoryphotourl || null
},
actions: {
@@ -56,6 +59,15 @@ export const useAuthStore = defineStore('auth', {
localStorage.removeItem('user')
},
// Clear the forced-password-change flag after a successful change so the
// router guard stops steering the user to the change-password view.
clearMustChangePassword() {
if (this.user) {
this.user.mustchangepassword = false
localStorage.setItem('user', JSON.stringify(this.user))
}
},
async fetchUser() {
try {
const response = await authApi.me()
@@ -77,7 +89,7 @@ export const useAuthStore = defineStore('auth', {
const emp = response.data?.data
if (emp) {
this.user.directoryname = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || null
this.user.directorypicture = emp.Picture || null
this.user.directoryphotourl = emp.photourl || null
}
} catch (err) { /* directory unavailable - fall back to username */ }
}

View File

@@ -9,6 +9,7 @@ const ASSET_TYPE_LABELS = {
'printer': 'Printers',
'network_device': 'Network Devices',
'network device': 'Network Devices',
'measuring_tool': 'Measuring Tools',
}
const ASSET_TYPE_ROUTES = {
@@ -17,6 +18,7 @@ const ASSET_TYPE_ROUTES = {
'printer': '/printers',
'network_device': '/network',
'network device': '/network',
'measuring_tool': '/measuringtools',
}
// Plugin-specific id field inside asset.typedata for each asset type.
@@ -26,6 +28,7 @@ const ASSET_TYPE_ID_KEYS = {
'printer': 'printerid',
'network_device': 'networkdeviceid',
'network device': 'networkdeviceid',
'measuring_tool': 'measuringtoolid',
}
function titleCase(text) {

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

@@ -7,7 +7,9 @@ export const assetTypeColorsMap = {
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
network_device: '#FF9800' // Orange (alternate key)
network_device: '#FF9800', // Orange (alternate key)
measuring_tool: '#9C27B0', // Purple
'measuring tool': '#9C27B0' // Purple (normalized key)
}
const DEFAULT_COLOR = '#BDBDBD'
@@ -37,6 +39,7 @@ export function getSubtypeId(asset) {
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
return null
}

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.
@@ -120,6 +122,16 @@ export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
}
// Email domain appended to a contact SSO to build email/Teams links
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
// means "disable contact action buttons", so read the raw setting rather than
// getSetting (which swaps '' for the fallback).
export async function getContactEmailDomain() {
const settings = await loadSettings()
const value = settings['contact_email_domain']
return (value === undefined || value === null) ? 'geaerospace.com' : value
}
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
// shipped defaults. Do not touch the style.css palette here.
export async function applyBranding() {

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>
@@ -39,13 +39,22 @@
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
:alt="authStore.displayName" @error="onAvatarError" />
<div class="user-ident">
<div class="username">{{ authStore.displayName }}</div>
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
<div class="user-identity">
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
:alt="authStore.displayName" @error="onAvatarError" />
<div class="user-ident">
<div class="username">{{ authStore.displayName }}</div>
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
</div>
</div>
<div class="user-actions">
<router-link to="/change-password" class="icon-btn" title="Change password">
<KeyRound :size="16" /> Password
</router-link>
<button class="icon-btn" @click="handleLogout" title="Log out">
<LogOut :size="16" /> Logout
</button>
</div>
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
</template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
</div>
@@ -75,32 +84,42 @@
</span>
</div>
</div>
<router-view />
<!-- Keyed on path so same-component navigation (machine -> machine via
a relationship link) remounts and reloads; query-only changes
(e.g. /reports?report=x) do not remount, and the settings shell
keys as one unit so its rail survives child navigation. -->
<router-view :key="routeViewKey" />
</main>
<ToastHost />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
Box, KeyRound, LogOut
} 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()
const authStore = useAuthStore()
const routeViewKey = computed(() =>
route.path.startsWith('/settings') ? '/settings' : route.path
)
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) {
@@ -128,6 +147,7 @@ const iconMap = {
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)
@@ -229,8 +249,31 @@ function onAvatarError(event) {
</script>
<style scoped>
.user-menu { display: flex; align-items: center; gap: 0.6rem; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); }
.user-ident { display: flex; flex-direction: column; line-height: 1.1; }
.user-sso { font-size: 0.72rem; color: var(--text-light); }
/* Footer user block: identity row on top, compact icon actions below, all
kept inside the fixed-width sidebar (no full-width buttons overflowing). */
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-actions { display: flex; gap: 0.5rem; }
.icon-btn {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: rgba(255,255,255,0.8);
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="login-container">
<div class="login-box">
<img :src="siteLogo" alt="Site logo" class="login-logo" />
<h1>Change Password</h1>
<p v-if="forced" class="first-run-note">
Your account uses a temporary password. Set a new one to continue.
</p>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
<form @submit.prevent="handleSubmit">
<div v-if="!forced" class="form-group">
<label for="currentpassword">Current Password</label>
<input id="currentpassword" v-model="currentPassword" type="password"
class="form-control" required />
</div>
<div class="form-group">
<label for="newpassword">New Password</label>
<input id="newpassword" v-model="newPassword" type="password"
class="form-control" required minlength="8" autofocus />
</div>
<div class="form-group">
<label for="confirmpassword">Confirm New Password</label>
<input id="confirmpassword" v-model="confirmPassword" type="password"
class="form-control" required minlength="8" />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Saving...' : 'Change password' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
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()
const authStore = useAuthStore()
const forced = computed(() => authStore.mustChangePassword)
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
onMounted(() => {
getSiteLogo().then(logo => { siteLogo.value = logo })
})
async function handleSubmit() {
error.value = ''
success.value = ''
if (newPassword.value !== confirmPassword.value) {
error.value = 'New passwords do not match'
return
}
loading.value = true
try {
const payload = { new_password: newPassword.value }
if (!forced.value) payload.current_password = currentPassword.value
await authApi.changePassword(payload)
authStore.clearMustChangePassword()
success.value = 'Password changed.'
// Land in the app now that the forced-change flag is cleared.
setTimeout(() => router.push('/'), 600)
} catch (event) {
error.value = apiError(event, 'Failed to change password')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
</style>

View File

@@ -48,15 +48,29 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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()
const authStore = useAuthStore()
const siteLogo = ref('/ge-aerospace-logo.svg')
// Where to land after login: the guard-supplied ?redirect target, else the
// dashboard. Only accept same-site absolute paths to avoid open redirects.
function postLoginTarget() {
const redirect = route.query.redirect
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
// 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(withBase('/ge-aerospace-logo.svg'))
const mode = ref('login')
const username = ref('')
const email = ref('')
@@ -79,7 +93,7 @@ async function handleLogin() {
loading.value = true
const result = await authStore.login(username.value, password.value)
loading.value = false
if (result.success) router.push('/')
if (result.success) router.push(postLoginTarget())
else error.value = result.message
}

View File

@@ -18,6 +18,7 @@
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
<option value="measuring_tool">Measuring Tools</option>
</select>
</div>
@@ -105,7 +106,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
@@ -158,7 +159,8 @@ function getTypeIcon(assettype) {
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}

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">
@@ -116,6 +116,7 @@ const typeLabels = {
knowledgebase: 'KB',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
employee: 'Employee',
notification: 'Notice',
subnet: 'Subnet'
@@ -127,6 +128,7 @@ const filterTypeMap = {
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
notifications: ['notification'],
@@ -139,6 +141,7 @@ const filterList = [
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'notifications', label: 'Notices' },
@@ -382,51 +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.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;
@@ -456,10 +441,6 @@ watch(results, () => {
color: var(--text-light);
}
.result-location::before {
content: '\1F4CD ';
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
@@ -482,50 +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.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

@@ -47,7 +47,7 @@
<div class="recognition-photo-container">
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:src="rec.employeepicture"
:alt="rec.employeename"
class="recognition-photo"
@error="handlePhotoError"
@@ -91,7 +91,7 @@
>
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:src="rec.employeepicture"
:alt="rec.employeename"
class="recert-photo"
@error="handlePhotoError"
@@ -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

@@ -48,26 +48,30 @@
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Support Info -->
<!-- Support -->
<div class="section-card">
<h3 class="section-title">Support Information</h3>
<h3 class="section-title">Support</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Support Team</span>
<span class="info-value">
<a v-if="app.supportteam?.teamurl" :href="app.supportteam.teamurl" target="_blank">
{{ app.supportteam?.teamname || '-' }}
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
{{ app.supportteamname || '-' }}
</a>
<span v-else>{{ app.supportteam?.teamname || '-' }}</span>
<span v-else>{{ app.supportteamname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">App Owner</span>
<span class="info-value">{{ app.supportteam?.owner?.appowner || '-' }}</span>
</div>
<div class="info-row" v-if="app.supportteam?.owner?.sso">
<span class="info-label">SSO</span>
<span class="info-value mono">{{ app.supportteam.owner.sso }}</span>
<div class="info-row" v-if="app.contacts && app.contacts.length">
<span class="info-label">Contacts</span>
<span class="info-value">
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</span>
</span>
</div>
</div>
</div>
@@ -89,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 -->
@@ -136,6 +157,7 @@
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { applicationsApi } from '../../api'
import { getContactEmailDomain } from '../../utils/siteSettings'
const route = useRoute()
@@ -143,9 +165,16 @@ const loading = ref(true)
const app = ref(null)
const versions = ref([])
const installedOn = ref([])
const contactEmailDomain = ref('')
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
onMounted(async () => {
try {
contactEmailDomain.value = await getContactEmailDomain()
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
@@ -155,7 +184,6 @@ onMounted(async () => {
const versionsRes = await applicationsApi.getVersions(route.params.id)
versions.value = versionsRes.data.data || []
} catch (e) {
console.log('No versions data')
}
// Load installed on which PCs
@@ -163,7 +191,6 @@ onMounted(async () => {
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
installedOn.value = installedRes.data.data || []
} catch (e) {
console.log('No installed data')
}
} catch (error) {
console.error('Error loading application:', error)
@@ -326,6 +353,11 @@ function handleImageError(e) {
font-size: 1.125rem;
}
/* Support contacts stack one per line */
.contact-line {
display: block;
}
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text {
white-space: pre-wrap;

View File

@@ -154,7 +154,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { applicationsApi } from '../../api'
import { applicationsApi, supportteamsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const route = useRoute()
@@ -187,7 +187,7 @@ const supportTeams = ref([])
onMounted(async () => {
try {
// Load support teams
const teamsRes = await applicationsApi.getSupportTeams()
const teamsRes = await supportteamsApi.list()
supportTeams.value = teamsRes.data.data || []
// Load application if editing
@@ -198,7 +198,7 @@ onMounted(async () => {
form.value = {
appname: app.appname || '',
appdescription: app.appdescription || '',
supportteamid: app.supportteam?.supportteamid || '',
supportteamid: app.supportteamid || '',
isinstallable: app.isinstallable || false,
islicenced: app.islicenced || false,
isprinter: app.isprinter || false,

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>App Owner</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.supportteam?.teamname || '-' }}</td>
<td>{{ app.supportteam?.owner?.appowner || '-' }}</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

@@ -5,8 +5,8 @@
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.Picture">
<img :src="employee.Picture" :alt="fullName" />
<div class="hero-image" v-if="employee.photourl">
<img :src="employee.photourl" :alt="fullName" />
</div>
<div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span>
@@ -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

@@ -6,6 +6,9 @@
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Badge
</router-link>
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
@@ -16,8 +19,21 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
<div v-if="machine.dualpathpartner" class="dualpath-banner">
Dual-bay machine - sibling bay:
<router-link
v-if="machine.dualpathpartner.machineid"
:to="`/machines/${machine.dualpathpartner.machineid}`"
>{{ machine.dualpathpartner.assetnumber }}</router-link>
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
</div>
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="machine.machine?.imageurl">
<img :src="machine.machine.imageurl" :alt="machine.machine.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ machine.assetnumber }}</h1>
@@ -56,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 -->
@@ -121,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>
@@ -154,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" />
@@ -190,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">
@@ -264,6 +282,22 @@ function formatDate(dateStr) {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.dualpath-banner {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
color: var(--text);
font-size: 0.9rem;
}
.dualpath-banner a {
color: var(--link);
font-weight: 600;
}
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;

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' }]
@@ -523,7 +520,6 @@ onMounted(async () => {
}
}
} catch (e) {
console.log('Could not load relationships')
}
}
}

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Machines</h2>
<router-link to="/print/asset-label-batch/machine" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
</div>
@@ -35,8 +36,10 @@
</tr>
</thead>
<tbody>
<tr v-for="item in machines" :key="item.assetid">
<td>{{ item.assetnumber }}</td>
<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>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.machine?.machinetypename || '-' }}</td>
@@ -47,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"
@@ -84,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)
@@ -120,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

@@ -3,6 +3,9 @@
<div class="page-header">
<h2>Measuring Tool Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/measuring_tool/${tool?.measuringtool?.measuringtoolid}`" class="btn btn-secondary" v-if="tool" target="_blank">
Print Label
</router-link>
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>
@@ -45,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 -->
@@ -61,10 +65,14 @@
<span class="info-label">Name</span>
<span class="info-value">{{ tool.name }}</span>
</div>
<div class="info-row" v-if="tool.gaugelabreference">
<div class="info-row" v-if="tool.gaugelabreference && isEnabled('gaugelabreference', 'measuring_tool')">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ tool.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="tool.maintenancereference && isEnabled('maintenancereference', 'measuring_tool')">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ tool.maintenancereference }}</span>
</div>
<div class="info-row" v-if="tool.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ tool.serialnumber }}</span>
@@ -132,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">
@@ -164,9 +172,12 @@ import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const tool = ref(null)
const { warranties } = useWarrantyBadge(() => tool.value?.assetid)
@@ -178,7 +189,11 @@ function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleStri
onMounted(async () => {
try {
const response = await measuringtoolsApi.get(route.params.id)
// by-asset route resolves the tool from a core asset id (search/cross-link
// rows carry assetid, not the extension id); detail route keys on the id.
const response = route.params.assetid
? await measuringtoolsApi.getByAsset(route.params.assetid)
: await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
} catch (err) {
console.error('Error loading measuring tool:', err)

View File

@@ -27,13 +27,21 @@
<label for="serialnumber">Serial Number (vendor)</label>
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
</div>
<div class="form-group">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'measuring_tool')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input id="gaugelabreference" v-model="form.gaugelabreference" type="text" class="form-control" />
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
</div>
<div class="form-row" v-if="isEnabled('maintenancereference', 'measuring_tool')">
<div class="form-group">
<label for="maintenancereference">Maintenance Reference</label>
<input id="maintenancereference" v-model="form.maintenancereference" type="text" class="form-control" />
<small class="form-help">Maintenance system asset reference (if tracked)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="measuringtooltypeid">Type</label>
@@ -126,11 +134,14 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { measuringtoolsApi, assetsApi, locationsApi, businessunitsApi } from '../../api'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
import { apiError } from '../../utils/apiError'
const route = useRoute()
const router = useRouter()
const { isEnabled } = useIdentifierFlags()
const isEdit = computed(() => !!route.params.id)
// Resolved dynamically from /api/assets/types (no hardcoded id) so custom
@@ -147,6 +158,7 @@ const form = ref({
assetnumber: '',
name: '',
gaugelabreference: '',
maintenancereference: '',
serialnumber: '',
statusid: '',
measuringtooltypeid: '',
@@ -190,6 +202,7 @@ onMounted(async () => {
assetnumber: data.assetnumber || '',
name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || '',
measuringtooltypeid: ext.measuringtooltypeid || '',
@@ -218,6 +231,7 @@ async function saveTool() {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || null,
measuringtooltypeid: form.value.measuringtooltypeid || null,

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Measuring Tools</h2>
<router-link to="/print/asset-label-batch/measuring_tool" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
</div>
@@ -48,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>
@@ -60,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"
@@ -95,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)
@@ -141,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

@@ -2,7 +2,8 @@
<div class="detail-page" v-if="device">
<div class="hero-card">
<div class="hero-image">
<div class="device-icon">
<img v-if="device.networkdevice?.imageurl" :src="device.networkdevice.imageurl" alt="Model photo" />
<div class="device-icon" v-else>
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
</div>
</div>
@@ -58,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>
@@ -137,42 +94,82 @@
<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>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
@@ -180,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,346 +1,347 @@
<template>
<div>
<div class="page-header">
<h2>Network Devices</h2>
<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'
<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'
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>
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

@@ -3,6 +3,7 @@
<div class="page-header">
<h2>Computer Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
</div>
@@ -13,6 +14,9 @@
<template v-else-if="computer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="computer.computer?.imageurl">
<img :src="computer.computer.imageurl" :alt="computer.computer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
@@ -45,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 -->
@@ -114,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 -->
@@ -136,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>
@@ -187,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>
@@ -208,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">
@@ -265,7 +270,6 @@ onMounted(async () => {
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
installedApps.value = appsResponse.data.data || []
} catch (appError) {
console.log('No installed apps data:', appError.message)
}
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {

View File

@@ -1,210 +1,211 @@
<template>
<div>
<div class="page-header">
<h2>Computers</h2>
<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

@@ -0,0 +1,425 @@
<template>
<div>
<!-- Controls (never printed) -->
<div class="no-print">
<div class="controls">
<h3>Print Asset Label</h3>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="!asset" class="error-msg">Asset not found</div>
<template v-else>
<div class="control-row">
<label>Style
<select v-model="style">
<option value="card">Card (badge)</option>
<option value="plain">Plain (code only)</option>
</select>
</label>
<label>Code type
<select v-model="codetype">
<option value="qr">QR code</option>
<option value="barcode">Barcode (CODE128)</option>
</select>
</label>
<label>Encodes
<select v-model="encodes">
<option value="assetpage">Asset page (link)</option>
<option value="assetnumber">Asset number</option>
<option value="serialnumber">Serial number</option>
<option v-if="hasLocation" value="location">Inspection location code</option>
<option value="custom">Custom target (settings template)</option>
</select>
</label>
</div>
<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: 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>
<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>
<!-- 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>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
import { getSetting } from '@/utils/siteSettings'
import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
} from './assetLabel'
const route = useRoute()
const assettype = route.params.assettype
const config = TYPE_CONFIG[assettype] || null
const loading = ref(true)
const asset = ref(null)
const barcodeEl = ref(null)
const qrImage = ref('')
// The resolved string the code encodes; empty when the chosen field has no
// value (e.g. serial number on an asset with none) so the UI can explain it.
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))
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
const cardTitle = computed(() => {
if (!asset.value) return ''
const ext = extension.value || {}
return ext.modelname
|| ext.measuringtooltypename
|| asset.value.assettypename
|| (config ? config.label : 'Asset')
})
const imageUrl = computed(() => {
const ext = extension.value
return (ext && ext.imageurl) ? ext.imageurl : null
})
// Small identity table shown on the card.
const identityFields = computed(() => {
if (!asset.value) return []
const rows = []
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
return rows
})
// Human caption printed under the code.
const caption = computed(() => captionFor(asset.value, encodes.value))
// Human phrase for the current encode mode, used in the "nothing to encode"
// message.
const ENCODE_LABELS = {
assetnumber: 'asset number', serialnumber: 'serial number',
location: 'inspection location', assetpage: 'page link', custom: 'target',
}
const encodeLabel = computed(() =>
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
async function renderCode() {
const text = await resolveText(assettype, asset.value, encodes.value)
codeText.value = text
if (!text) { qrImage.value = ''; return }
if (codetype.value === 'qr') {
qrImage.value = await renderQrDataUrl(text)
} else {
await nextTick()
if (!barcodeEl.value) return
try {
JsBarcode(barcodeEl.value, text, {
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
})
} catch (err) {
console.error('Barcode error:', err)
}
}
}
onMounted(async () => {
if (!config) { loading.value = false; return }
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
encodes.value = await resolveDefaultEncodes(assettype)
try {
const response = await config.api.get(route.params.id)
asset.value = response.data.data
} catch (err) {
console.error('Error loading asset:', err)
} finally {
loading.value = false
await nextTick()
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()
}
</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 {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
max-width: 40rem;
}
.controls h3 { margin-top: 0; }
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
.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;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
}
.print-btn:hover { background: var(--primary-dark); }
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
.asset-label {
width: 2.13in;
min-height: 3.38in;
background: white;
color: #000;
border: 1px solid #ccc;
box-sizing: border-box;
padding: 0.15in;
display: flex;
flex-direction: column;
align-items: center;
}
.asset-label.plain { justify-content: center; min-height: 2in; }
.label-title {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.08in;
}
.label-image {
max-width: 1.6in;
max-height: 1.2in;
object-fit: contain;
margin-bottom: 0.08in;
}
.label-fields { width: 100%; margin-bottom: 0.08in; }
.label-field {
display: flex;
justify-content: space-between;
gap: 6px;
font-size: 8pt;
line-height: 1.4;
}
.field-label { color: #555; }
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
.code-area {
margin-top: auto;
text-align: center;
width: 100%;
}
.code-qr { width: 1.5in; height: 1.5in; }
.code-barcode { width: 1.8in; height: 0.9in; }
.code-caption {
font-size: 12pt;
font-weight: bold;
font-family: monospace;
margin-top: 0.02in;
}
.code-missing {
font-size: 9pt;
color: #999;
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;
}
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<div>
<!-- Controls (never printed) -->
<div class="no-print">
<div class="controls">
<h3>Batch Print {{ config ? config.label : 'Asset' }} Labels</h3>
<p v-if="!config" class="error-msg">Unknown asset type.</p>
<template v-else>
<div class="control-row">
<label>Format
<select v-model="format">
<option value="uline6">ULINE 6-up (3 in x 3 in, 6 per page)</option>
<option value="mini72">Mini 72-up (12 per cell, 72 per page)</option>
</select>
</label>
<label>Code type
<select v-model="codetype">
<option value="qr">QR code</option>
<option value="barcode">Barcode (CODE128)</option>
</select>
</label>
<label>Encodes
<select v-model="encodes">
<option value="assetpage">Asset page (link)</option>
<option value="assetnumber">Asset number</option>
<option value="serialnumber">Serial number</option>
<option v-if="hasLocation" value="location">Inspection location code</option>
<option value="custom">Custom target (settings template)</option>
</select>
</label>
<label>Start at cell
<select v-model="startCell">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loadingAssets" class="loading-msg">Loading assets...</div>
<div v-else-if="assets.length === 0" class="loading-msg">No assets found.</div>
<div v-else class="asset-grid">
<div
v-for="asset in assets"
:key="asset.assetid"
class="asset-item"
:class="{ selected: isSelected(asset) }"
@click="toggle(asset)"
>
<input type="checkbox" :checked="isSelected(asset)" @click.stop />
<label>
<strong>{{ asset.assetnumber || '-' }}</strong>
<div class="asset-sub">{{ asset.name || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selected.length }}</span>
(<span class="pages">{{ pageCount }}</span> page{{ pageCount === 1 ? '' : 's' }})
</div>
<button class="print-btn" :disabled="selected.length === 0" @click="print">Print Labels</button>
<button class="secondary-btn" @click="selectAll">Select All</button>
<button class="secondary-btn" @click="clearSelection">Clear</button>
</template>
</div>
</div>
<!-- Printable sheets -->
<div class="sheets-container">
<!-- ULINE 6-up: one asset per 3in cell -->
<template v-if="format === 'uline6'">
<div v-for="(page, pageIdx) in uline6Pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label"
:class="[`pos-${cellNum}`, page[cellNum - 1] ? 'active' : 'inactive']"
>
<template v-if="page[cellNum - 1]">
<div class="model-name">{{ titleFor(page[cellNum - 1]) }}</div>
<div class="qr-container">
<img
v-if="codetype === 'qr'"
class="qr-img"
:src="qrMap[page[cellNum - 1].assetid] || ''"
/>
<svg
v-else
:ref="el => setBarcodeRef(el, page[cellNum - 1].assetid, 'u')"
class="barcode-svg"
></svg>
</div>
<div class="info-section">
<div class="csf-name">{{ captionMap[page[cellNum - 1].assetid] }}</div>
<div v-if="page[cellNum - 1].name" class="info-row">{{ page[cellNum - 1].name }}</div>
</div>
</template>
</div>
</div>
</template>
<!-- Mini 72-up: 6 cells x 12 mini labels -->
<template v-else>
<div v-for="(page, pageIdx) in mini72Pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label-cell"
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
>
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
<div
v-for="(item, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="item ? 'filled' : 'empty'"
>
<template v-if="item">
<img v-if="codetype === 'qr'" class="mini-qr" :src="qrMap[item.assetid] || ''" />
<svg v-else :ref="el => setBarcodeRef(el, item.assetid, 'm')" class="mini-barcode"></svg>
<div class="serial-text">{{ captionMap[item.assetid] }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
import { getSetting } from '@/utils/siteSettings'
import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
resolveCodeText, captionFor,
} from './assetLabel'
const MINI_PER_CELL = 12
const CELLS_PER_PAGE = 6
const route = useRoute()
const assettype = route.params.assettype
const config = TYPE_CONFIG[assettype] || null
const assets = ref([])
const selected = ref([])
const loadingAssets = ref(true)
const format = ref('uline6')
const codetype = ref('qr')
const encodes = ref('assetpage')
const startCell = ref('1')
// assetid -> encoded code text / caption / QR data URL. Barcodes render to refs.
const codeMap = ref({})
const captionMap = ref({})
const qrMap = ref({})
const barcodeRefs = ref({})
const hasLocation = computed(() => hasLocationType(assettype))
function isSelected(asset) {
return selected.value.some(a => a.assetid === asset.assetid)
}
function toggle(asset) {
const idx = selected.value.findIndex(a => a.assetid === asset.assetid)
if (idx > -1) selected.value.splice(idx, 1)
else selected.value.push(asset)
}
function selectAll() { selected.value = [...assets.value] }
function clearSelection() { selected.value = [] }
function titleFor(asset) {
return asset.name || (config ? config.label : 'Asset')
}
const pageCount = computed(() => {
if (selected.value.length === 0) return 0
const skipped = parseInt(startCell.value) - 1
if (format.value === 'uline6') {
return Math.ceil((selected.value.length + skipped) / CELLS_PER_PAGE)
}
const cells = Math.ceil(selected.value.length / MINI_PER_CELL)
return Math.ceil((cells + skipped) / CELLS_PER_PAGE)
})
// ULINE 6-up: each page is 6 cells, each holding one asset (or null).
const uline6Pages = computed(() => {
const pages = []
if (selected.value.length === 0) return pages
const skip = parseInt(startCell.value) - 1
let idx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
const blank = page === 0 && cell < skip
cells.push((!blank && idx < selected.value.length) ? selected.value[idx++] : null)
}
pages.push(cells)
}
return pages
})
// Mini 72-up: each page is 6 cells, each holding up to 12 mini labels.
const mini72Pages = computed(() => {
const pages = []
if (selected.value.length === 0) return pages
const skip = parseInt(startCell.value) - 1
let idx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
const blank = (page === 0 && cell < skip) || idx >= selected.value.length
const items = []
if (!blank) {
for (let mini = 0; mini < MINI_PER_CELL; mini++) {
items.push(idx < selected.value.length ? selected.value[idx++] : null)
}
}
cells.push({ hasContent: !blank, items })
}
pages.push(cells)
}
return pages
})
function setBarcodeRef(el, assetid, prefix) {
if (el) barcodeRefs.value[`${prefix}-${assetid}`] = el
}
// Resolve the encode text + caption for every loaded asset once, then per
// encode-mode change. Kept over the whole list (cheap) so selection is instant.
async function buildCodeMap() {
const codes = {}
const captions = {}
for (const asset of assets.value) {
codes[asset.assetid] = await resolveCodeText(assettype, asset, encodes.value)
captions[asset.assetid] = captionFor(asset, encodes.value)
}
codeMap.value = codes
captionMap.value = captions
}
async function buildQrMap() {
if (codetype.value !== 'qr') return
const images = {}
for (const asset of selected.value) {
const text = codeMap.value[asset.assetid]
if (text) images[asset.assetid] = await renderQrDataUrl(text)
}
qrMap.value = images
}
function renderBarcodes() {
if (codetype.value !== 'barcode') return
const prefix = format.value === 'uline6' ? 'u' : 'm'
const height = format.value === 'uline6' ? 60 : 22
const width = format.value === 'uline6' ? 2 : 1
for (const asset of selected.value) {
const el = barcodeRefs.value[`${prefix}-${asset.assetid}`]
const text = codeMap.value[asset.assetid]
if (!el || !text) continue
try {
JsBarcode(el, text, {
format: 'CODE128', displayValue: false, width, height, margin: 0,
background: 'transparent',
})
} catch (err) {
console.error('Barcode error:', asset.assetnumber, err)
}
}
}
async function refreshCodes() {
await buildCodeMap()
await refreshRender()
}
async function refreshRender() {
await buildQrMap()
await nextTick()
renderBarcodes()
}
onMounted(async () => {
if (!config) { loadingAssets.value = false; return }
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
encodes.value = await resolveDefaultEncodes(assettype)
try {
const response = await config.api.list({ perpage: 500 })
assets.value = response.data.data || []
} catch (err) {
console.error('Error loading assets:', err)
} finally {
loadingAssets.value = false
await refreshCodes()
}
})
watch(encodes, refreshCodes)
watch([selected, format, codetype, startCell], refreshRender, { deep: true })
function print() { window.print() }
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { padding: 20px; }
.controls {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.controls h3 { margin-top: 0; }
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
.control-row select { padding: 6px; font-size: 0.875rem; }
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.asset-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.asset-item:hover { border-color: var(--primary); }
.asset-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.asset-item input { margin-right: 10px; }
.asset-item label { cursor: pointer; flex: 1; }
.asset-item .asset-sub { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 12px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.secondary-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--secondary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.loading-msg, .error-msg { text-align: center; padding: 1.5rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
/* ULINE 6-up cells */
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
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); }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.qr-img { width: 144px; height: 144px; display: block; }
.barcode-svg { width: 2.4in; height: 0.8in; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; color: #000; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; text-align: center; }
/* Mini 72-up cells */
.label-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid var(--primary); }
.label-cell.empty { background: #fafafa; }
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1in);
grid-template-rows: repeat(4, 0.75in);
width: 3in;
height: 3in;
}
.mini-label {
width: 1in;
height: 0.75in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.02in;
border: 1px dotted #ddd;
overflow: hidden;
}
.mini-label.filled { border: 1px solid #999; }
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
.mini-qr { width: 0.48in; height: 0.48in; }
.mini-barcode { max-width: 0.9in; height: 24px; }
.serial-text {
font-size: 6pt;
font-weight: bold;
font-family: monospace;
text-align: center;
margin-top: 1px;
letter-spacing: 0.3px;
}
.empty-cell-text {
color: #ccc;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
/* ULINE S-5627 / 6-up 3in label positions on a letter sheet */
.pos-1, .cell-1 { top: 0.875in; left: 1.1875in; }
.pos-2, .cell-2 { top: 0.875in; left: 4.3125in; }
.pos-3, .cell-3 { top: 4in; left: 1.1875in; }
.pos-4, .cell-4 { top: 4in; left: 4.3125in; }
.pos-5, .cell-5 { top: 7.125in; left: 1.1875in; }
.pos-6, .cell-6 { top: 7.125in; left: 4.3125in; }
@media print {
body, .print-sheet, .label, .label-cell, .mini-label, .qr-img, .mini-qr, .barcode-svg, .mini-barcode {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
.label-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>

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,99 @@
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
// encode" resolution so both surfaces behave identically.
import {
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
} from '../../api'
import { fillUrlTemplate } from '@/utils/qrTarget'
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
// Per asset-type: which api loads it, its detail/list routes, the extension key
// nested in the merged payload, and its qr_target_<type> settings key.
export const TYPE_CONFIG = {
machine: {
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
},
computer: {
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
},
printer: {
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
},
network_device: {
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
},
measuring_tool: {
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
},
}
// Hardcoded fallback default per type when the site setting is unset. Machines
// encode their machine number, measuring tools their inspection location.
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
export function hasLocationType(assettype) {
return assettype === 'measuring_tool'
}
// The default encode mode for a type: the label_default_encodes_<type> site
// setting when valid, else the hardcoded fallback.
export async function resolveDefaultEncodes(assettype) {
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
if (hasLocationType(assettype)) valid.push('location')
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
}
// 'location' degrades to the asset page when the asset has no location code.
export function effectiveEncodes(encodes, asset) {
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
return encodes
}
function tokensFor(asset) {
return {
assetid: asset.assetid || '',
assetnumber: asset.assetnumber || '',
serialnumber: asset.serialnumber || '',
name: asset.name || '',
pluginid: asset.pluginid || '',
locationcode: asset.locationcode || '',
locationname: asset.locationname || '',
}
}
// The string a label's code encodes for one asset. Empty when the chosen field
// has no value (e.g. serial number on an asset with none).
export async function resolveCodeText(assettype, asset, encodes) {
const config = TYPE_CONFIG[assettype]
if (!config || !asset) return ''
const detailId = asset.pluginid
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
switch (effectiveEncodes(encodes, asset)) {
case 'assetnumber': return asset.assetnumber || ''
case 'serialnumber': return asset.serialnumber || ''
case 'location': return asset.locationcode || ''
case 'custom': {
const template = (await getSetting(config.targetKey, '')).trim()
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
}
case 'assetpage':
default: return assetPageUrl
}
}
// Human caption printed under the code.
export function captionFor(asset, encodes) {
if (!asset) return ''
switch (effectiveEncodes(encodes, asset)) {
case 'location': return asset.locationcode || ''
case 'serialnumber': return asset.serialnumber || ''
case 'assetnumber':
default: return asset.assetnumber || ''
}
}

View File

@@ -0,0 +1,133 @@
<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">
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
class="btn btn-secondary btn-sm">Edit</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>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
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
}
})
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,141 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</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;
}
.lowstock-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-light);
cursor: pointer;
}
</style>

View File

@@ -6,6 +6,9 @@
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print QR
</router-link>
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>
@@ -16,6 +19,9 @@
<template v-else-if="printer">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="printer.printer?.imageurl">
<img :src="printer.printer.imageurl" :alt="printer.printer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ displayTitle }}</h1>
@@ -51,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 -->
@@ -120,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>
@@ -164,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>
@@ -255,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

@@ -4,6 +4,7 @@
<h2>Printers</h2>
<div class="header-actions">
<router-link to="/print/printer-qr" class="btn btn-secondary" target="_blank">Batch Print QR</router-link>
<router-link to="/print/asset-label-batch/printer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
</div>
</div>
@@ -43,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>
@@ -54,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"
@@ -89,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)
@@ -134,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

@@ -4,6 +4,8 @@
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
@@ -99,6 +101,17 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const emailColumns = [
{ key: 'printer', label: 'Printer' },
{ key: 'assetnumber', label: 'Asset #' },
{ key: 'location', label: 'Location' },
{ key: 'ipaddress', label: 'IP Address' },
{ key: 'supply', label: 'Supply' },
{ key: 'level', label: 'Level' },
{ key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null)
@@ -119,6 +132,25 @@ const filteredPrinters = computed(() => {
)
})
// One row per supply, honoring the active filter, for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const printer of filteredPrinters.value) {
for (const supply of printer.supplies || []) {
rows.push({
printer: printer.printername || '',
assetnumber: printer.assetnumber || '',
location: printer.location || '',
ipaddress: printer.ipaddress || '',
supply: supply.name || '',
level: supply.level + '%',
status: supply.status || '',
})
}
}
return rows
})
function exportCSV() {
// one row per supply, honoring the active filter
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`

View File

@@ -4,6 +4,8 @@
<h1>Warranty Report</h1>
<div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading" subject="Warranty Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
@@ -53,13 +55,39 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { warrantyApi } from '../../api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const emailColumns = [
{ key: 'bucket', label: 'Status' },
{ key: 'vendor', label: 'Vendor' },
{ key: 'servicelevel', label: 'Service Level' },
{ key: 'enddate', label: 'Ends' },
{ key: 'assets', label: 'Covers' },
]
// Flatten the buckets into one row per warranty for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const b of bucketOrder) {
for (const w of buckets.value[b.key] || []) {
rows.push({
bucket: b.label,
vendor: w.vendor || '',
servicelevel: w.servicelevel || '',
enddate: w.enddate || '',
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
})
}
}
return rows
})
const bucketOrder = [
{ key: 'expired', label: 'Expired', color: '#F44336' },
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
@@ -70,7 +98,7 @@ const bucketOrder = [
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
return (map[a.assettypename] || '/assets/') + a.assetid
}

View File

@@ -0,0 +1,551 @@
<template>
<div>
<div class="page-header">
<h2>API Tokens</h2>
<button class="btn btn-primary" @click="openCreate()">+ New Token</button>
</div>
<div class="card">
<p class="tokens-intro">
Personal access tokens let scripts and integrations authenticate as you
without an hourly-expiring login session. Send the token as
<code>Authorization: Bearer shopdb_pat_...</code>. Ideal for long-running
imports that would otherwise die when the login JWT expires. A token may
be restricted to a subset of your permissions; a restricted token cannot
reach admin-only (role-gated) endpoints or import mode.
</p>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Access</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in myTokens" :key="token.tokenid">
<td>{{ token.name }}</td>
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
<td>{{ formatDate(token.createddate) }}</td>
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
<td>
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
<span v-else class="badge badge-success">Active</span>
</td>
<td class="actions">
<button v-if="token.isactive" class="btn btn-secondary btn-sm"
@click="openEdit(token)">Edit</button>
<button v-if="token.isactive" class="btn btn-danger btn-sm"
@click="confirmRevoke(token)">Revoke</button>
</td>
</tr>
<tr v-if="myTokens.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No tokens yet
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Admin: all tokens across every user -->
<div v-if="isAdmin" class="card admin-tokens">
<h3 class="section-subtitle">All Tokens (admin)</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Owner</th>
<th>Name</th>
<th>Token</th>
<th>Access</th>
<th>Expires</th>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in allTokens" :key="token.tokenid">
<td>{{ token.username || '-' }}</td>
<td>{{ token.name }}</td>
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
<td>
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
<span v-else class="badge badge-success">Active</span>
</td>
<td class="actions">
<button v-if="token.isactive" class="btn btn-danger btn-sm"
@click="confirmRevoke(token)">Revoke</button>
</td>
</tr>
<tr v-if="allTokens.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No tokens
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create modal -->
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
<div class="modal modal-lg">
<div class="modal-header"><h3>New API Token</h3></div>
<form @submit.prevent="createToken">
<div class="modal-body">
<div class="form-group">
<label for="tokenname">Name *</label>
<input id="tokenname" v-model="form.name" type="text" class="form-control"
placeholder="e.g. legacy import runner" required />
</div>
<div class="form-group">
<label for="tokenexpiry">Expiry (optional)</label>
<input id="tokenexpiry" v-model="form.expiresat" type="date" class="form-control" />
<small class="form-hint">Leave blank for a token that never expires.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.restrict" />
Restrict permissions
</label>
<small class="form-hint">
Default is full access (the token acts as you). Restrict to grant
only the checked permissions. A restricted token cannot use
admin-only endpoints or import mode.
</small>
</div>
<div v-if="form.restrict" class="form-group">
<label>Allowed permissions</label>
<div class="permissions-grid">
<div v-for="(perms, category) in availableGrouped" :key="category"
class="permission-category">
<div class="category-header">
<label class="checkbox-label">
<input type="checkbox"
:checked="isCategoryFullySelected(category)"
:indeterminate.prop="isCategoryPartiallySelected(category)"
@change="toggleCategory(category, $event.target.checked)" />
<strong>{{ formatCategory(category) }}</strong>
</label>
</div>
<div class="category-perms">
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
<input type="checkbox" :value="p.name" v-model="form.scopes" />
{{ p.description }}
</label>
</div>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeCreate">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Creating...' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
<!-- Edit modal (scopes) -->
<div v-if="editing" class="modal-overlay" @click.self="closeEdit">
<div class="modal modal-lg">
<div class="modal-header"><h3>Edit token access</h3></div>
<form @submit.prevent="saveEdit">
<div class="modal-body">
<p class="tokens-intro">Editing <strong>{{ editing.name }}</strong>.</p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.restrict" />
Restrict permissions
</label>
<small class="form-hint">
Uncheck for full access (acts as the owner).
</small>
</div>
<div v-if="editForm.restrict" class="form-group">
<label>Allowed permissions</label>
<div class="permissions-grid">
<div v-for="(perms, category) in availableGrouped" :key="category"
class="permission-category">
<div class="category-header">
<label class="checkbox-label">
<input type="checkbox"
:checked="isEditCategoryFullySelected(category)"
:indeterminate.prop="isEditCategoryPartiallySelected(category)"
@change="toggleEditCategory(category, $event.target.checked)" />
<strong>{{ formatCategory(category) }}</strong>
</label>
</div>
<div class="category-perms">
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
<input type="checkbox" :value="p.name" v-model="editForm.scopes" />
{{ p.description }}
</label>
</div>
</div>
</div>
</div>
<div v-if="editError" class="error-message">{{ editError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeEdit">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Secret reveal modal (shown once) -->
<div v-if="newSecret" class="modal-overlay" @click.self="dismissSecret">
<div class="modal">
<div class="modal-header"><h3>Copy your new token</h3></div>
<div class="modal-body">
<p class="secret-warning">
This is the only time the token is shown. Copy it now and store it
somewhere safe. You will not be able to see it again.
</p>
<div class="secret-box">
<code class="secret-value">{{ newSecret }}</code>
<button class="btn btn-secondary btn-sm" @click="copySecret">
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" @click="dismissSecret">Done</button>
</div>
</div>
</div>
<!-- Revoke confirm -->
<div v-if="toRevoke" class="modal-overlay" @click.self="toRevoke = null">
<div class="modal">
<div class="modal-header"><h3>Revoke Token</h3></div>
<div class="modal-body">
<p>Revoke <strong>{{ toRevoke.name }}</strong>? Any script using it will
immediately lose access.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="toRevoke = null">Cancel</button>
<button class="btn btn-danger" @click="revokeToken">Revoke</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { apitokensApi, usersApi, authApi } from '../../api'
import { useAuthStore } from '../../stores/auth'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.isAdmin)
const myTokens = ref([])
const allTokens = ref([])
const loading = ref(true)
// Permission catalog grouped by category, plus the permissions the current
// user actually holds (the create/edit grid never offers more than these).
const permissionsGrouped = ref({})
const myPermissions = ref([])
const showCreate = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', expiresat: '', restrict: false, scopes: [] })
const editing = ref(null)
const editError = ref('')
const editForm = ref({ restrict: false, scopes: [] })
const newSecret = ref('')
const copied = ref(false)
const toRevoke = ref(null)
// Grid limited to permissions the current user holds. Admins see everything.
const availableGrouped = computed(() => {
const held = new Set(myPermissions.value)
const result = {}
for (const [category, perms] of Object.entries(permissionsGrouped.value)) {
const usable = isAdmin.value ? perms : perms.filter(p => held.has(p.name))
if (usable.length) result[category] = usable
}
return result
})
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [tokensRes, permsRes, meRes] = await Promise.all([
apitokensApi.list(),
usersApi.permissions.list(),
authApi.me()
])
myTokens.value = tokensRes.data.data || []
permissionsGrouped.value = permsRes.data.data.grouped || {}
myPermissions.value = meRes.data.data.permissions || []
if (isAdmin.value) {
const all = await apitokensApi.list({ all: true })
allTokens.value = all.data.data || []
}
} catch (err) {
console.error('Error loading API tokens:', err)
} finally {
loading.value = false
}
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleDateString()
}
function formatCategory(category) {
return category.charAt(0).toUpperCase() + category.slice(1)
}
function scopeSummary(token) {
if (!token.scopes) return 'Full access'
const count = token.scopes.length
return count === 1 ? '1 permission' : `${count} permissions`
}
function scopeBadgeClass(token) {
return token.scopes ? 'badge-warning' : 'badge-success'
}
// --- Create grid helpers ---
function isCategoryFullySelected(category) {
const perms = availableGrouped.value[category] || []
return perms.length > 0 && perms.every(p => form.value.scopes.includes(p.name))
}
function isCategoryPartiallySelected(category) {
const perms = availableGrouped.value[category] || []
const selected = perms.filter(p => form.value.scopes.includes(p.name))
return selected.length > 0 && selected.length < perms.length
}
function toggleCategory(category, checked) {
const names = (availableGrouped.value[category] || []).map(p => p.name)
if (checked) {
for (const name of names) {
if (!form.value.scopes.includes(name)) form.value.scopes.push(name)
}
} else {
form.value.scopes = form.value.scopes.filter(n => !names.includes(n))
}
}
// --- Edit grid helpers ---
function isEditCategoryFullySelected(category) {
const perms = availableGrouped.value[category] || []
return perms.length > 0 && perms.every(p => editForm.value.scopes.includes(p.name))
}
function isEditCategoryPartiallySelected(category) {
const perms = availableGrouped.value[category] || []
const selected = perms.filter(p => editForm.value.scopes.includes(p.name))
return selected.length > 0 && selected.length < perms.length
}
function toggleEditCategory(category, checked) {
const names = (availableGrouped.value[category] || []).map(p => p.name)
if (checked) {
for (const name of names) {
if (!editForm.value.scopes.includes(name)) editForm.value.scopes.push(name)
}
} else {
editForm.value.scopes = editForm.value.scopes.filter(n => !names.includes(n))
}
}
function openCreate() {
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
error.value = ''
showCreate.value = true
}
function closeCreate() { showCreate.value = false }
async function createToken() {
error.value = ''
if (form.value.restrict && form.value.scopes.length === 0) {
error.value = 'Select at least one permission, or turn off Restrict permissions.'
return
}
saving.value = true
try {
const payload = { name: form.value.name }
if (form.value.expiresat) payload.expiresat = form.value.expiresat
if (form.value.restrict) payload.scopes = form.value.scopes
const response = await apitokensApi.create(payload)
showCreate.value = false
newSecret.value = response.data.data.secret
copied.value = false
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to create token')
} finally {
saving.value = false
}
}
function openEdit(token) {
editing.value = token
editError.value = ''
editForm.value = {
restrict: !!token.scopes,
scopes: token.scopes ? [...token.scopes] : []
}
}
function closeEdit() { editing.value = null }
async function saveEdit() {
editError.value = ''
if (editForm.value.restrict && editForm.value.scopes.length === 0) {
editError.value = 'Select at least one permission, or turn off Restrict permissions.'
return
}
saving.value = true
try {
const payload = { scopes: editForm.value.restrict ? editForm.value.scopes : null }
await apitokensApi.update(editing.value.tokenid, payload)
editing.value = null
loadData()
} catch (err) {
editError.value = apiError(err, 'Failed to update token')
} finally {
saving.value = false
}
}
async function copySecret() {
try {
await navigator.clipboard.writeText(newSecret.value)
copied.value = true
} catch {
toast.error('Copy failed. Select the token and copy manually.')
}
}
function dismissSecret() { newSecret.value = ''; copied.value = false }
function confirmRevoke(token) { toRevoke.value = token }
async function revokeToken() {
try {
await apitokensApi.remove(toRevoke.value.tokenid)
toRevoke.value = null
loadData()
} catch (err) {
toast.error('Failed to revoke token')
}
}
</script>
<style scoped>
.tokens-intro {
color: var(--text-light);
margin-bottom: 1rem;
}
.tokens-intro code {
background: var(--bg);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.admin-tokens {
margin-top: 1.5rem;
}
.section-subtitle {
margin-bottom: 1rem;
}
.form-hint {
display: block;
color: var(--text-light);
margin-top: 0.25rem;
}
.secret-warning {
color: var(--danger);
margin-bottom: 1rem;
}
.secret-box {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.secret-value {
flex: 1;
word-break: break-all;
font-size: 0.95rem;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.permission-category {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
}
.category-header {
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.category-perms {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.perm-item {
font-size: 0.85rem;
padding: 0.25rem 0;
}
</style>

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