Compare commits
127 Commits
v0.5.0
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cfac33b4 | ||
|
|
8dd1fadeca | ||
|
|
a0b92f7b5e | ||
|
|
6362cef699 | ||
|
|
d99de002bf | ||
|
|
eed947b207 | ||
|
|
f5f67172aa | ||
|
|
97f7dfb0de | ||
|
|
3ba09ac9f3 | ||
|
|
f16d2289ff | ||
|
|
99cac87d9a | ||
|
|
7d7862f4b5 | ||
|
|
6010f01de1 | ||
|
|
69dd6d0abe | ||
|
|
fba2fa05b4 | ||
|
|
9e2544a687 | ||
|
|
760b00f4d1 | ||
|
|
4a74a1a405 | ||
|
|
9c909af66d | ||
|
|
c9641a3c62 | ||
|
|
a736ed541e | ||
|
|
58af5afda2 | ||
|
|
8528617037 | ||
|
|
dd541fba0a | ||
|
|
5f51aa2383 | ||
|
|
a6ebec5118 | ||
|
|
6d843e46e1 | ||
|
|
49d311b56e | ||
|
|
b2dea82ac9 | ||
|
|
2229db4a70 | ||
|
|
c4690da262 | ||
|
|
35690bd169 | ||
|
|
83a4867d1b | ||
|
|
009ac9f9ef | ||
|
|
474f245ae7 | ||
|
|
af9afc15c5 | ||
|
|
99ad6c0ebb | ||
|
|
40a89b360a | ||
|
|
6d79e469fa | ||
|
|
6c4bd20e01 | ||
|
|
b11a6f26d8 | ||
|
|
012718f0fd | ||
|
|
be1ea29403 | ||
|
|
1c6c7ba14b | ||
|
|
9c8b2c9c9e | ||
|
|
cd02cd20f4 | ||
|
|
b99da362b5 | ||
|
|
ea3cca8954 | ||
|
|
d49baeb5fa | ||
|
|
cd353b6432 | ||
|
|
85a6ab8645 | ||
|
|
d7b777a7a0 | ||
|
|
456a44104b | ||
|
|
4e5b4228c1 | ||
|
|
3355436fcd | ||
|
|
810687953f | ||
|
|
3b400d8cc4 | ||
|
|
8dceb8812f | ||
|
|
6e7d51de21 | ||
|
|
e6ed47c533 | ||
|
|
ab0c9a454b | ||
|
|
655fe1087f | ||
|
|
378af32c1d | ||
|
|
3ac41c1556 | ||
|
|
fc1f56fec3 | ||
|
|
796007bcca | ||
|
|
d9d40297b6 | ||
|
|
24e38c6e75 | ||
|
|
7089a61b50 | ||
|
|
0dcd186820 | ||
|
|
d894f054ac | ||
|
|
adc5b7f69e | ||
|
|
7eb6cebeb5 | ||
|
|
d157502d5b | ||
|
|
6dc31c6149 | ||
|
|
d85b33bd68 | ||
|
|
cf2f9c308e | ||
|
|
c88d673d7b | ||
|
|
9dd2aa3cc6 | ||
|
|
1672e349e5 | ||
|
|
f6dcaef4c0 | ||
|
|
b2e1827e5d | ||
|
|
275224822e | ||
|
|
9daf1578a7 | ||
|
|
d187a2c535 | ||
|
|
68b6949f1c | ||
|
|
cced6fe96e | ||
|
|
55b42fed00 | ||
|
|
4b20c2eb56 | ||
|
|
a846587f39 | ||
|
|
7d309aabeb | ||
|
|
1c5128a5c8 | ||
|
|
12da0429b0 | ||
|
|
7dfbe7bf8a | ||
|
|
12175169e4 | ||
|
|
848a8fb34f | ||
|
|
688ff6646d | ||
|
|
da86b3ae0c | ||
|
|
64a5abdb08 | ||
|
|
31f5e07294 | ||
|
|
9fc5aca63b | ||
|
|
1e93b3d570 | ||
|
|
5393846b8d | ||
|
|
7bde765f1a | ||
|
|
b130ef43f3 | ||
|
|
5a192f3100 | ||
|
|
4fd110a33d | ||
|
|
1d21bf0206 | ||
|
|
7dae281993 | ||
|
|
46e50c07ff | ||
|
|
f8e5109255 | ||
|
|
24f67d6ac5 | ||
|
|
9eed3745fc | ||
|
|
e6a99b1bfd | ||
|
|
02d8983195 | ||
|
|
48d3160bc5 | ||
|
|
3c43c8d5c8 | ||
|
|
e6edbd23f8 | ||
|
|
26c280df25 | ||
|
|
dc9fe5834e | ||
|
|
81178b7be3 | ||
|
|
529b9f2fed | ||
|
|
94f852a1c8 | ||
|
|
54c4c808cc | ||
|
|
611f62326d | ||
|
|
2efe17b743 | ||
|
|
22e623c1f6 |
@@ -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
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
# CI for shopdb-flask.
|
||||
#
|
||||
# NOTE: this is a best-effort configuration. Gitea Actions availability on
|
||||
# the host is UNVERIFIED - no runner has been confirmed. Until a runner is
|
||||
# registered and this workflow is observed passing, treat it as config-only.
|
||||
# The Gitea runner executes jobs on the host (act host executor), so
|
||||
# actions/setup-python cannot provision interpreters (no prebuilt binaries
|
||||
# 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
|
||||
|
||||
@@ -22,16 +31,14 @@ jobs:
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
python3 --version
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
- name: Run backend tests
|
||||
run: python -m pytest
|
||||
run: .venv/bin/python -m pytest
|
||||
|
||||
naming:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -55,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
1
.gitignore
vendored
@@ -75,3 +75,4 @@ secrets.yml
|
||||
*_secret
|
||||
*_secrets
|
||||
credentials.json
|
||||
scripts/site_imports/wjf/idmap.json
|
||||
|
||||
411
CHANGELOG.md
411
CHANGELOG.md
@@ -10,6 +10,411 @@ 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
|
||||
|
||||
- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions
|
||||
with type lookup (color-coded), calibration tracking (derived
|
||||
overdue/due-soon/current status), calibration report, and full frontend.
|
||||
Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its
|
||||
construction step by step as the plugin-system tutorial.
|
||||
- CSV export on the Warranty and Toner report pages; per-report filter
|
||||
controls (business unit, asset type, location, application, limit) on the
|
||||
inline core reports; report open-state is URL-backed and deep-linkable.
|
||||
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now
|
||||
carries its own chain with a stamp-only anchor; new plugin schema changes
|
||||
land in `plugins/<name>/migrations/`, never the core chain. Deploys run
|
||||
`flask plugin upgrade-all` after `flask db upgrade`.
|
||||
- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages
|
||||
redirect to the dashboard; new public `GET /api/plugins/enabled`.
|
||||
- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins
|
||||
contribute their own report cards; warranty and toner cards moved off the
|
||||
hardcoded frontend list.
|
||||
- Reports hub grouped by category with a search filter.
|
||||
- Configurable QR label targets: `qr_target_printer` / `qr_target_usb`
|
||||
settings (blank = the asset's own page, else a URL template with
|
||||
placeholders) and a `usb_label_style` barcode/QR toggle for USB
|
||||
mini-labels. New Settings > Printing & Labels section.
|
||||
- Site palette theming: optional `brand_primary_dark_color`,
|
||||
`brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap.
|
||||
- Collector integration guide rewrite: header-only auth reference and a
|
||||
paste-ready GE-Enforce PowerShell reporting function.
|
||||
|
||||
### Changed
|
||||
|
||||
- Equipment -> machines rename (backend). The equipment plugin is now the
|
||||
machines plugin: `/api/equipment` -> `/api/machines`, tables
|
||||
`equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns
|
||||
`equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`,
|
||||
`equipmenttype` -> `machinetype`), permissions `equipment.*` ->
|
||||
`machines.*`, assettype value `equipment` -> `machine`. The legacy core
|
||||
`machinetypes` lookup (it types the vendor MODELS catalog, not machine
|
||||
instances) is renamed to `modeltypes` (`/api/machinetypes` ->
|
||||
`/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to
|
||||
free the name. Data flips migrate assettypes, auditlog entitytype,
|
||||
settings keys (`identifier_*_equipment_enabled` ->
|
||||
`identifier_*_machine_enabled`, `search_equipment_enabled` ->
|
||||
`search_machine_enabled`), and permission rows in place; plugins.json
|
||||
registry entries carry over automatically. Upgrade: run
|
||||
`flask db upgrade` then `flask plugin upgrade-all`.
|
||||
|
||||
- Inter (variable) replaces Roboto, bundled locally - no Google Fonts
|
||||
fetch, so air-gapped installs render correctly. Tables use tabular
|
||||
numerals.
|
||||
- ServiceNow defaults point at the current geaerospaceqa.service-now.com
|
||||
global search (search, incident, and change links).
|
||||
|
||||
### Fixed
|
||||
|
||||
- USB frontend remapped to the actual API shape (`device_id` /
|
||||
`device_desc`): device list, detail, form, label batch, and the employee
|
||||
profile's checked-out/history panels were all reading dead legacy fields.
|
||||
- External-mode `GET /api/usb/checkouts/active` now honors the `badge`
|
||||
filter.
|
||||
- Warranties list page no longer demands login (matches every other list
|
||||
page; reads were already public).
|
||||
- Removed the dead legacy Warranty Status report (always-zero buckets from
|
||||
a retired column); the warranty plugin's report is the real one.
|
||||
- Pruned dead usbApi client methods that had no backend routes.
|
||||
|
||||
## [0.5.0] - 2026-07-10
|
||||
|
||||
First release cut with a version, tag, changelog, and CI. Focused on
|
||||
@@ -36,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).
|
||||
|
||||
@@ -69,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
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -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,19 +42,22 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
|
||||
|
||||
### Active state
|
||||
|
||||
- 206 tests passing, naming/style check green
|
||||
- `__contract_version__` at 0.5.0
|
||||
- 10 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, 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
|
||||
|
||||
- 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.
|
||||
- `measuringtools` plugin (ADR-005). First plugin to be built using the scaffold.
|
||||
- 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 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
|
||||
- (DONE) Frontend plugin hook contract (ADR-010): get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation shipped; generic renderers for panels/overlays land incrementally.
|
||||
- (DONE) Per-plugin Alembic chains (ADR-008): every bundled plugin carries its own chain; no plugin uses db.create_all().
|
||||
- Legacy ASP data import against the renamed schema (unblocked; run via docs/IMPORT-API.md) + a production pilot deployment.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
476
README.md
476
README.md
@@ -1,233 +1,243 @@
|
||||
# ShopDB Flask
|
||||
|
||||
A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Python) and Vue 3. This application manages shop floor machines, PCs, printers, applications, and related infrastructure for manufacturing environments.
|
||||
|
||||
## Overview
|
||||
|
||||
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
|
||||
- **Knowledge Base** - Documentation and troubleshooting guides
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.x with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.6+ database
|
||||
- JWT authentication
|
||||
- Plugin architecture for extensibility
|
||||
|
||||
**Frontend:**
|
||||
- Vue 3 with Composition API
|
||||
- Vue Router for navigation
|
||||
- Pinia for state management
|
||||
- Vite build system
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
shopdb-flask/
|
||||
├── shopdb/ # Flask application
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Validation schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── plugins/ # Plugin system
|
||||
│ └── utils/ # Shared utilities
|
||||
├── frontend/ # Vue 3 application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── router/ # Route definitions
|
||||
│ │ └── stores/ # Pinia stores
|
||||
│ └── public/ # Static assets
|
||||
├── plugins/ # Bundled and external plugins
|
||||
├── migrations/ # Alembic migration chain (flask db upgrade)
|
||||
├── scripts/ # Import and utility scripts
|
||||
└── tests/ # Test suite
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
To maintain consistency with the legacy ShopDB database and codebase, the following naming standards apply:
|
||||
|
||||
### Database
|
||||
|
||||
- **Table names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machines`, `pctypes`, `machinetypes`, `businessunits`
|
||||
- **Column names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
|
||||
- **Foreign keys:** Referenced table name + `id`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
|
||||
- **Boolean columns:** Prefixed with `is` or `has`
|
||||
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `islicenced`
|
||||
|
||||
### Code
|
||||
|
||||
- **Python variables:** Follow database naming where applicable (lowercase, no underscores for model fields)
|
||||
- **JavaScript variables:** camelCase for local variables, but match API field names from backend
|
||||
- **Vue components:** PascalCase for component names
|
||||
- **CSS classes:** Lowercase with dashes for multi-word classes
|
||||
|
||||
### API
|
||||
|
||||
- **Endpoints:** Lowercase, plural nouns
|
||||
- Examples: `/api/machines`, `/api/pctypes`, `/api/locations`
|
||||
- **Query parameters:** Lowercase, single word
|
||||
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
- No emojis in code, comments, documentation, or UI
|
||||
- Keep UI functional and professional
|
||||
- Dark theme is the default
|
||||
- Consistent table layouts across all list views
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
|
||||
ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the
|
||||
test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory
|
||||
SQLite). Do not run dev or production against SQLite.
|
||||
|
||||
### Distribution
|
||||
|
||||
The application is distributed internally through the GE Aerospace Gitea. Clone
|
||||
it from there; there is no public package or image registry.
|
||||
|
||||
### Fast path (Docker)
|
||||
|
||||
The Docker image builds the Vue frontend and serves it from the API container,
|
||||
so a container deploy needs no separate Node build step.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS,
|
||||
# and the MYSQL_* passwords (see docs/CONFIG.md for every variable).
|
||||
|
||||
docker compose up -d --build
|
||||
|
||||
# Create the schema and seed the platform data (idempotent, safe to re-run):
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
docker compose exec api flask seed reference-data
|
||||
```
|
||||
|
||||
Then browse to the site and complete the first-run setup wizard at `/setup`
|
||||
(it creates the first admin account and captures site identity). To create the
|
||||
admin headlessly instead of using the wizard:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask seed admin --username admin --email admin@facility.example.com
|
||||
```
|
||||
|
||||
### Manual path (venv + Node)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and secrets.
|
||||
|
||||
flask db upgrade
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
flask seed reference-data
|
||||
flask run
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # dev server on :5173
|
||||
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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (`.env`):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | MySQL connection string |
|
||||
| `SECRET_KEY` | Flask secret key |
|
||||
| `JWT_SECRET_KEY` | JWT signing key |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRES` | Access token TTL (seconds) |
|
||||
| `LOG_LEVEL` | Logging verbosity |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The REST API follows standard conventions:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/machines` | List machines (filterable by type) |
|
||||
| GET | `/api/machines/:id` | Get machine details |
|
||||
| POST | `/api/machines` | Create machine |
|
||||
| PUT | `/api/machines/:id` | Update machine |
|
||||
| DELETE | `/api/machines/:id` | Soft delete machine |
|
||||
|
||||
Query parameters for list endpoints:
|
||||
- `page` - Page number (default: 1)
|
||||
- `per_page` - Items per page (default: 25)
|
||||
- `sort` - Sort field
|
||||
- `order` - Sort direction (asc/desc)
|
||||
- `search` - Search term
|
||||
- `type` - Filter by machine type (pc, printer, equipment)
|
||||
|
||||
## 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:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations
|
||||
- **employees** - Employee directory
|
||||
- **equipment** - CNC, CMM, and inspection equipment
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices
|
||||
- **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
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
This project replicates functionality from the classic ASP/VBScript ShopDB site. Key mappings:
|
||||
|
||||
| Legacy | Modern |
|
||||
|--------|--------|
|
||||
| ASP/VBScript | Flask/Python |
|
||||
| Classic ADO | SQLAlchemy |
|
||||
| Server-side HTML | Vue 3 SPA |
|
||||
| Session auth | JWT tokens |
|
||||
|
||||
## License
|
||||
|
||||
Internal use only.
|
||||
# ShopDB Flask
|
||||
|
||||
A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Python) and Vue 3. This application manages shop floor machines, PCs, printers, applications, and related infrastructure for manufacturing environments.
|
||||
|
||||
## Overview
|
||||
|
||||
ShopDB tracks and manages:
|
||||
- **Machines** - CNC equipment, CMMs, inspection systems, etc.
|
||||
- **PCs** - Shopfloor computers, engineering workstations
|
||||
- **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.12 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
- JWT authentication
|
||||
- Plugin architecture for extensibility
|
||||
|
||||
**Frontend:**
|
||||
- Vue 3 with Composition API
|
||||
- Vue Router for navigation
|
||||
- Pinia for state management
|
||||
- Vite build system
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
shopdb-flask/
|
||||
├── shopdb/ # Flask application
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Validation schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── plugins/ # Plugin system
|
||||
│ └── utils/ # Shared utilities
|
||||
├── frontend/ # Vue 3 application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── router/ # Route definitions
|
||||
│ │ └── stores/ # Pinia stores
|
||||
│ └── public/ # Static assets
|
||||
├── plugins/ # Bundled and external plugins
|
||||
├── migrations/ # Alembic migration chain (flask db upgrade)
|
||||
├── scripts/ # Import and utility scripts
|
||||
└── tests/ # Test suite
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
To maintain consistency with the legacy ShopDB database and codebase, the following naming standards apply:
|
||||
|
||||
### Database
|
||||
|
||||
- **Table names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `assets`, `computers`, `printers`, `businessunits`
|
||||
- **Column names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate`
|
||||
- **Foreign keys:** Referenced table name + `id`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid`
|
||||
- **Boolean columns:** Prefixed with `is` or `has`
|
||||
- Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced`
|
||||
|
||||
### Code
|
||||
|
||||
- **Python variables:** Follow database naming where applicable (lowercase, no underscores for model fields)
|
||||
- **JavaScript variables:** camelCase for local variables, but match API field names from backend
|
||||
- **Vue components:** PascalCase for component names
|
||||
- **CSS classes:** Lowercase with dashes for multi-word classes
|
||||
|
||||
### API
|
||||
|
||||
- **Endpoints:** Lowercase, plural nouns
|
||||
- Examples: `/api/machines`, `/api/computers`, `/api/locations`
|
||||
- **Query parameters:** Lowercase, single word
|
||||
- Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer`
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
- No emojis in code, comments, documentation, or UI
|
||||
- Keep UI functional and professional
|
||||
- Dark theme is the default
|
||||
- Consistent table layouts across all list views
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
|
||||
ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the
|
||||
test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory
|
||||
SQLite). Do not run dev or production against SQLite.
|
||||
|
||||
### Distribution
|
||||
|
||||
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)
|
||||
|
||||
The Docker image builds the Vue frontend and serves it from the API container,
|
||||
so a container deploy needs no separate Node build step.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS,
|
||||
# and the MYSQL_* passwords (see docs/CONFIG.md for every variable).
|
||||
|
||||
docker compose up -d --build
|
||||
|
||||
# Create the schema and seed the platform data (idempotent, safe to re-run):
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
docker compose exec api flask seed reference-data
|
||||
```
|
||||
|
||||
Then browse to the site and complete the first-run setup wizard at `/setup`
|
||||
(it creates the first admin account and captures site identity). To create the
|
||||
admin headlessly instead of using the wizard:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask seed admin --username admin --email admin@facility.example.com
|
||||
```
|
||||
|
||||
### Manual path (venv + Node)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and secrets.
|
||||
|
||||
flask db upgrade
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
flask seed reference-data
|
||||
flask run
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # dev server on :5173
|
||||
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 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).
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (`.env`):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | MySQL connection string |
|
||||
| `SECRET_KEY` | Flask secret key |
|
||||
| `JWT_SECRET_KEY` | JWT signing key |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRES` | Access token TTL (seconds) |
|
||||
| `LOG_LEVEL` | Logging verbosity |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The REST API follows standard conventions:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/machines` | List machines (filterable by type) |
|
||||
| GET | `/api/machines/:id` | Get machine details |
|
||||
| POST | `/api/machines` | Create machine |
|
||||
| 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)
|
||||
- `perpage` - Items per page
|
||||
- `sort` - Sort field
|
||||
- `dir` - Sort direction (asc/desc)
|
||||
- `search` - Search term
|
||||
- `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 twelve plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
|
||||
- **machines** - CNC, CMM, and other shop-floor machines
|
||||
- **measuringtools** - Gage-lab instruments with calibration tracking
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices 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** - Warranty records with Dell lookups
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
This project replicates functionality from the classic ASP/VBScript ShopDB site. Key mappings:
|
||||
|
||||
| Legacy | Modern |
|
||||
|--------|--------|
|
||||
| ASP/VBScript | Flask/Python |
|
||||
| Classic ADO | SQLAlchemy |
|
||||
| Server-side HTML | Vue 3 SPA |
|
||||
| Session auth | JWT tokens |
|
||||
|
||||
## License
|
||||
|
||||
Internal use only.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -1,67 +1,258 @@
|
||||
# Collector integration (PC auto-update)
|
||||
|
||||
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
|
||||
classic ASP `api.asp?action=updateCompleteAsset` path.
|
||||
classic ASP `api.asp?action=updateCompleteAsset` path. The generic collector
|
||||
contract is defined in ADR-006; this doc is the operational reference for wiring
|
||||
a real caller (the GE-Enforce fleet agent) to it.
|
||||
|
||||
## Endpoint
|
||||
- Server code: `shopdb/core/api/collector.py`
|
||||
- Computers schema + upsert: `plugins/computers/plugin.py`
|
||||
(`get_collector_schema` / `apply_collector_payload`)
|
||||
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
|
||||
|
||||
`POST /api/collector/computers`
|
||||
---
|
||||
|
||||
Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTERS`
|
||||
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
|
||||
`hostname`.
|
||||
## 1. How it works now
|
||||
|
||||
> **Breaking change:** the API key must be sent in the `X-API-Key` header. The
|
||||
> old `?api_key=<key>` querystring fallback has been removed, on every collector
|
||||
> endpoint (`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`,
|
||||
> `/status`). Querystring keys leak into access logs and proxy history. Update
|
||||
> any caller still passing `api_key` in the URL to use the header instead.
|
||||
### Auth model (header-only, env keys, fail-closed)
|
||||
|
||||
## Payload (project naming convention: lowercase concatenated)
|
||||
- The API key travels in the `X-API-Key` request header. Nothing else is
|
||||
accepted. The old `?api_key=<key>` querystring fallback has been removed on
|
||||
every collector endpoint (see the breaking-change section below).
|
||||
- Keys come from environment variables (never the database, never a file the app
|
||||
serves):
|
||||
- `COLLECTOR_API_KEY_<PLUGINNAME>` - per-plugin override, uppercased plugin
|
||||
name (e.g. `COLLECTOR_API_KEY_COMPUTERS`).
|
||||
- `COLLECTOR_API_KEY` - shared fallback used when no per-plugin key is set.
|
||||
- Resolution order per request: per-plugin key first, then the shared key
|
||||
(`_plugin_api_key` in `collector.py`).
|
||||
- Fail-closed: if neither variable is set server-side, the endpoint returns
|
||||
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.
|
||||
|
||||
| Field | Meaning | Flask target |
|
||||
|-------|---------|--------------|
|
||||
| `hostname` (required) | identity | `Computer.hostname` |
|
||||
| `machinenumber` | machine number | `Asset.assetnumber` (skips `9999` placeholder, falls back to hostname) |
|
||||
| `pctype` | imaging pc-type | `Computer.computertypeid` via the configurable mapping |
|
||||
| `pcsubtype` | finer class | accepted, not yet stored (warning) |
|
||||
| `serialnumber` | BIOS serial | `Asset.serialnumber` |
|
||||
| `loggedinuser` | current user | `Computer.loggedinuser` |
|
||||
| `lastboottime` | ISO datetime | `Computer.lastboottime` |
|
||||
| `lastcheckin` | ISO datetime | accepted (heartbeat) |
|
||||
| `ipaddress` | primary IP | primary `Communication` |
|
||||
| `vendorname` | manufacturer | `Computer.vendorid` (created if missing) |
|
||||
| `modelnumber` | model | `Computer.modelnumberid` (created if missing) |
|
||||
| `osname` | OS caption | `Computer.osid` (looked up; warned if unknown) |
|
||||
| `installedsoftware` | `[{name, version}]` | `ComputerInstalledApp` (known apps only) |
|
||||
### Managed collector tokens (recommended)
|
||||
|
||||
Response: `{status, action: created|updated, assetid, identityvalue, warnings[]}`.
|
||||
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.
|
||||
|
||||
## Source of truth on the PC (current method, may change)
|
||||
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.
|
||||
|
||||
The data already exists at image time and at runtime:
|
||||
Both wire transports are accepted (send whichever is convenient; GE-Enforce
|
||||
sends `X-API-Key` today, so that stays ergonomic):
|
||||
|
||||
- **machine number**: registry `HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\Dnc\General\MachineNo`
|
||||
FIRST (authoritative post Update-MachineNumber; ignore the `9999` placeholder),
|
||||
then `C:\Enrollment\machine-number.txt` as fallback. This is exactly what
|
||||
GE-Enforce.ps1 already does.
|
||||
- **pc-type / pc-subtype**: `C:\Enrollment\pc-type.txt` / `pc-subtype.txt`
|
||||
(the `gea-shopfloor-*` taxonomy).
|
||||
- **serial / vendor / model / os / user / boot**: live WMI on the PC.
|
||||
```
|
||||
POST /api/collector/computers
|
||||
X-API-Key: shopdb_pat_<40 hex>
|
||||
```
|
||||
or
|
||||
```
|
||||
POST /api/collector/computers
|
||||
Authorization: Bearer shopdb_pat_<40 hex>
|
||||
```
|
||||
|
||||
GE-Enforce currently writes a status JSON to the SFLD share rather than POSTing.
|
||||
Whatever transport is used (a relay reading those status files, or a direct POST
|
||||
later), map its field names to the table above.
|
||||
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.
|
||||
|
||||
## pc-type mapping (configurable)
|
||||
#### How to mint one (admin flow)
|
||||
|
||||
`pctype` (e.g. `gea-shopfloor-cmm`) is mapped to a flask Computer Type through
|
||||
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
|
||||
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>`
|
||||
|
||||
One dynamic route serves every enabled plugin that returns a collector schema.
|
||||
For the PC fleet that is `POST /api/collector/computers`.
|
||||
|
||||
Request flow inside `generic_collect`:
|
||||
|
||||
1. Look up the plugin's schema. Unknown / disabled plugin -> HTTP 404
|
||||
`No collector registered for plugin <plugin>`.
|
||||
2. Resolve and check the API key (per-plugin then shared, as above).
|
||||
3. Parse the JSON body. Missing or non-object body -> HTTP 400 `No data provided`.
|
||||
4. Identity-field resolution: the schema names an `identityfield`
|
||||
(`hostname` for computers). If that field is missing or blank in the payload,
|
||||
HTTP 400 `<identityfield> is required`.
|
||||
5. Idempotent upsert: the plugin's `apply_collector_payload` finds the existing
|
||||
asset by identity and updates it, or inserts a new one. Same identity on a
|
||||
later submission updates the same row - re-imaging a PC does not duplicate it,
|
||||
and existing asset relationships are preserved.
|
||||
6. Audit logging: every accepted submission writes an `AuditLog` row
|
||||
(`action` = created/updated, entity `Collector`, `details = collector:<plugin>
|
||||
action=<action>`), then commits.
|
||||
|
||||
Response body (HTTP 200), wrapped in the standard envelope
|
||||
`{status, data, message, meta}`, with the collector result under `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"action": "created",
|
||||
"assetid": 12345,
|
||||
"identityvalue": "WJRP2335",
|
||||
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
|
||||
},
|
||||
"message": "computers collector created",
|
||||
"meta": { "timestamp": "...", "requestid": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`action` is `created`, `updated`, or `noop`. `warnings` is a list of soft
|
||||
problems (unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that
|
||||
did NOT fail the request - the row was still written.
|
||||
|
||||
### Error responses
|
||||
|
||||
Errors use the same envelope with `status: "error"` and the detail under
|
||||
`data.error`:
|
||||
|
||||
```json
|
||||
{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } }
|
||||
```
|
||||
|
||||
The plugin can raise `ValueError` for controlled validation failures; its message
|
||||
is returned verbatim (HTTP 400). Any other exception is caught, logged
|
||||
server-side, the transaction is rolled back, and the caller gets a generic
|
||||
HTTP 500 `Internal error processing collector payload` with no internal detail
|
||||
(by design - check the server log to see what actually failed).
|
||||
|
||||
### Schema discovery: `GET /api/collector/_schemas`
|
||||
|
||||
Returns the collector schema for every enabled plugin. This route is
|
||||
JWT-protected (an interactive/admin token), NOT API-key protected - it is for
|
||||
humans and tooling introspecting what payloads are accepted, not for the
|
||||
headless collectors themselves.
|
||||
|
||||
### Legacy endpoints (computers-only, predate ADR-006)
|
||||
|
||||
These still exist for the older PowerShell callers and all require the same
|
||||
`X-API-Key` header:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /api/collector/pc` | Update one PC matched by `hostname` (lastboot, user, serial). |
|
||||
| `POST /api/collector/apps` | Update installed apps for a PC (known apps only). |
|
||||
| `POST /api/collector/heartbeat` | Record check-in for one or many hostnames. |
|
||||
| `POST /api/collector/bulk` | Update many PCs in one call. |
|
||||
| `GET /api/collector/status` | Liveness + endpoint list. |
|
||||
|
||||
New integrations should target `POST /api/collector/computers`, not these. Per
|
||||
ADR-006 the legacy `/pc` path is deprecated and slated for removal before v1.0.
|
||||
|
||||
### Computers plugin field mapping
|
||||
|
||||
Payload naming follows the project convention (lowercase concatenated). The
|
||||
identity field is `hostname`. All other fields are optional; an omitted field
|
||||
leaves the existing value untouched (patch-style), so a bare report never blanks
|
||||
a column.
|
||||
|
||||
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|
||||
|---|---|---|
|
||||
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
|
||||
| `machinenumber` | string | Business tag -> `Asset.assetnumber`. The placeholder `9999` and empty string are skipped; when skipped a new PC falls back to `assetnumber = hostname`. On an existing PC a real value updates `assetnumber`. |
|
||||
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
|
||||
| `pcsubtype` | string | Accepted but not stored yet -> warning. |
|
||||
| `serialnumber` | string | `Asset.serialnumber`. |
|
||||
| `loggedinuser` | string | `Computer.loggedinuser`. (`currentuser` is also accepted as a legacy alias.) |
|
||||
| `lastboottime` | ISO-8601 datetime | `Computer.lastboottime`. Unparseable -> warning. |
|
||||
| `lastcheckin` | ISO-8601 datetime | Accepted (heartbeat semantics). `lastreporteddate` is set server-side on every call regardless. |
|
||||
| `ipaddress` | string | Primary `Communication` row (`isprimary=True`, type `IP`). Updated in place or created. |
|
||||
| `vendorname` | string | `Computer.vendorid`. Vendor row auto-created if missing (free vocab). |
|
||||
| `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 > 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 are recorded as a warning,
|
||||
not an error.
|
||||
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
|
||||
failure.
|
||||
|
||||
## Classic api.asp field mapping (for migrating the PowerShell scripts)
|
||||
### Classic api.asp field mapping (for porting the PowerShell reporter)
|
||||
|
||||
The fleet's classic-ASP reporter posts form fields to
|
||||
`api.asp?action=updateCompleteAsset`. Map them to the collector JSON as follows:
|
||||
|
||||
| Classic `updateCompleteAsset` form field | Collector field |
|
||||
|---|---|
|
||||
@@ -76,5 +267,396 @@ not an error.
|
||||
| `osVersion` | `osname` |
|
||||
| `installedApps` | `installedsoftware` |
|
||||
|
||||
Not carried over (no current home): warranty fields, DNC config, multi-NIC
|
||||
detail beyond the primary IP, VNC/WinRM flags.
|
||||
Not carried over (no current home in the computers schema): warranty fields, DNC
|
||||
config, multi-NIC detail beyond the single primary IP, VNC/WinRM flags. The
|
||||
classic reporter posts a full `networkInterfaces` array; the collector accepts
|
||||
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
|
||||
the PowerShell below).
|
||||
|
||||
---
|
||||
|
||||
## 2. Breaking change: querystring `api_key` removed
|
||||
|
||||
The API key must now be sent in the `X-API-Key` header. The `?api_key=<key>`
|
||||
querystring form has been removed on every collector endpoint
|
||||
(`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`).
|
||||
Querystring keys leak into web-server access logs, proxy history, and browser
|
||||
history. Header-only keeps the secret out of those logs.
|
||||
|
||||
Before (no longer works - the key is ignored and the request is rejected 401):
|
||||
|
||||
```
|
||||
POST /api/collector/computers?api_key=SECRET
|
||||
Content-Type: application/json
|
||||
|
||||
{ "hostname": "WJRP2335", "machinenumber": "2335" }
|
||||
```
|
||||
|
||||
After (correct):
|
||||
|
||||
```
|
||||
POST /api/collector/computers
|
||||
X-API-Key: SECRET
|
||||
Content-Type: application/json
|
||||
|
||||
{ "hostname": "WJRP2335", "machinenumber": "2335" }
|
||||
```
|
||||
|
||||
PowerShell before/after:
|
||||
|
||||
```powershell
|
||||
# BEFORE (broken)
|
||||
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers?api_key=$key" `
|
||||
-Method Post -Body $json -ContentType 'application/json'
|
||||
|
||||
# AFTER (correct)
|
||||
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers" `
|
||||
-Method Post -Body $json -ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $key }
|
||||
```
|
||||
|
||||
Any caller still putting `api_key` in the URL must move it to the header.
|
||||
|
||||
---
|
||||
|
||||
## 3. GE-Enforce implementation guide
|
||||
|
||||
GE-Enforce is the SYSTEM-context fleet agent that runs every enforcement cycle on
|
||||
each shopfloor PC (scheduled task, at-logon + periodic). It lives OUTSIDE this
|
||||
repo. The facts below are drawn from the real scripts so the payload matches what
|
||||
the PC already knows:
|
||||
|
||||
- `playbook/shopfloor-setup/common/GE-Enforce.ps1` - the enforcement pass. At the
|
||||
end of each run it writes a status JSON to
|
||||
`<share>\_outputs\logs\<hostname>\status.json` (interim transport; the fleet
|
||||
dashboard reads those files). It logs via `Write-EnforceLog` to
|
||||
`C:\Logs\Shopfloor\enforce-YYYYMMDD.log`. Its machine-number resolution is:
|
||||
registry `HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General` then
|
||||
`HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General`, property `MachineNo`,
|
||||
skipping the `9999` placeholder, then `C:\Enrollment\machine-number.txt` as
|
||||
fallback. hostname is `[System.Environment]::MachineName` (live NetBIOS name,
|
||||
not `$env:COMPUTERNAME`, which goes stale after a post-image rename).
|
||||
- `playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1` -
|
||||
the existing HTTP reporter that POSTs to the classic ASP
|
||||
`api.asp?action=updateCompleteAsset`. It runs every cycle as a `Type=PS1`
|
||||
manifest entry (DetectionMethod `Always`) under the SYSTEM task, logs to
|
||||
`C:\Logs\Shopfloor\report-asset-YYYYMMDD.log`, always exits 0, and reads the
|
||||
same machine number (reg, then `cmm\cmmid.txt`, then `machine-number.txt`) plus
|
||||
BIOS serial, OS caption, make/model, logged-in user, and corp-NIC IP from WMI.
|
||||
The function below is the direct-to-flask analogue of this reporter.
|
||||
- `playbook/shopfloor-setup/Shopfloor/lib/Update-MachineNumber.ps1` - keeps the
|
||||
reg `MachineNo` and `C:\Enrollment\machine-number.txt` in sync when a tech
|
||||
reassigns a bay, so both sources agree.
|
||||
|
||||
### Paste-ready reporter
|
||||
|
||||
Drop this in as a `Type=PS1` / DetectionMethod `Always` manifest entry (mirroring
|
||||
`Report-AssetToShopDB.ps1`), or dot-source `Send-ShopdbCollectorReport` from
|
||||
GE-Enforce.ps1 and call it at the end of the pass. It reads the machine number
|
||||
exactly the way GE-Enforce already does, builds a payload matching the computers
|
||||
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.
|
||||
# Runs as SYSTEM from the GE-Enforce cycle. Always exits without throwing;
|
||||
# failures are logged, never fatal.
|
||||
|
||||
function Get-ShopdbCollectorApiKey {
|
||||
# NEVER hardcode the key in this script (it lives on the share). Read it
|
||||
# from a per-site value the SYSTEM/machine account can already reach.
|
||||
# Preferred: a field in the site-config.json GE-Enforce already parses.
|
||||
# Fallback: a machine-local file staged at imaging, ACL'd to SYSTEM.
|
||||
$key = ''
|
||||
$siteCfg = 'C:\Enrollment\site-config.json'
|
||||
if (Test-Path -LiteralPath $siteCfg) {
|
||||
try {
|
||||
$cfg = Get-Content -LiteralPath $siteCfg -Raw | ConvertFrom-Json
|
||||
if ($cfg.collectorApiKey) { $key = "$($cfg.collectorApiKey)".Trim() }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $key) {
|
||||
$keyFile = 'C:\Enrollment\collector-api-key.txt'
|
||||
if (Test-Path -LiteralPath $keyFile) {
|
||||
try { $key = (Get-Content -LiteralPath $keyFile -First 1 -ErrorAction Stop).Trim() } catch {}
|
||||
}
|
||||
}
|
||||
return $key
|
||||
}
|
||||
|
||||
function Get-ShopdbMachineNumber {
|
||||
# Same resolution GE-Enforce.ps1 / Report-AssetToShopDB.ps1 use:
|
||||
# eDNC registry (skip 9999 placeholder) then C:\Enrollment\machine-number.txt.
|
||||
$machineNumber = ''
|
||||
foreach ($rp in @(
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
|
||||
)) {
|
||||
if ($machineNumber) { break }
|
||||
if (Test-Path $rp) {
|
||||
try {
|
||||
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
|
||||
if ($v -and "$v".Trim() -ne '9999') { $machineNumber = "$v".Trim() }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
|
||||
try {
|
||||
$v = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction Stop).Trim()
|
||||
if ($v -and $v -ne '9999') { $machineNumber = $v }
|
||||
} catch {}
|
||||
}
|
||||
return $machineNumber
|
||||
}
|
||||
|
||||
function Get-ShopdbCorpIPv4 {
|
||||
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as
|
||||
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
|
||||
$allowedRanges = @(
|
||||
@{ Network = '10.134.48.0'; PrefixLen = 23 },
|
||||
@{ Network = '10.48.249.0'; PrefixLen = 26 }
|
||||
)
|
||||
function ConvertTo-Uint32([string]$ip) {
|
||||
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
|
||||
[Array]::Reverse($bytes)
|
||||
return [BitConverter]::ToUInt32($bytes, 0)
|
||||
}
|
||||
try {
|
||||
$ips = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
||||
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
||||
foreach ($ipo in $ips) {
|
||||
$ipInt = ConvertTo-Uint32 $ipo.IPAddress
|
||||
foreach ($r in $allowedRanges) {
|
||||
$netInt = ConvertTo-Uint32 $r.Network
|
||||
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
|
||||
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $ipo.IPAddress }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Send-ShopdbCollectorReport {
|
||||
param(
|
||||
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net',
|
||||
[string]$ApiKey = (Get-ShopdbCollectorApiKey),
|
||||
[int]$TimeoutSec = 30,
|
||||
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||
)
|
||||
|
||||
$logDir = Split-Path -Parent $LogFile
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
function Write-CollectorLog([string]$msg) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
"$ts $msg" | Tee-Object -FilePath $LogFile -Append | Out-Null
|
||||
}
|
||||
|
||||
Write-CollectorLog '=== Report to shopdb-flask collector ==='
|
||||
|
||||
if (-not $ApiKey) {
|
||||
Write-CollectorLog 'ERROR no collector API key (site-config.json / collector-api-key.txt) - skipping.'
|
||||
return
|
||||
}
|
||||
|
||||
# PowerShell 5.1 does not always negotiate TLS 1.2 by default.
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||
|
||||
# --- Gather identity (matches the computers collector schema) ---
|
||||
$hostname = [System.Environment]::MachineName
|
||||
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
|
||||
|
||||
$machineNumber = Get-ShopdbMachineNumber
|
||||
$ipAddress = Get-ShopdbCorpIPv4
|
||||
|
||||
$serialNumber = ''
|
||||
try {
|
||||
$serialNumber = "$((Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber)".Trim()
|
||||
} catch { Write-CollectorLog "WARN BIOS serial read failed: $($_.Exception.Message)" }
|
||||
|
||||
$vendorName = ''; $modelNumber = ''; $loggedInUser = ''
|
||||
try {
|
||||
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||
$vendorName = "$($cs.Manufacturer)".Trim()
|
||||
$modelNumber = "$($cs.Model)".Trim()
|
||||
# UserName is COMPUTERNAME\user or DOMAIN\user; keep the bare username.
|
||||
if ($cs.UserName) { $loggedInUser = ($cs.UserName -split '\\')[-1].Trim() }
|
||||
} catch { Write-CollectorLog "WARN ComputerSystem read failed: $($_.Exception.Message)" }
|
||||
|
||||
$osName = ''; $lastBootTime = ''
|
||||
try {
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||
$osName = "$($os.Caption)".Trim()
|
||||
$dv = ''
|
||||
try { $dv = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion } catch {}
|
||||
if ($dv) { $osName += " $dv" }
|
||||
if ($os.BuildNumber) { $osName += " (build $($os.BuildNumber))" }
|
||||
$osName = $osName.Trim()
|
||||
# ISO-8601 so the server's datetime parse accepts it.
|
||||
try { $lastBootTime = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch {}
|
||||
} catch { Write-CollectorLog "WARN OS read failed: $($_.Exception.Message)" }
|
||||
|
||||
$pcType = ''
|
||||
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
|
||||
try { $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
|
||||
}
|
||||
$pcSubType = ''
|
||||
if (Test-Path -LiteralPath 'C:\Enrollment\pc-subtype.txt') {
|
||||
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 }
|
||||
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
|
||||
if ($loggedInUser) { $payload['loggedinuser'] = $loggedInUser }
|
||||
if ($lastBootTime) { $payload['lastboottime'] = $lastBootTime }
|
||||
if ($ipAddress) { $payload['ipaddress'] = $ipAddress }
|
||||
if ($vendorName) { $payload['vendorname'] = $vendorName }
|
||||
if ($modelNumber) { $payload['modelnumber'] = $modelNumber }
|
||||
if ($osName) { $payload['osname'] = $osName }
|
||||
$payload['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
# installedsoftware is optional; add an array of @{ name=..; version=.. }
|
||||
# here if the manifest introspection is wired to real Application names.
|
||||
|
||||
$uri = "https://$SiteHost/api/collector/computers"
|
||||
$json = $payload | ConvertTo-Json -Depth 5
|
||||
$headers = @{ 'X-API-Key' = $ApiKey }
|
||||
|
||||
Write-CollectorLog ("POST {0} host={1} machineNo={2} pcType={3} serial={4} ip={5}" -f `
|
||||
$uri, $hostname, $machineNumber, $pcType, $serialNumber, $ipAddress)
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri $uri -Method Post -Body $json `
|
||||
-ContentType 'application/json' -Headers $headers `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
$d = $resp.data
|
||||
Write-CollectorLog ("OK action={0} assetid={1} identity={2} warnings={3}" -f `
|
||||
$d.action, $d.assetid, $d.identityvalue, ((@($d.warnings)) -join '; '))
|
||||
} catch {
|
||||
$code = $null
|
||||
try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch {}
|
||||
Write-CollectorLog "ERROR POST failed (http=$code): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry point when run as a standalone manifest PS1 entry:
|
||||
Send-ShopdbCollectorReport
|
||||
```
|
||||
|
||||
### Where the call slots into the GE-Enforce run
|
||||
|
||||
Put the POST at the END of the enforcement pass, after the enforcement scopes are
|
||||
processed and the PC identity is settled. Two placements, pick one:
|
||||
|
||||
- Preferred - a sibling manifest entry, mirroring `Report-AssetToShopDB.ps1`:
|
||||
add `Send-ShopdbCollectorReport.ps1` as a `Type=PS1`, DetectionMethod `Always`
|
||||
manifest entry in `common\manifest.json`. It runs every cycle under the SYSTEM
|
||||
task, keeps GE-Enforce.ps1 untouched, and gets its own log file. This matches
|
||||
the established pattern the classic reporter already uses.
|
||||
- Alternative - inline: dot-source the function and call
|
||||
`Send-ShopdbCollectorReport` inside GE-Enforce.ps1's status-write-back `try`
|
||||
block (right after `status.json` is written, near the end of the pass). Use
|
||||
this only if you want the HTTP push to share GE-Enforce's own log and lifecycle.
|
||||
|
||||
Either way, run it ALONGSIDE the existing `status.json` share write (and
|
||||
alongside the classic `Report-AssetToShopDB.ps1`) during rollout - do not remove
|
||||
the share write until the HTTP path is proven.
|
||||
|
||||
### Delivering the API key to clients
|
||||
|
||||
Do NOT hardcode the key in the script - the script itself lives on the SFLD
|
||||
share, so a literal key there is effectively published to everyone with share
|
||||
read. Two viable options:
|
||||
|
||||
1. Per-site value the machine account already reads. GE-Enforce already parses
|
||||
`C:\Enrollment\site-config.json` (its local copy of the per-site config on the
|
||||
share). Add one field, `collectorApiKey`, populated per site. The script reads
|
||||
it with the machine/SYSTEM identity it already runs as. Rotation = update the
|
||||
one config value at the site, no re-image, no script redeploy.
|
||||
2. Baked at imaging into a machine-local file (`C:\Enrollment\collector-api-key.txt`)
|
||||
or an HKLM value, ACL'd to SYSTEM/Administrators, written by the enrollment
|
||||
pipeline. Tighter blast radius (the key never sits on the share at all), but
|
||||
rotation requires touching every PC or re-imaging.
|
||||
|
||||
Recommendation: use option 1 (`collectorApiKey` in the per-site config), matching
|
||||
how GE-Enforce already sources its share paths and mirroring how SFLD credentials
|
||||
are provisioned per site. It keeps the secret out of source control and off the
|
||||
script, sits at the same trust boundary as everything else the SYSTEM agent
|
||||
already reads, and supports rotation without a fleet touch. `Get-ShopdbCollectorApiKey`
|
||||
above already prefers this and falls back to the imaging-baked file, so a site
|
||||
can start on option 1 and tighten to option 2 later with no code change. The
|
||||
server side pairs this with `COLLECTOR_API_KEY_COMPUTERS` (per-plugin) so the PC
|
||||
fleet's key is scoped to the computers collector only.
|
||||
|
||||
### Staged rollout
|
||||
|
||||
1. Keep everything as-is: `status.json` share write + classic
|
||||
`Report-AssetToShopDB.ps1` continue running. Provision `collectorApiKey` per
|
||||
site and set `COLLECTOR_API_KEY_COMPUTERS` on the shopdb-flask server.
|
||||
2. Add `Send-ShopdbCollectorReport` as a new manifest entry on a handful of pilot
|
||||
bays (one per pc-type: collections, cmm, keyence, waxtrace, ...). It logs to
|
||||
`C:\Logs\Shopfloor\collector-YYYYMMDD.log` and cannot break enforcement (it
|
||||
never throws, exits cleanly).
|
||||
3. Verify on the server: pilot PCs appear/update in shopdb-flask, `action` is
|
||||
`created`/`updated`, warnings are understood (map any unmapped `pctype`, add
|
||||
any missing OS strings to the `operatingsystems` vocab). Cross-check the
|
||||
collector log's `OK` lines against the audit log.
|
||||
4. Roll the manifest entry out fleet-wide (it deploys from `common\`, so every
|
||||
pc-type gets it). Continue running both the share write and the HTTP POST.
|
||||
5. Once shopdb-flask is the system of record and stable across a full imaging
|
||||
cycle, retire the classic `Report-AssetToShopDB.ps1` and, if desired, the
|
||||
`status.json` share write.
|
||||
|
||||
---
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
| Symptom | HTTP | Cause / fix |
|
||||
|---|---|---|
|
||||
| `Invalid API key` | 401 | The `X-API-Key` header is missing or wrong. Confirm the client key matches `COLLECTOR_API_KEY_COMPUTERS` (or the shared `COLLECTOR_API_KEY`) on the server. Check the key is being read (`Get-ShopdbCollectorApiKey` returned non-empty). Remember the querystring form no longer works. |
|
||||
| `Collector API key not configured` | 500 | Fail-closed: neither `COLLECTOR_API_KEY_COMPUTERS` nor `COLLECTOR_API_KEY` is set in the server environment. Set one and restart the app. This is a SERVER config gap, not a client problem. |
|
||||
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
|
||||
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
|
||||
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
|
||||
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
|
||||
|
||||
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
|
||||
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
|
||||
schema failures there).
|
||||
|
||||
107
docs/CONFIG.md
107
docs/CONFIG.md
@@ -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
|
||||
|
||||
@@ -146,9 +156,12 @@ them under `instance/branding/`.
|
||||
|-----|---------|-------|
|
||||
| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. |
|
||||
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
|
||||
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
|
||||
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the machine badge print page. |
|
||||
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
|
||||
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value. Blank = built-in theme color. |
|
||||
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. |
|
||||
| `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. |
|
||||
| `brand_accent_color` | (empty) | Accent color for secondary buttons and badges (maps to `--secondary`). Blank = built-in theme color. |
|
||||
| `brand_sidebar_color` | (empty) | Sidebar background color (maps to `--sidebar-bg`). Blank = built-in theme color. |
|
||||
|
||||
### printing
|
||||
|
||||
@@ -157,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. |
|
||||
@@ -197,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
|
||||
|
||||
@@ -217,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
|
||||
@@ -231,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
|
||||
|
||||
117
docs/CONTRACT-STABILITY.md
Normal file
117
docs/CONTRACT-STABILITY.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Path to contract 1.0
|
||||
|
||||
This page is an honest read of how stable the plugin contract is today, for a
|
||||
sister site deciding how much to build on it. It is derived from the ADRs and
|
||||
the live code, not aspiration. The authoritative hook reference is
|
||||
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md); the versioning rules are in
|
||||
[ADR-002](adr/ADR-002-plugin-versioning.md).
|
||||
|
||||
## Current version
|
||||
|
||||
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.
|
||||
|
||||
The product release version (`__version__`, currently 0.5.0) is a separate
|
||||
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
|
||||
Do not pin against it for compatibility - pin against `__contract_version__`.
|
||||
|
||||
### 0.x history
|
||||
|
||||
Recorded in the comment block in `shopdb/__init__.py`:
|
||||
|
||||
| Version | Change | Kind |
|
||||
|---------|--------|------|
|
||||
| 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, 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.
|
||||
|
||||
## Settled surface
|
||||
|
||||
These are unlikely to break before 1.0. A plugin can depend on them with
|
||||
reasonable confidence; a breaking change to any would be a major bump and would
|
||||
land with a new or amended ADR.
|
||||
|
||||
| Surface | What it is |
|
||||
|---------|-----------|
|
||||
| `meta` / manifest schema | `PluginMeta` fields and `manifest.json` (ADR-002 single source of truth) |
|
||||
| `get_blueprint` | Flask Blueprint registration at the manifest `api_prefix` |
|
||||
| `get_models` | SQLAlchemy model classes the plugin owns |
|
||||
| `init_app` | Custom init after blueprint + models are known |
|
||||
| `get_cli_commands` | Click commands added to the Flask CLI |
|
||||
| `get_services` | Named service classes, consumed by `plugin_manager.get_service` |
|
||||
| Lifecycle hooks | `on_install`, `on_uninstall`, `on_enable`, `on_disable` |
|
||||
| `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 |
|
||||
| `get_config_schema` | Setup-wizard config field declarations |
|
||||
| `shopdb.api` import surface | The only core module plugins may import (plus `shopdb.plugins.base`); adding a name is minor, removing one is major |
|
||||
|
||||
The asset model itself - `Asset`, `AssetType`, `AssetStatus`,
|
||||
`AssetRelationship`, and the shared reference models - is the platform contract
|
||||
locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md).
|
||||
|
||||
## Expected churn before 1.0
|
||||
|
||||
Known-unstable areas. Building on these means expecting rework.
|
||||
|
||||
| Area | Status | Reference |
|
||||
|------|--------|-----------|
|
||||
| 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) |
|
||||
|
||||
## Bump rules
|
||||
|
||||
From [ADR-002](adr/ADR-002-plugin-versioning.md), applied to `__contract_version__`:
|
||||
|
||||
| Bump | Trigger |
|
||||
|------|---------|
|
||||
| major | Breaking change to the `BasePlugin` ABC, the `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). Removing a name from `shopdb.api` is major. |
|
||||
| minor | Additive change: a new optional hook, a new field on a contract model with a default, a new name added to `shopdb.api`. |
|
||||
| patch | Bug fix with no change to the contract surface. |
|
||||
|
||||
### Deprecation policy
|
||||
|
||||
ADR-002 defines the bump classification above but is **silent** on any
|
||||
deprecation window or notice period for pre-1.0 removals. In practice a removal
|
||||
is simply a major (or, pre-1.0, a breaking minor) bump: the hook or name is
|
||||
gone, and the loader fails loud in dev or excludes the mismatched plugin in prod
|
||||
(see the load-time table in [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md)).
|
||||
Because the ADR says nothing about a grace period, this document proposes none;
|
||||
the honest guidance for sister sites is the mitigation that already exists - pin
|
||||
a tight `core_version` range and re-test before widening it.
|
||||
|
||||
## Criteria for declaring 1.0
|
||||
|
||||
Provisional. This is the maintainer's working list, not a committed checklist,
|
||||
and it will move. ADR-002's own open question ("When does the framework declare
|
||||
1.0.0?") ties 1.0 to the `Machine` retirement from ADR-001 and the framework
|
||||
being "ready for sister sites"; the items below expand that intent.
|
||||
|
||||
- Frontend hook contract defined via its own ADR (asset-detail panels, map
|
||||
markers, search results), closing the biggest churn item above.
|
||||
- `Machine` / legacy-model retirement complete per ADR-001, so the asset model
|
||||
is the only contract.
|
||||
- At least two external plugins running in production at a second site - the
|
||||
bar ADR-003 already sets before pip distribution and sister-site readiness
|
||||
are considered justified.
|
||||
- The per-plugin migration pattern (ADR-008) proven across real upgrades, not
|
||||
just fresh installs.
|
||||
- No contract bump needed for N consecutive product releases (N to be fixed
|
||||
when the list is firmed up), showing the surface has actually settled.
|
||||
|
||||
Until then: pre-1.0, pin tight, re-test on every framework bump.
|
||||
@@ -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
|
||||
|
||||
@@ -62,9 +62,16 @@ If `ProductionConfig.validate()` raises, the container exits with the offending
|
||||
|
||||
```bash
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask plugin upgrade-all
|
||||
```
|
||||
|
||||
This applies the baseline migration (creates all tables) and any subsequent migrations. Re-running is idempotent.
|
||||
`flask db upgrade` applies the core Alembic chain: the baseline migration plus
|
||||
every later migration, which together create all core AND bundled-plugin tables
|
||||
through the chain head. `flask plugin upgrade-all` then stamps each bundled
|
||||
plugin's own migration chain (the `alembic_version_<plugin>` tables) and applies
|
||||
any plugin-specific migrations added after the ownership cutover. Both commands
|
||||
are idempotent, so re-running them is safe. See ADR-008 for why plugin schema
|
||||
splits into per-plugin chains from the cutover forward.
|
||||
|
||||
**Charset:** the schema is utf8mb4 (`utf8mb4_unicode_ci`). The docker-compose `db` service sets `--character-set-server=utf8mb4`, so the auto-created `shopdb_flask` database is utf8mb4. If you point at an external MySQL instead of the bundled container, create the database as utf8mb4 first, or it inherits the server default (often latin1) and the schema silently drifts:
|
||||
|
||||
@@ -97,12 +104,12 @@ docker compose exec api flask seed reference-data
|
||||
|
||||
## Step 5: Pick plugins to enable
|
||||
|
||||
The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
|
||||
The image bundles eleven plugins (computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
|
||||
|
||||
```bash
|
||||
docker compose exec api flask plugin list
|
||||
docker compose exec api flask plugin install computers
|
||||
docker compose exec api flask plugin install equipment
|
||||
docker compose exec api flask plugin install machines
|
||||
# ... repeat for each plugin the site tracks
|
||||
```
|
||||
|
||||
|
||||
126
docs/GE-ENFORCE-CLIENT.md
Normal file
126
docs/GE-ENFORCE-CLIENT.md
Normal 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
135
docs/GE-ENFORCE-DEPLOY.md
Normal 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
329
docs/GE-ENFORCE.md
Normal 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
71
docs/IMPORT-ADOPTION.md
Normal 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
527
docs/IMPORT-API.md
Normal 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.
|
||||
@@ -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
160
docs/PILOT-DEPLOY.md
Normal 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.
|
||||
341
docs/PLUGIN-EXTERNAL-REPO.md
Normal file
341
docs/PLUGIN-EXTERNAL-REPO.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Developing a plugin in its own repo
|
||||
|
||||
This guide is for a sister GE Aerospace site (or any third party) building a
|
||||
shopdb-flask plugin in a git repo it owns, outside the framework tree. It is
|
||||
the "external plugin" path in [ADR-003](adr/ADR-003-plugin-distribution.md):
|
||||
the framework ships a bundled set, and you drop your own plugin into
|
||||
`<framework>/plugins/<name>/` by clone, submodule, or symlink. No pip packaging
|
||||
is required for v1 (pip distribution is deferred to v2 per ADR-003).
|
||||
|
||||
If you have not written a plugin before, start with
|
||||
[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in
|
||||
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are
|
||||
different when the plugin lives in its own repo.
|
||||
|
||||
## Recommended repo layout
|
||||
|
||||
Your repo root holds the plugin directory contents directly, so the whole repo
|
||||
can be dropped in at `<framework>/plugins/<name>/`. A plugin named `shipping`
|
||||
in a repo named `wjsf-shipping` looks like this:
|
||||
|
||||
```
|
||||
wjsf-shipping/ # your git repo root == the plugin directory
|
||||
manifest.json # required: name, version, description, core_version
|
||||
plugin.py # required: your BasePlugin subclass
|
||||
__init__.py
|
||||
models/
|
||||
__init__.py # exports every model
|
||||
shipping.py
|
||||
api/
|
||||
__init__.py
|
||||
routes.py # Flask Blueprint returned by get_blueprint()
|
||||
migrations/ # per-plugin Alembic chain (ADR-008), if you own tables
|
||||
env.py
|
||||
script.py.mako
|
||||
versions/
|
||||
0001_shipping_baseline.py
|
||||
tests/ # your own tests (the CI harness runs these)
|
||||
test_shipping.py
|
||||
README.md # what it tracks, who maintains it, where to file issues
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `manifest.json` is the single source of truth for metadata (ADR-002). The
|
||||
`name` field must match the directory name the site installs it under and
|
||||
follows the framework naming convention (lowercase concatenated, no
|
||||
underscores or dashes). Prefix site-specific names with the site code when a
|
||||
collision across sites is possible, e.g. `wjsf-shipping`
|
||||
(see [PLUGINS.md](PLUGINS.md) naming policy and ADR-003).
|
||||
- `migrations/` is only needed if your plugin owns tables. A plugin built
|
||||
outside the tree never had its tables created by the framework's core chain,
|
||||
so its `0001` is a REAL baseline that creates them, not a stamp-only anchor.
|
||||
This is the same "baseline vs anchor" distinction ADR-008 draws for
|
||||
post-cutover plugins; see [ADR-008](adr/ADR-008-plugin-migration-ownership.md).
|
||||
- Import core code ONLY through `shopdb.api` (plus `shopdb.plugins.base` for the
|
||||
ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or `shopdb.utils.*`
|
||||
are contract violations. See PLUGIN-HOOKS.md for the exposed surface.
|
||||
|
||||
## Local dev workflow
|
||||
|
||||
Symlinking lets you edit in your own repo while the framework loads the plugin
|
||||
live. The loader discovers a symlinked directory the same as a real one.
|
||||
|
||||
```bash
|
||||
# 1. Clone the framework and your plugin repo side by side.
|
||||
git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
git clone https://gitea.proudtech.net/wjsf/wjsf-shipping.git
|
||||
|
||||
# 2. Symlink your repo into the framework's plugins/ directory.
|
||||
# The link name is the plugin name from your manifest.json.
|
||||
cd shopdb-flask
|
||||
ln -s ../../wjsf-shipping plugins/shipping
|
||||
# (use an absolute path if you prefer: ln -s "$(pwd)/../wjsf-shipping" plugins/shipping)
|
||||
|
||||
# 3. Set up the framework as usual.
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
|
||||
# 4. Install (enable) your plugin.
|
||||
venv/bin/flask plugin install shipping
|
||||
|
||||
# 5. If your plugin owns tables, apply its migrations.
|
||||
venv/bin/flask db upgrade # core chain
|
||||
venv/bin/flask plugin upgrade-all # plugin chains, including yours
|
||||
|
||||
# 6. Run tests.
|
||||
venv/bin/python -m pytest tests/test_plugin_contract.py
|
||||
venv/bin/python -m pytest ../wjsf-shipping/tests
|
||||
```
|
||||
|
||||
Edits in `../wjsf-shipping` are picked up on the next framework restart, because
|
||||
the symlink points back at your working tree.
|
||||
|
||||
## core_version pinning for sister sites
|
||||
|
||||
The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break
|
||||
the contract, and this project uses that latitude (see the history in
|
||||
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that
|
||||
admits only the contract minor you tested against, not the whole 0.x line.
|
||||
|
||||
The current contract version is declared in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.6.0'
|
||||
```
|
||||
|
||||
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "shipping",
|
||||
"version": "1.0.0",
|
||||
"description": "Tracks shipping-station scanners and label printers",
|
||||
"core_version": ">=0.6.0,<0.7.0",
|
||||
"dependencies": []
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT pin the loose `>=0.2.0,<1.0.0` default that `PluginMeta` falls back to.
|
||||
That default exists so bundled plugins keep loading across minor bumps; an
|
||||
external plugin should be deliberate and re-test before widening its range.
|
||||
|
||||
What happens at load time on a mismatch (ADR-002):
|
||||
|
||||
| Environment | Behavior on core_version mismatch |
|
||||
|-------------|-----------------------------------|
|
||||
| dev / test | The loader re-raises. Startup fails loud so you notice immediately. |
|
||||
| production | The loader logs an error, marks the plugin incompatible, and excludes it from registration. The rest of the app still starts. |
|
||||
|
||||
When you move a site to a newer framework, bump your `core_version` upper bound
|
||||
only after the harness below passes against the new ref.
|
||||
|
||||
## CI recipe
|
||||
|
||||
`scripts/test-external-plugin.sh` (in the framework repo) stands up a throwaway
|
||||
framework at a pinned ref, drops your plugin in as a symlink, and runs the
|
||||
framework contract tests plus your own `tests/`. It has two modes:
|
||||
|
||||
- **CI / remote** (default): clones the framework at `FRAMEWORK_REF` and builds
|
||||
a fresh venv. Needs network access to git and PyPI.
|
||||
- **Local / offline**: set `LOCAL_FRAMEWORK` to a framework checkout on disk.
|
||||
The script exports that checkout at HEAD with `git archive` and reuses its
|
||||
existing venv, so it runs with no internet. Useful for air-gapped verification.
|
||||
|
||||
```bash
|
||||
# CI: test the plugin in the current repo against a pinned tag
|
||||
PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh
|
||||
|
||||
# Offline: test against a framework checkout already on disk
|
||||
LOCAL_FRAMEWORK=/opt/shopdb-flask PLUGIN_DIR=. scripts/test-external-plugin.sh
|
||||
```
|
||||
|
||||
The full script:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-external-plugin.sh
|
||||
#
|
||||
# Verify an out-of-tree shopdb-flask plugin against a pinned framework build.
|
||||
# Stand up a throwaway copy of the framework, drop the plugin into
|
||||
# plugins/<name>/ as a symlink (the mechanism ADR-003 documents for sister
|
||||
# sites), and run the framework contract tests plus the plugin's own tests.
|
||||
# Nonzero exit means the plugin is not compatible with that framework ref.
|
||||
#
|
||||
# Two modes:
|
||||
#
|
||||
# CI / remote (default): clone the framework at FRAMEWORK_REF from
|
||||
# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs
|
||||
# network access to git and PyPI.
|
||||
#
|
||||
# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk.
|
||||
# The script exports that checkout at HEAD with `git archive` (no network)
|
||||
# and reuses the checkout's existing venv, so it runs with no internet.
|
||||
#
|
||||
# Inputs (env var, or positional):
|
||||
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
|
||||
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
|
||||
# FRAMEWORK_URL framework git URL for CI mode.
|
||||
# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then
|
||||
echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)"
|
||||
PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)"
|
||||
if [ -z "$PLUGIN_NAME" ]; then
|
||||
PLUGIN_NAME="$(basename "$PLUGIN_ABS")"
|
||||
fi
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$WORKDIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
FRAMEWORK="$WORKDIR/framework"
|
||||
mkdir -p "$FRAMEWORK"
|
||||
|
||||
if [ -n "$LOCAL_FRAMEWORK" ]; then
|
||||
echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)"
|
||||
LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)"
|
||||
git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK"
|
||||
if [ -x "$LOCAL_ABS/venv/bin/python" ]; then
|
||||
PYTHON="$LOCAL_ABS/venv/bin/python"
|
||||
elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then
|
||||
PYTHON="$LOCAL_ABS/.venv/bin/python"
|
||||
else
|
||||
echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF"
|
||||
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK"
|
||||
echo "==> Creating venv and installing requirements"
|
||||
python3 -m venv "$WORKDIR/venv"
|
||||
PYTHON="$WORKDIR/venv/bin/python"
|
||||
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||
fi
|
||||
|
||||
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||
rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||
ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||
|
||||
rm -f "$FRAMEWORK/instance/plugins.json"
|
||||
|
||||
RC=0
|
||||
|
||||
echo "==> Running framework contract tests"
|
||||
( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1
|
||||
|
||||
if [ -d "$PLUGIN_ABS/tests" ]; then
|
||||
echo "==> Running plugin's own tests"
|
||||
( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1
|
||||
else
|
||||
echo "==> Plugin has no tests/ directory - skipping plugin test step"
|
||||
fi
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'"
|
||||
else
|
||||
echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2
|
||||
fi
|
||||
exit "$RC"
|
||||
```
|
||||
|
||||
### What the harness does and does not check
|
||||
|
||||
The symlinked plugin is discovered and loaded by the plugin loader when the app
|
||||
starts under test. That validates the things that break a real install: the
|
||||
manifest parses, the `core_version` range admits the framework's
|
||||
`__contract_version__` (an out-of-range plugin makes startup fail loud, so the
|
||||
contract test run errors and the script exits nonzero), models expose
|
||||
`__tablename__`, and hooks return the right shapes.
|
||||
|
||||
The import-surface scan (`test_plugins_only_import_contract_surface`) covers
|
||||
symlinked plugins too: the scanner resolves each plugin directory before
|
||||
walking it, because `Path.rglob` alone does not descend symlinks (pinned by
|
||||
`test_import_scan_covers_symlinked_plugins`). You can additionally keep an
|
||||
equivalent import-surface assertion in your own `tests/`, so violations fail
|
||||
in your repo's CI even when run without the framework harness. A minimal
|
||||
version:
|
||||
|
||||
```python
|
||||
# tests/test_import_surface.py
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED = ('shopdb.api', 'shopdb.plugins.base')
|
||||
IMPORT_RE = re.compile(r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE)
|
||||
|
||||
def test_only_contract_surface_imports():
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
bad = []
|
||||
for path in root.rglob('*.py'):
|
||||
if '__pycache__' in path.parts or 'migrations' in path.parts:
|
||||
continue
|
||||
for m in IMPORT_RE.finditer(path.read_text()):
|
||||
mod = m.group(1) or m.group(2)
|
||||
if mod and not any(mod == a or mod.startswith(a + '.') for a in ALLOWED):
|
||||
bad.append(f'{path.name}: {mod}')
|
||||
assert not bad, 'core imports outside shopdb.api / shopdb.plugins.base: ' + '; '.join(bad)
|
||||
```
|
||||
|
||||
## GitHub Actions example
|
||||
|
||||
For a plugin repo hosted on GitHub, a workflow calling the harness against a
|
||||
pinned framework tag (config only, adjust URLs and ref to your setup):
|
||||
|
||||
```yaml
|
||||
name: plugin-contract
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FRAMEWORK_REF: v0.5.0
|
||||
FRAMEWORK_URL: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
steps:
|
||||
- name: Check out the plugin
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Fetch the harness from the framework
|
||||
run: |
|
||||
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework
|
||||
- name: Run the contract harness against this plugin
|
||||
run: |
|
||||
PLUGIN_DIR="$GITHUB_WORKSPACE" \
|
||||
FRAMEWORK_REF="$FRAMEWORK_REF" \
|
||||
FRAMEWORK_URL="$FRAMEWORK_URL" \
|
||||
bash /tmp/framework/scripts/test-external-plugin.sh
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate and install a plugin fast
|
||||
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - the full hook and import-surface reference
|
||||
- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what is settled vs still churning before 1.0
|
||||
- [ADR-002](adr/ADR-002-plugin-versioning.md) - contract versioning and core_version ranges
|
||||
- [ADR-003](adr/ADR-003-plugin-distribution.md) - the bundled vs external distribution model
|
||||
- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - per-plugin migration ownership
|
||||
685
docs/PLUGIN-GUIDE.md
Normal file
685
docs/PLUGIN-GUIDE.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# Building a ShopDB plugin: the measuringtools walkthrough
|
||||
|
||||
This guide builds one real plugin, `measuringtools`, from an empty directory to a
|
||||
running feature with its own list, detail, form, settings page, and report. It is
|
||||
the companion to [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md): the quickstart gets
|
||||
you moving with `flask plugin new`; this guide explains *why* each piece looks the
|
||||
way it does by walking the shipped code of the exemplar plugin.
|
||||
|
||||
`measuringtools` was chosen as the exemplar on purpose. It is the first plugin
|
||||
built after the framework matured (ADR-005 scoped it; ADR-008 changed how plugin
|
||||
migrations work; ADR-009 added frontend route gating). It exercises every current
|
||||
framework feature correctly, so it doubles as the reference implementation. When
|
||||
in doubt about how a plugin should do something, read `plugins/measuringtools/`.
|
||||
|
||||
The domain: measuring tools are gage-lab instruments (calipers, micrometers,
|
||||
thread gages, bore gages, height gages, indicators, Genspect heads) that *measure*
|
||||
parts, as opposed to equipment that *makes* parts. They live inside operation
|
||||
Locations (e.g. "0615 Blisk Inspection"). Their lifecycle is CALIBRATION, not
|
||||
maintenance: an interval, a last date, a next date, and a status derived from the
|
||||
next date. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) for the split
|
||||
between `equipment` and `measuringtools`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin anatomy
|
||||
|
||||
A bundled plugin is a Python package under `plugins/<name>/` plus a frontend that
|
||||
(for now) lives in core `frontend/src/` (see section 9 and ADR-009 for why). The
|
||||
backend tree for `measuringtools`:
|
||||
|
||||
```
|
||||
plugins/measuringtools/
|
||||
__init__.py # exports the plugin class
|
||||
manifest.json # metadata: name, version, core_version, api_prefix
|
||||
plugin.py # MeasuringToolsPlugin(BasePlugin): hooks + on_install
|
||||
models/
|
||||
__init__.py # re-exports the models + derive_status helper
|
||||
measuringtool.py # MeasuringTool, MeasuringToolType, derive_status
|
||||
api/
|
||||
__init__.py # exports the blueprint
|
||||
routes.py # the Flask blueprint (CRUD, types, report)
|
||||
migrations/
|
||||
env.py # delegates to the shared plugin Alembic runner
|
||||
script.py.mako # migration template (copied from a sibling plugin)
|
||||
versions/
|
||||
0001_measuringtools_baseline.py # REAL baseline: creates the tables
|
||||
```
|
||||
|
||||
Every plugin implements three required hooks (`meta`, `get_blueprint`,
|
||||
`get_models`) and overrides the optional hooks it needs. The base class is
|
||||
`shopdb/plugins/base.py`; the full hook catalog is
|
||||
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
|
||||
|
||||
---
|
||||
|
||||
## 2. Manifest and versioning
|
||||
|
||||
`plugins/measuringtools/manifest.json` is the single source of truth for the
|
||||
plugin's identity (ADR-002):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "measuringtools",
|
||||
"version": "1.0.0",
|
||||
"description": "Metrology and inspection instruments ... derived calibration status.",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.6.0,<1.0.0",
|
||||
"api_prefix": "/api/measuringtools",
|
||||
"default_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Two fields deserve attention.
|
||||
|
||||
`core_version` is a semver range against the framework's `__contract_version__`
|
||||
(declared in `shopdb/__init__.py`, currently `0.6.0`). The loader refuses to load
|
||||
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
|
||||
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
|
||||
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
|
||||
the framework is pre-1.0 and the contract can still shift; a sister site forking
|
||||
the plugin gets an explicit failure rather than a silent break if it lands on a
|
||||
newer, incompatible core. This is the ADR-002 discipline: consume the lowest
|
||||
contract version you actually need, and cap below the next major.
|
||||
|
||||
`default_enabled: false` means installing the plugin does not turn it on. Sites opt
|
||||
in. This is the convention for any plugin that provisions extra tables or that not
|
||||
every site wants; a gage lab installs it, a site without one does not.
|
||||
|
||||
The `plugin.py` `meta` property just reads the manifest, so the manifest stays the
|
||||
one place these values live:
|
||||
|
||||
```python
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'measuringtools'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
...
|
||||
core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Models, naming convention, and PLUGIN_TABLE_OWNERS
|
||||
|
||||
The plugin owns two tables (`plugins/measuringtools/models/measuringtool.py`):
|
||||
|
||||
- `measuringtooltypes` - the site-managed lookup (Caliper, Micrometer, ...) with a
|
||||
display color, the same shape as `machinetypes` / `computertypes`.
|
||||
- `measuringtools` - the one-to-one extension of a core `Asset`, carrying only the
|
||||
metrology domain fields.
|
||||
|
||||
```python
|
||||
class MeasuringTool(BaseModel):
|
||||
__tablename__ = 'measuringtools'
|
||||
|
||||
measuringtoolid = db.Column(db.Integer, primary_key=True)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True, nullable=False, index=True,
|
||||
)
|
||||
measuringtooltypeid = db.Column(
|
||||
db.Integer, db.ForeignKey('measuringtooltypes.measuringtooltypeid'),
|
||||
nullable=True)
|
||||
calibrationintervaldays = db.Column(db.Integer, nullable=True)
|
||||
lastcalibrationdate = db.Column(db.Date, nullable=True)
|
||||
nextcalibrationdate = db.Column(db.Date, nullable=True)
|
||||
calibrationprovider = db.Column(db.String(150), nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
```
|
||||
|
||||
Design points:
|
||||
|
||||
- **Identity lives on the Asset, not here.** Per ADR-001 and ADR-005,
|
||||
`Asset.assetnumber` is the gage tag, `Asset.serialnumber` is the vendor serial,
|
||||
and `Asset.gaugelabreference` is the gage-lab identifier. The extension table
|
||||
carries only what is specific to a measuring tool: its type and its calibration
|
||||
lifecycle. One extension row per asset, enforced by `unique=True` on `assetid`.
|
||||
|
||||
- **Naming convention (CONTRIBUTING.md).** DB tables and columns are lowercase and
|
||||
concatenated: `measuringtooltypeid`, `calibrationintervaldays`,
|
||||
`nextcalibrationdate`. No underscores, no camelCase, no banned shorthand. The
|
||||
pre-commit hook `scripts/check-naming-and-style.sh` fails the build otherwise.
|
||||
|
||||
- **Derived status is never stored.** Calibration status is computed from
|
||||
`nextcalibrationdate` at read time, mirroring the warranty plugin's pattern:
|
||||
|
||||
```python
|
||||
DUESOON_WINDOW_DAYS = 30
|
||||
STATUS_COLORS = {'overdue': '#F44336', 'duesoon': '#FF9800',
|
||||
'current': '#4CAF50', 'unknown': '#9E9E9E'}
|
||||
|
||||
def derive_status(nextcalibrationdate, today=None):
|
||||
if not nextcalibrationdate:
|
||||
return 'unknown'
|
||||
today = today or date.today()
|
||||
if nextcalibrationdate < today:
|
||||
return 'overdue'
|
||||
if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS):
|
||||
return 'duesoon'
|
||||
return 'current'
|
||||
```
|
||||
|
||||
Storing a status column would let it go stale the moment a due date passed with no
|
||||
write. Deriving it means the list badge, the detail badge, and the report are
|
||||
always correct without a nightly job.
|
||||
|
||||
`to_dict()` overlays the derived status and the type name onto the row and emits
|
||||
the two `Date` columns as `YYYY-MM-DD` (the framework's `BaseModel.to_dict` only
|
||||
iso-formats `datetime`, not plain `date`, so the plugin does it explicitly for
|
||||
predictable JSON).
|
||||
|
||||
**Register the tables in `PLUGIN_TABLE_OWNERS`.** The migration engine needs an
|
||||
explicit map of which tables each plugin owns
|
||||
(`shopdb/plugins/alembic_template.py`):
|
||||
|
||||
```python
|
||||
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
...
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`tests/test_plugin_migrations.py::test_table_owners_match_declared_models` fails if
|
||||
this map drifts from the `__tablename__` declarations in either direction, so you
|
||||
cannot forget it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-plugin migrations: anchor vs real baseline
|
||||
|
||||
This is the part that differs most from the ten older bundled plugins, so read
|
||||
[ADR-008](adr/ADR-008-plugin-migration-ownership.md) alongside this section.
|
||||
|
||||
Every table-owning plugin carries its own Alembic chain under
|
||||
`plugins/<name>/migrations/`, with a per-plugin version table
|
||||
`alembic_version_<name>` independent of the core `alembic_version`. The `env.py` is
|
||||
a three-line delegate to the shared runner:
|
||||
|
||||
```python
|
||||
import os
|
||||
os.environ['PLUGIN_NAME'] = 'measuringtools'
|
||||
from shopdb.plugins.alembic_template import run_migrations
|
||||
run_migrations()
|
||||
```
|
||||
|
||||
The key distinction is the `0001` revision.
|
||||
|
||||
**Cutover plugins (the older ten) ship a no-op anchor.** ADR-008 froze a cutover
|
||||
at the core-chain head. The core Alembic chain had already created every table
|
||||
that existed at that point, including those ten plugins' tables. So their `0001`
|
||||
migration is a stamp-only no-op: `upgrade()` does `pass`, because the tables
|
||||
already exist. It exists only to give the plugin chain a base revision that
|
||||
`flask plugin upgrade-all` can stamp.
|
||||
|
||||
**`measuringtools` was built after the cutover, so its `0001` is a REAL baseline.**
|
||||
The core chain never knew about `measuringtooltypes` / `measuringtools`, so this
|
||||
per-plugin chain is their sole authoritative creator. `upgrade()` actually creates
|
||||
the tables:
|
||||
|
||||
```python
|
||||
revision = 'measuringtools0001baseline'
|
||||
down_revision = None
|
||||
|
||||
def upgrade():
|
||||
op.create_table('measuringtooltypes', ...)
|
||||
op.create_table('measuringtools',
|
||||
...,
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['measuringtooltypeid'],
|
||||
['measuringtooltypes.measuringtooltypeid']),
|
||||
...)
|
||||
op.create_index('idx_measuringtool_type', 'measuringtools', ['measuringtooltypeid'])
|
||||
```
|
||||
|
||||
Two things to know when you write a post-cutover baseline:
|
||||
|
||||
- **The shared `create_plugin_tables` helper cannot render a foreign key to a core
|
||||
table.** It builds a per-plugin `MetaData` filtered to only the plugin's own
|
||||
tables, so a foreign key to `assets` has no `assets` table to resolve against and
|
||||
Alembic raises `NoReferencedTableError` at compile time. Because these tables
|
||||
reference `assets.assetid`, the baseline uses explicit `op.create_table` ops (the
|
||||
same shape Alembic autogenerate produces) instead of the helper. If your plugin's
|
||||
tables have no FKs to core tables, `create_plugin_tables` works; if they do, hand
|
||||
the DDL to `op.create_table`. (This limitation surfaced building this exemplar
|
||||
and is a candidate framework fix: teach `_get_plugin_metadata` to pull in
|
||||
FK-referenced core tables.)
|
||||
|
||||
- **Charset.** The baseline emits plain `CREATE TABLE`, so the tables inherit the
|
||||
connection's default charset. On the documented utf8mb4 database that yields
|
||||
utf8mb4 tables, matching how the core chain creates its tables. No explicit
|
||||
`mysql_charset` is needed, and none of the unique columns exceed the InnoDB index
|
||||
prefix limit (the longest is `name VARCHAR(100)` = 400 bytes < 767).
|
||||
|
||||
The deploy sequence is unchanged: `flask db upgrade` (core chain) then
|
||||
`flask plugin upgrade-all` (stamps anchors and runs real baselines like this one).
|
||||
Both are idempotent.
|
||||
|
||||
**Adapting the guard test.** `tests/test_plugin_migrations.py` has a test that
|
||||
asserts each `0001` anchor is a pure no-op. That is true for the ten cutover
|
||||
plugins but deliberately false for `measuringtools`. The fix is to scope the no-op
|
||||
assertion to a frozen `CUTOVER_PLUGINS` list rather than to all discovered plugins,
|
||||
and to expect `measuringtools`'s real baseline revision in the upgrade-all test:
|
||||
|
||||
```python
|
||||
CUTOVER_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase',
|
||||
'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
|
||||
|
||||
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) # not all plugins
|
||||
def test_anchor_migration_is_noop(plugin):
|
||||
...
|
||||
```
|
||||
|
||||
Freezing the list (rather than deriving it) is intentional: a newly discovered
|
||||
plugin should not silently be treated as a cutover no-op.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contract purity: import only from `shopdb.api`
|
||||
|
||||
A plugin may import from exactly two core places: `shopdb.plugins.base` (for
|
||||
`BasePlugin` / `PluginMeta`) and `shopdb.api` (everything else). Importing internal
|
||||
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` is a
|
||||
contract violation. `measuringtools` gets its whole surface from `shopdb.api`:
|
||||
|
||||
```python
|
||||
from shopdb.api import (
|
||||
db, Asset, AssetType, AuditLog,
|
||||
success_response, error_response, paginated_response, ErrorCodes,
|
||||
get_pagination_params, paginate_query,
|
||||
require_permission,
|
||||
)
|
||||
from shopdb.api import db, BaseModel # in the models module
|
||||
```
|
||||
|
||||
`shopdb.api` is the versioned platform contract (ADR-001). Adding a name there is
|
||||
an additive minor bump; removing one is a breaking major bump. The guard test
|
||||
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface` scans
|
||||
every plugin's imports and fails the build if one reaches past the contract. Build
|
||||
against `shopdb.api` and your plugin travels cleanly to a sister site running a
|
||||
compatible core.
|
||||
|
||||
---
|
||||
|
||||
## 6. Blueprint: authz, responses, pagination
|
||||
|
||||
`plugins/measuringtools/api/routes.py` is a normal Flask blueprint. Three framework
|
||||
conventions run through it.
|
||||
|
||||
**Reads are `jwt_required(optional=True)`; writes require a permission.** This is
|
||||
the app-wide pattern: anonymous kiosks and unauthenticated internal users can read
|
||||
the asset catalog, but any state change needs a logged-in user with the right
|
||||
permission.
|
||||
|
||||
```python
|
||||
@measuringtools_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_tools():
|
||||
...
|
||||
|
||||
@measuringtools_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.create')
|
||||
def create_tool():
|
||||
...
|
||||
```
|
||||
|
||||
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
|
||||
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'),
|
||||
]
|
||||
```
|
||||
|
||||
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
|
||||
(`{status, data, meta}`) so every plugin's API looks the same to the frontend. The
|
||||
list endpoint pages with `get_pagination_params` + `paginate_query`.
|
||||
|
||||
**The write path merges asset core and extension in one payload**, mirroring
|
||||
`equipment` and `computers`. Create builds the `Asset` first, flushes to get the
|
||||
`assetid`, then builds the extension row and writes an audit log:
|
||||
|
||||
```python
|
||||
asset = Asset(assetnumber=data['assetnumber'], ..., assettypeid=assettypeid, ...)
|
||||
db.session.add(asset); db.session.flush()
|
||||
tool = MeasuringTool(assetid=asset.assetid, measuringtooltypeid=..., ...)
|
||||
db.session.add(tool); db.session.flush()
|
||||
AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
entityname=asset.assetnumber)
|
||||
db.session.commit()
|
||||
return success_response(_merged(tool), message='Measuring tool created', http_code=201)
|
||||
```
|
||||
|
||||
`_merged()` returns `asset.to_dict()` with the extension nested under
|
||||
`measuringtool`, so the GET, POST, and PUT responses all share one shape.
|
||||
|
||||
**The types resource has an in-use delete guard.** Deleting a type that tools still
|
||||
reference is refused with a 409, so a color/name in use cannot vanish out from
|
||||
under existing rows:
|
||||
|
||||
```python
|
||||
inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count()
|
||||
if inuse:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f'Cannot delete: {inuse} tool(s) still use this type', http_code=409)
|
||||
```
|
||||
|
||||
**The calibration report** mirrors the warranty report shape: counts plus lists
|
||||
bucketed by derived status.
|
||||
|
||||
```python
|
||||
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def calibration_report():
|
||||
buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []}
|
||||
for tool in MeasuringTool.query.join(Asset).filter(Asset.isactive == True).all():
|
||||
buckets[derive_status(tool.nextcalibrationdate)].append(_merged(tool))
|
||||
return success_response({'counts': {k: len(v) for k, v in buckets.items()},
|
||||
'buckets': buckets, 'statuscolors': STATUS_COLORS})
|
||||
```
|
||||
|
||||
One deliberate note: the list endpoint's `calibrationstatus` filter is applied to
|
||||
the built rows, not as a SQL `WHERE`, because the status is derived and not a
|
||||
column. That is fine for a gage lab's worth of tools; it would need rethinking for
|
||||
a fleet the size of the PC estate.
|
||||
|
||||
---
|
||||
|
||||
## 7. on_install seeding
|
||||
|
||||
`on_install` runs once, when a site runs `flask plugin install measuringtools`. It
|
||||
seeds the plugin's own asset type and a set of starter tool types:
|
||||
|
||||
```python
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._ensure_asset_type() # AssetType 'measuring_tool'
|
||||
self._ensure_starter_types() # Caliper, Micrometer, Thread Gage, ...
|
||||
db.session.commit()
|
||||
```
|
||||
|
||||
`_ensure_asset_type` registers `AssetType(assettype='measuring_tool',
|
||||
pluginname='measuringtools', tablename='measuringtools', icon='ruler')`. The asset
|
||||
type is what ties a core `Asset` to this plugin's extension table and drives the
|
||||
custom-fields lookup (section 10). The starter types come with colors drawn from
|
||||
the shared frontend palette so map markers and badges are legible out of the box.
|
||||
|
||||
Seeding is idempotent (each `_ensure_*` checks for existence first), so a reinstall
|
||||
does not duplicate rows.
|
||||
|
||||
---
|
||||
|
||||
## 8. Hooks, one by one
|
||||
|
||||
`measuringtools` implements four optional hooks and consciously skips two.
|
||||
|
||||
**`get_navigation_items`** puts "Measuring Tools" in the sidebar. Navigation is
|
||||
data-driven: the frontend renders whatever enabled plugins return, so no core file
|
||||
lists the link.
|
||||
|
||||
```python
|
||||
def get_navigation_items(self):
|
||||
return [{'name': 'Measuring Tools', 'icon': 'ruler',
|
||||
'route': '/measuringtools', 'position': 22}]
|
||||
```
|
||||
|
||||
`position: 22` slots it into the Assets band (10-29) after Network and Printers.
|
||||
The `icon` is a string key; the frontend maps `'ruler'` to a Lucide component
|
||||
(section 9).
|
||||
|
||||
**`get_reports`** (contract 0.6.0) contributes the calibration card to the Reports
|
||||
hub. Each card has a stable `id`, a `name`, a `description`, a `category`, and
|
||||
exactly one of `route` (a dedicated page) or `endpoint` (inline render). We use a
|
||||
route because the report is a full bucketed page:
|
||||
|
||||
```python
|
||||
def get_reports(self):
|
||||
return [{'id': 'calibration', 'name': 'Calibration Due',
|
||||
'description': 'Measuring tools bucketed by calibration status',
|
||||
'category': 'compliance', 'route': '/reports/calibration'}]
|
||||
```
|
||||
|
||||
`GET /api/reports` merges this after the core reports and drops it when the plugin
|
||||
is disabled.
|
||||
|
||||
**`get_models`** returns `[MeasuringTool, MeasuringToolType]` so migration tooling
|
||||
and admin views know the plugin's tables.
|
||||
|
||||
**`get_config_schema` returns `[]`, on purpose.** The setup wizard reads this to
|
||||
know what a plugin needs configured. Measuring tools are tracked by hand: there is
|
||||
no external system to authenticate against, no endpoint URL, no API key. So the
|
||||
schema is empty and the wizard shows nothing to configure. This is the intentional
|
||||
"no external creds" case. Contrast the printers plugin, which declares a Zabbix URL
|
||||
field. If your plugin talks to an external service, declare its (non-secret)
|
||||
settings here and mark credentials `secret: True` so the wizard emits an `.env`
|
||||
line instead of storing them in the database.
|
||||
|
||||
**Skipped: the collector hooks (`get_collector_schema` /
|
||||
`apply_collector_payload`).** These (ADR-006) exist for plugins fed by an automated
|
||||
agent pushing to `/api/collector/<name>`, like `computers` fed by the PXE pipeline.
|
||||
Measuring tools are entered by hand in the gage lab; there is no collector. Because
|
||||
`get_collector_schema` returns `None` (the base-class default), no collector
|
||||
endpoint is registered and `apply_collector_payload` is never called. The contract
|
||||
test `test_schema_declaring_plugins_implement_apply` only requires the upsert method
|
||||
when a schema is declared, so skipping both is clean. If a site later automates
|
||||
calibration imports (say from a cal-lab spreadsheet), that is the hook to add.
|
||||
|
||||
**Not skipped so much as not needed: dashboard widgets, services, CLI commands.**
|
||||
`get_dashboard_widgets` could add a "calibration due" tile later; `get_services`
|
||||
and `get_cli_commands` have no use here yet. Leaving them at their defaults keeps
|
||||
the plugin small.
|
||||
|
||||
---
|
||||
|
||||
## 9. Frontend integration
|
||||
|
||||
There is no frontend plugin system yet (see
|
||||
[ADR-009](adr/ADR-009-frontend-plugin-gating.md), "Future direction"). A plugin's
|
||||
Vue routes and views ship in the core bundle. The plugin's job is to add them
|
||||
correctly and gate them.
|
||||
|
||||
**Route module with `meta.plugin` gating (ADR-009).** A new file
|
||||
`frontend/src/router/routes/measuringtools.js` is auto-discovered by the router's
|
||||
`import.meta.glob('./routes/*.js')`. Every route carries `meta.plugin =
|
||||
'measuringtools'`:
|
||||
|
||||
```js
|
||||
export default [
|
||||
{ path: 'measuringtools', name: 'measuringtools',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
|
||||
meta: { plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/new', name: 'measuringtool-new',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/:id', ..., meta: { plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/:id/edit', ..., meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
||||
{ path: 'reports/calibration', ..., meta: { plugin: 'measuringtools' } },
|
||||
{ path: 'settings/measuringtooltypes', ...,
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } },
|
||||
]
|
||||
```
|
||||
|
||||
`meta.plugin` makes the router guard redirect to the dashboard (instead of loading
|
||||
a broken shell) when the backend plugin is disabled. It fetches
|
||||
`GET /api/plugins/enabled` once, cached, and fails open. Note the auth pattern that
|
||||
matches the rest of the app: list and detail are public (no `requiresAuth`); the
|
||||
form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdmin`.
|
||||
|
||||
**API client, addition only.** `frontend/src/api/index.js` gets a
|
||||
`measuringtoolsApi` object appended after `warrantyApi` (`list`, `get`, `create`,
|
||||
`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
|
||||
(`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
|
||||
the color the API derived.
|
||||
- `views/measuringtools/MeasuringToolDetail.vue` - hero + Identity card +
|
||||
Calibration card (with the derived badge) + Location card, plus the shared
|
||||
`CustomFieldsSection` and `WarrantyPanel` (section 10).
|
||||
- `views/measuringtools/MeasuringToolForm.vue` - asset core fields + type +
|
||||
location + the calibration fields, plus `CustomFieldsInputs`.
|
||||
- `views/reports/CalibrationReport.vue` - the four buckets (overdue / due soon /
|
||||
current / unknown), mirroring `WarrantyReport.vue`.
|
||||
|
||||
**Settings subtype page.** `views/settings/MeasuringToolTypesList.vue` mirrors
|
||||
`PCTypesList.vue`: add / edit / delete with a `ColorSwatchPicker`. It is linked from
|
||||
`settingsNav.js` with a "Measuring Tools" card group, so it appears in the settings
|
||||
rail and landing overview.
|
||||
|
||||
**iconMap, entry only.** The sidebar maps backend icon-name strings to Lucide
|
||||
components in `AppLayout.vue`. Add the import and one map entry:
|
||||
|
||||
```js
|
||||
import { ..., Ruler } from 'lucide-vue-next'
|
||||
const iconMap = { ..., 'ruler': Ruler }
|
||||
```
|
||||
|
||||
That is the only change to `AppLayout.vue`. This is exactly the kind of core-file
|
||||
edit ADR-009's future direction wants to replace with a registration API; for now
|
||||
it is a one-line addition.
|
||||
|
||||
---
|
||||
|
||||
## 10. Custom fields and warranty panel composition
|
||||
|
||||
Two cross-cutting core features compose onto the plugin's pages for free.
|
||||
|
||||
**Custom fields.** Sites define extra attributes per asset type in Settings. The
|
||||
detail page drops in `<CustomFieldsSection :assetid="tool.assetid" />` and the form
|
||||
drops in `<CustomFieldsInputs :assettypeid="assettypeid" :assetid="currentAssetId" />`,
|
||||
then calls `customFieldsRef.value.save(assetId)` after the tool saves. The one
|
||||
subtlety: `CustomFieldsInputs` needs the asset-type id. Rather than hardcode it (the
|
||||
equipment form hardcodes `EQUIPMENT_ASSETTYPEID = 1`), the measuringtools form
|
||||
resolves it dynamically from `GET /api/assets/types`, finding the row whose
|
||||
`assettype === 'measuring_tool'`. Dynamic lookup is preferred because seeded ids are
|
||||
not stable across sites.
|
||||
|
||||
**Warranty panel.** `<WarrantyPanel :assetid="tool.assetid" :items="warranties" />`
|
||||
renders any warranties covering the tool. Warranty is an asset-general plugin, so it
|
||||
works on any asset type with no coupling; the measuring-tool detail page composes it
|
||||
the same way the equipment and PC detail pages do.
|
||||
|
||||
**Map.** The asset map is data-driven off asset types and resolved positions
|
||||
(ADR-001). Once a measuring tool is typed and given a location or map coordinates,
|
||||
it appears on `/assets/map` with its type color, with no plugin-side map code. There
|
||||
is nothing to force; giving the type a color and the asset a position is enough.
|
||||
|
||||
---
|
||||
|
||||
## 11. Tests
|
||||
|
||||
`tests/test_plugins/test_measuringtools.py` covers the plugin end to end:
|
||||
|
||||
- **Derived status**, as pure unit tests against `derive_status`: all four buckets
|
||||
plus the 30-day boundary (exactly `+30` is still "due soon"; due today is "due
|
||||
soon", not "overdue").
|
||||
- **Type CRUD + the in-use delete guard** (409 when a tool references the type).
|
||||
- **Tool create/update in one merged payload**, asserting asset core fields land at
|
||||
the top level and extension fields nest under `measuringtool`, and that a past
|
||||
`nextcalibrationdate` derives `overdue`.
|
||||
- **The report shape** (counts and buckets agree; the four keys are present).
|
||||
|
||||
Because the plugin ships `default_enabled: false`, the shared session app does not
|
||||
register its blueprint. The test module builds its own app, registers the blueprint,
|
||||
runs `on_install` seeding, and restores the process-wide `plugin_manager` singleton
|
||||
afterward, the same snapshot/restore pattern `test_plugin_migrations.py` uses. The
|
||||
existing parametrized contract and migration tests pick the plugin up automatically
|
||||
from the filesystem and must stay green.
|
||||
|
||||
Run the suite:
|
||||
|
||||
```bash
|
||||
venv/bin/python -m pytest
|
||||
bash scripts/check-naming-and-style.sh
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Install / enable lifecycle
|
||||
|
||||
The plugin is on disk but does nothing until a site installs it. Two commands, then
|
||||
a restart:
|
||||
|
||||
```bash
|
||||
flask plugin install measuringtools # runs the 0001 baseline, then on_install seeding
|
||||
flask plugin enable measuringtools # default_enabled is false, so enable explicitly
|
||||
```
|
||||
|
||||
`install` runs the migration (creating the tables), registers the plugin in
|
||||
`instance/plugins.json`, and calls `on_install`. Because `default_enabled` is false,
|
||||
it installs disabled; `enable` flips the registry flag. The registry entry looks
|
||||
like:
|
||||
|
||||
```json
|
||||
"measuringtools": { "name": "measuringtools", "version": "1.0.0",
|
||||
"enabled": true, "migrations_applied": [], "config": {} }
|
||||
```
|
||||
|
||||
**Restart is required.** Blueprints are registered at app startup; Flask forbids
|
||||
registering one after the app has served a request. So after enabling, restart the
|
||||
API so the loader registers `/api/measuringtools`. On a dev box:
|
||||
|
||||
```bash
|
||||
# kill the running server on :5001, then:
|
||||
setsid venv/bin/flask run --port 5001 --no-reload >/tmp/flask.log 2>&1 < /dev/null &
|
||||
```
|
||||
|
||||
For a deploy, the sequence is `flask db upgrade` then `flask plugin upgrade-all`
|
||||
(which runs this plugin's baseline on a fresh install and is a no-op on an existing
|
||||
one), then a service restart. See [PLUGINS.md](PLUGINS.md) and
|
||||
[ADR-008](adr/ADR-008-plugin-migration-ownership.md).
|
||||
|
||||
---
|
||||
|
||||
## End checklist
|
||||
|
||||
When you build a plugin, confirm all of this before you call it done:
|
||||
|
||||
- [ ] `manifest.json`: name, version, `core_version` pinned to the lowest contract
|
||||
you need and capped below the next major, `api_prefix`, `default_enabled`.
|
||||
- [ ] Models declare tables with the naming convention; identity stays on `Asset`.
|
||||
- [ ] Derived state (if any) is computed at read time, never stored.
|
||||
- [ ] Tables registered in `PLUGIN_TABLE_OWNERS`.
|
||||
- [ ] Per-plugin migration chain: no-op anchor if your tables predate the cutover, a
|
||||
real baseline if the plugin is post-cutover. Hand-write `op.create_table` if
|
||||
you have FKs to core tables.
|
||||
- [ ] 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 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.
|
||||
- [ ] Frontend: route module with `meta.plugin` gating, API client addition, views
|
||||
mirroring the master templates, settings subtype page + `settingsNav` entry,
|
||||
one `iconMap` entry.
|
||||
- [ ] Custom fields and warranty panel composed onto the detail/form pages.
|
||||
- [ ] Tests: unit + API, self-contained app if `default_enabled` is false; full
|
||||
suite and naming/style check green; `npm run build` green.
|
||||
- [ ] Install / enable / restart verified live; a demo record renders on every page.
|
||||
@@ -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.4.0'
|
||||
__contract_version__ = '0.11.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -192,6 +192,207 @@ class ComputersPlugin(BasePlugin):
|
||||
> already covers every bundled asset type; no plugin ever implemented the hook.
|
||||
> Search honors runtime plugin enable/disable.
|
||||
|
||||
### `get_reports() -> List[Dict]`
|
||||
|
||||
Returns report card definitions for the Reports hub. Added in contract 0.6.0.
|
||||
|
||||
Each entry has `id`, `name`, `description`, `category`, plus EXACTLY ONE of
|
||||
`route` (a frontend path for a dedicated report page) or `endpoint` (an API
|
||||
endpoint the hub renders inline).
|
||||
|
||||
```python
|
||||
class WarrantyPlugin(BasePlugin):
|
||||
def get_reports(self):
|
||||
return [{
|
||||
'id': 'warranty',
|
||||
'name': 'Warranty Report',
|
||||
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
|
||||
'category': 'warranty',
|
||||
'route': '/reports/warranty',
|
||||
}]
|
||||
```
|
||||
|
||||
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
|
||||
during setup. Return `None` (the default) for plugins that need no special
|
||||
setup. Plugins that create extra tables beyond their asset-extension table
|
||||
(e.g. a self-hosted directory) return:
|
||||
|
||||
```python
|
||||
class EmployeesPlugin(BasePlugin):
|
||||
def get_provisioning_note(self):
|
||||
return {
|
||||
'tables': ['directoryemployees'],
|
||||
'note': 'Creates a local employee directory table in the shopdb database.',
|
||||
'docs': 'plugins/employees/README.md',
|
||||
}
|
||||
```
|
||||
|
||||
### `get_config_schema() -> List[Dict]`
|
||||
|
||||
Declares the config fields this plugin needs, so the setup wizard can prompt
|
||||
for them. Return `[]` (the default) if the plugin needs no configuration.
|
||||
Each field is a dict:
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `key` | the Setting key (non-secret) it maps to |
|
||||
| `label` | human label shown in the wizard |
|
||||
| `type` | `'text'` / `'number'` / `'password'` |
|
||||
| `secret` | `True` for credentials; NOT stored in the DB - the wizard emits an `.env` line for the operator instead |
|
||||
| `envvar` | (secret only) the `.env` variable name to emit |
|
||||
| `default` | optional placeholder |
|
||||
| `help` | optional hint |
|
||||
|
||||
```python
|
||||
class PrintersPlugin(BasePlugin):
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{'key': 'zabbix_url', 'label': 'Zabbix URL', 'type': 'text',
|
||||
'help': 'Base URL of the Zabbix server for supply lookups'},
|
||||
{'key': 'zabbix_token', 'label': 'Zabbix API token', 'type': 'password',
|
||||
'secret': True, 'envvar': 'ZABBIX_TOKEN'},
|
||||
]
|
||||
```
|
||||
|
||||
### `get_collector_schema() -> Optional[Dict]`
|
||||
|
||||
Declares the JSON Schema for an external collector pushing to `/api/collector/<pluginname>`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract.
|
||||
@@ -268,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
|
||||
@@ -319,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:
|
||||
|
||||
237
docs/PLUGIN-LAB-PRINTEDPARTS.md
Normal file
237
docs/PLUGIN-LAB-PRINTEDPARTS.md
Normal 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` |
|
||||
@@ -11,7 +11,7 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr
|
||||
flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
|
||||
```
|
||||
|
||||
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README.
|
||||
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. When a `frontend/src/` tree is present, it also writes the frontend starting points: `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`, and the auto-discovered route file `frontend/src/router/routes/cameras.js`. (A plugin developed in its own repo, with no frontend tree, gets the backend skeleton plus the snippet only.)
|
||||
|
||||
The generated plugin already passes the framework's contract tests. Verify before editing:
|
||||
|
||||
@@ -121,19 +121,50 @@ 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.
|
||||
|
||||
## Step 7: Frontend (manual for now)
|
||||
## Step 7: Frontend (finish the generated starting points)
|
||||
|
||||
Backend scaffolding is automated. Frontend is manual until the frontend scaffolding skill ships. Convention:
|
||||
The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order:
|
||||
|
||||
- `frontend/src/views/cameras/CamerasList.vue`
|
||||
- `frontend/src/views/cameras/CameraDetail.vue`
|
||||
- `frontend/src/views/cameras/CameraForm.vue`
|
||||
1. **View files** - the scaffold created `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling.
|
||||
|
||||
Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. Update the API client in `frontend/src/api/index.js` to add cameras endpoints.
|
||||
2. **Route file** - the scaffold created `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`:
|
||||
|
||||
```js
|
||||
export default [
|
||||
{
|
||||
path: 'cameras',
|
||||
name: 'cameras',
|
||||
component: () => import('../../views/cameras/CamerasList.vue'),
|
||||
meta: { plugin: 'cameras' }
|
||||
},
|
||||
{
|
||||
path: 'cameras/:id/edit',
|
||||
name: 'cameras-edit',
|
||||
component: () => import('../../views/cameras/CamerasForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'cameras' }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '../../api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`).
|
||||
|
||||
4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`.
|
||||
|
||||
5. **Report cards** (if any) - implement `get_reports` on the plugin class. No frontend edit: the Reports hub builds itself from `/api/reports`. Use `route` for a dedicated page (add it to your route file), or `endpoint` for inline rendering.
|
||||
|
||||
6. **Settings page** (if the plugin has subtypes) - add a route whose path starts with `settings/` (e.g. `settings/cameratypes`) to your route file; the router automatically nests it under the settings shell. Copy a types-list view from `frontend/src/views/settings/`.
|
||||
|
||||
7. **Verify** - `npm run build` must pass, then screenshot your pages against the dev servers: `venv/bin/python tools/shot.py /cameras`.
|
||||
|
||||
## Common errors
|
||||
|
||||
@@ -147,6 +178,7 @@ Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`)
|
||||
|
||||
## Next steps
|
||||
|
||||
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin end to end (models, per-plugin migration baseline, authz, hooks, frontend integration, tests). Read this after the quickstart when you want the exemplar that exercises every framework feature.
|
||||
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions
|
||||
- [docs/adr/ADR-001-asset-as-platform-contract.md](../docs/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on
|
||||
@@ -155,3 +187,5 @@ Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`)
|
||||
## Distribution
|
||||
|
||||
If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `<repo>/plugins/<name>/`. See [ADR-003](../docs/adr/ADR-003-plugin-distribution.md).
|
||||
|
||||
For the full own-repo workflow (layout, symlink dev loop, CI recipe with `scripts/test-external-plugin.sh`, version pinning), see [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md). For what you can rely on staying stable before contract 1.0, see [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md).
|
||||
|
||||
@@ -4,26 +4,28 @@ shopdb-flask is a framework. The plugins listed here are the pieces other GE Aer
|
||||
|
||||
## Bundled (ship with the framework)
|
||||
|
||||
These six plugins are in `plugins/` in this repo. Enable per site with `flask plugin install <name>`.
|
||||
These plugins are in `plugins/` in this repo. Enable per site with `flask plugin install <name>`.
|
||||
|
||||
| Plugin | Tracks | Notes |
|
||||
|--------|--------|-------|
|
||||
| `equipment` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
|
||||
| `machines` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
|
||||
| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). |
|
||||
| `printers` | Network and shop-floor printers | Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
|
||||
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |
|
||||
| `usb` | USB devices issued to shop-floor users | Lightweight checkout / check-in. |
|
||||
| `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. |
|
||||
|
||||
## Planned (in the roadmap, not yet built)
|
||||
|
||||
| Plugin | Tracks | Status |
|
||||
|--------|--------|--------|
|
||||
| `measuringtools` | Metrology and inspection: CMMs, Keyence vision, surface profilometers, GenSpec | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). First plugin to be built using `flask plugin new` as the canary for the scaffold. |
|
||||
| `measuringtools` | Metrology and inspection instruments: calipers, micrometers, thread/bore/height gages, indicators | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Calibration lifecycle with derived status. First plugin built on the matured scaffold; its walkthrough is [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md). Ships `default_enabled: false`. |
|
||||
|
||||
## Building your own
|
||||
|
||||
See [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) for the 30-minute walkthrough. The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
|
||||
Guides:
|
||||
|
||||
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate, customize, install, and test a plugin in 30 minutes using `flask plugin new`.
|
||||
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin, the exemplar that exercises every current framework feature (models, per-plugin migrations, authz, hooks, frontend integration, tests).
|
||||
- [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - developing a plugin in its own repo per ADR-003: repo layout, symlink dev workflow, `core_version` pinning, and a runnable CI harness (`scripts/test-external-plugin.sh`) that tests the plugin against a pinned framework ref.
|
||||
- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - path to contract 1.0: what is settled vs still churning, the bump rules, and how much a sister site can safely build on today.
|
||||
|
||||
The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
|
||||
|
||||
Quick path:
|
||||
|
||||
@@ -33,6 +35,29 @@ flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
|
||||
flask plugin install cameras
|
||||
```
|
||||
|
||||
## Migrations (per-plugin chains)
|
||||
|
||||
Each plugin that owns tables carries its own Alembic chain under
|
||||
`plugins/<name>/migrations/`, with a per-plugin version table
|
||||
`alembic_version_<name>` independent of the core `alembic_version`. Ownership is
|
||||
split at a fixed cutover (see [ADR-008](adr/ADR-008-plugin-migration-ownership.md)):
|
||||
|
||||
- The core chain (`flask db upgrade`) created every table that existed through
|
||||
its head, including the bundled-plugin tables. Each bundled plugin's `0001`
|
||||
migration is a stamp-only no-op anchor recording that fact.
|
||||
- From the cutover forward, a change to a plugin's schema lands as
|
||||
`plugins/<name>/migrations/versions/000N_*.py`, never in the core chain. The
|
||||
core chain is reserved for core tables.
|
||||
- A plugin built AFTER the cutover (e.g. `measuringtools`) is different: the
|
||||
core chain never created its tables, so its `0001` is a REAL baseline that
|
||||
creates them, not a no-op anchor. See [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) for
|
||||
the anchor-vs-baseline distinction.
|
||||
|
||||
Deploys and upgrades run `flask db upgrade` then `flask plugin upgrade-all`.
|
||||
`upgrade-all` stamps every plugin anchor and applies any later plugin
|
||||
migrations; it is idempotent. The registry (`instance/plugins.json`) records
|
||||
which revisions each plugin has applied in `migrations_applied`.
|
||||
|
||||
## Distribution conventions
|
||||
|
||||
For sister-site plugins (per [ADR-003](adr/ADR-003-plugin-distribution.md)):
|
||||
|
||||
@@ -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,28 +12,26 @@ 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.
|
||||
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `equipment` and `measuringtools` plugins).
|
||||
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `machines` and `measuringtools` plugins).
|
||||
|
||||
### Deferred (out of scope for 1.0)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,12 +51,16 @@ cd frontend && npm ci && npm run build && cd ..
|
||||
```bash
|
||||
# Docker:
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask plugin upgrade-all
|
||||
# venv:
|
||||
flask db upgrade
|
||||
flask plugin upgrade-all
|
||||
```
|
||||
|
||||
`flask db upgrade` applies any new migrations in the core Alembic chain. It is
|
||||
idempotent; running it when already at head is a no-op.
|
||||
`flask db upgrade` applies any new migrations in the core Alembic chain.
|
||||
`flask plugin upgrade-all` then applies any new per-plugin migrations (each
|
||||
bundled plugin owns its schema going forward - see ADR-008). Both are
|
||||
idempotent; running them when already at head is a no-op.
|
||||
|
||||
## Step 4: Re-seed permissions and settings
|
||||
|
||||
@@ -93,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.
|
||||
|
||||
123
docs/adr/ADR-008-plugin-migration-ownership.md
Normal file
123
docs/adr/ADR-008-plugin-migration-ownership.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ADR-008: Plugin migration ownership (per-plugin chains from the cutover)
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-10
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
|
||||
|
||||
## Context
|
||||
|
||||
ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables
|
||||
into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and
|
||||
later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the
|
||||
core chain. At the time this was the safe choice: bundled-plugin baselines and
|
||||
the core baseline had both been creating the same tables, so
|
||||
`flask plugin upgrade-all` would collide with `flask db upgrade`.
|
||||
|
||||
That resolution left the per-plugin Alembic engine
|
||||
(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the
|
||||
`alembic_version_<plugin>` version tables, and `flask plugin upgrade-all`) fully
|
||||
built but unused for the bundled plugins. The framework is the product (per the
|
||||
project's charter); a plugin that cannot own its own schema is not really a
|
||||
plugin. Sister sites that adopt or fork a plugin need its schema history to
|
||||
travel with the plugin, not be entangled in the host's core chain. Keeping every
|
||||
future plugin table change in the core chain also means the core chain grows
|
||||
without bound and a plugin can never be cleanly removed.
|
||||
|
||||
The blocker ADR-004 worried about (double table creation) only exists while a
|
||||
plugin's own migration tries to CREATE tables the core chain already created.
|
||||
That is avoidable: history is immutable, so the tables already built by the core
|
||||
chain stay owned by the core chain; only NEW schema needs a new home.
|
||||
|
||||
## Decision
|
||||
|
||||
Ownership splits at a fixed cutover, the current core-chain head.
|
||||
|
||||
1. **Core chain owns history through its head.** The core Alembic chain
|
||||
(baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains
|
||||
authoritative for every table that exists at the cutover, including the
|
||||
bundled-plugin tables it created. Those migrations are immutable and are not
|
||||
rewritten. `flask db upgrade` continues to reproduce the full schema.
|
||||
|
||||
2. **Plugin chains own plugin schema going forward.** From the cutover forward,
|
||||
any change to a plugin's schema lands as
|
||||
`plugins/<name>/migrations/versions/000N_*.py` in that plugin's own chain,
|
||||
never in the core chain. The core chain is reserved for core tables.
|
||||
|
||||
3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin
|
||||
carries a migration chain whose first revision is a stamp-only no-op:
|
||||
`upgrade()` does nothing because the core chain already created the tables.
|
||||
The anchor exists so the plugin chain has a base that
|
||||
`flask plugin upgrade-all` can stamp into the per-plugin version table
|
||||
`alembic_version_<plugin>`. Blueprint-only plugins that own no tables get no
|
||||
chain.
|
||||
|
||||
4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade`
|
||||
(core chain, creates everything through the head) then
|
||||
`flask plugin upgrade-all` (stamps every plugin anchor and applies any later
|
||||
per-plugin migrations). The same two commands upgrade an existing install;
|
||||
both are idempotent. The registry (`instance/plugins.json`) tracks each
|
||||
plugin's applied revisions in `migrations_applied`.
|
||||
|
||||
5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in
|
||||
`shopdb/plugins/alembic_template.py` is the explicit map of which tables each
|
||||
plugin owns; it is kept in sync with the plugins' `__tablename__` declarations
|
||||
and pinned by `tests/test_plugin_migrations.py`.
|
||||
|
||||
External (out-of-tree) plugins per ADR-003 already shipped their own chains;
|
||||
this ADR brings the bundled plugins onto the same model, so there is one rule
|
||||
for all plugins.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A plugin's schema history travels with the plugin. Adopting or forking sites
|
||||
get the plugin's migrations, not a slice of someone else's core chain.
|
||||
- The core chain stops accreting plugin schema; it stays about core tables.
|
||||
- A plugin can be evolved (or, with its own downgrade, removed) independently.
|
||||
- The long-built per-plugin Alembic engine is finally exercised on every deploy,
|
||||
so it cannot silently rot.
|
||||
|
||||
### Negative / cost
|
||||
|
||||
- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md`
|
||||
and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call.
|
||||
- A plugin author must now put new tables in the plugin chain and register them
|
||||
in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The
|
||||
`flask plugin new` guidance and this ADR spell that out.
|
||||
- The cutover is a discontinuity: tables created before it are core-owned,
|
||||
tables created after it are plugin-owned. The line is the core-chain head at
|
||||
this ADR's date, recorded here so it is unambiguous.
|
||||
|
||||
### Neutral
|
||||
|
||||
- No schema changes and no data migration: the anchors are no-ops. A fresh
|
||||
install and an existing install converge to the same state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Keep everything in the core chain (status quo per ADR-004).** Simplest
|
||||
operationally but defeats the plugin-as-product goal: plugin schema cannot
|
||||
travel, the core chain grows without bound, and plugins can never be cleanly
|
||||
removed. Rejected.
|
||||
2. **Rewrite history so plugin tables move out of the core chain into plugin
|
||||
`0001` CREATE migrations.** Would make each plugin chain self-contained from
|
||||
empty, but breaks the immutability rule, forces every existing site to
|
||||
re-run a rewritten chain, and re-introduces the exact double-creation footgun
|
||||
ADR-004 fixed. Rejected.
|
||||
3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty
|
||||
install build plugin tables from the plugin chain, but then two chains both
|
||||
claim the same tables and drift can diverge silently. The no-op anchor keeps
|
||||
a single authoritative creator (the core chain) for cutover-era tables.
|
||||
Rejected.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-003 (plugin distribution; external plugins already ship chains)
|
||||
- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note)
|
||||
- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner)
|
||||
- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`)
|
||||
- `plugins/<name>/migrations/` (per-plugin chains and `0001` anchors)
|
||||
- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards)
|
||||
- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence)
|
||||
146
docs/adr/ADR-009-frontend-plugin-gating.md
Normal file
146
docs/adr/ADR-009-frontend-plugin-gating.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# ADR-009: Frontend plugin gating
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-10
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
## Context
|
||||
|
||||
The backend plugin system (ADR-002, ADR-003) lets an operator disable a
|
||||
plugin. Disabling it unregisters the plugin's API blueprint, drops its
|
||||
rows from global search (see `test_search_disabled`), and removes its
|
||||
entry from `get_navigation_items()`, so the disabled feature's sidebar
|
||||
link disappears.
|
||||
|
||||
The frontend told a different story. Every plugin's Vue routes and views
|
||||
ship inside the core bundle. `frontend/src/router/index.js` auto-discovers
|
||||
them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or
|
||||
`/printers/3` is registered regardless of whether the owning backend
|
||||
plugin is enabled. A user who typed the URL, followed a stale bookmark,
|
||||
or clicked a cross-link reached a page whose API calls all 404, landing
|
||||
on a broken shell instead of a clean redirect. The navigation was already
|
||||
data-driven; direct-URL reachability was the gap.
|
||||
|
||||
There is no frontend plugin system yet. The product is packaged as one
|
||||
core Flask app plus backend-only plugins; the Vue app is monolithic and
|
||||
build-time static. Any gating has to live in core frontend code because
|
||||
that is the only place the plugin routes exist.
|
||||
|
||||
## Decision
|
||||
|
||||
### Step 1 (this ADR, implemented): route-level gating
|
||||
|
||||
Plugin-owned frontend routes are gated against the backend's enabled-plugin
|
||||
list. This is the whole of what ships now.
|
||||
|
||||
1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled`
|
||||
(`jwt_required(optional=True)`) returns a flat JSON array of enabled
|
||||
plugin name strings and nothing else. It is a cheap registry read
|
||||
(`registry.get_enabled_plugins()`), no database access. Exposing it to
|
||||
anonymous callers is safe because `GET /api/dashboard/navigation`
|
||||
already leaks the same enabled/disabled signal, and unauthenticated
|
||||
kiosk routes (`/tv`) need the answer too. It carries no metadata, so it
|
||||
reveals strictly less than the admin-gated `GET /api/plugins`.
|
||||
|
||||
2. **Route tagging.** Every plugin-owned route carries `meta.plugin =
|
||||
'<pluginname>'`. This covers the per-plugin route modules
|
||||
(`routes/computers.js`, `routes/equipment.js`, ...) and the
|
||||
plugin-owned routes that physically live in core files: the
|
||||
PC-relationships and toner reports, the slide manager and `/tv`
|
||||
dashboard (slides), the printer-QR and USB-label print pages, and the
|
||||
employee-detail page. Genuinely core routes (dashboard, search, map,
|
||||
applications, reference-data settings) stay untagged and are never
|
||||
gated.
|
||||
|
||||
3. **Cached fetch, fail-open.** A composable
|
||||
(`composables/enabledPlugins.js`) fetches the list exactly once behind
|
||||
a cached promise. If the fetch fails or returns a non-array, the code
|
||||
fails **open**: every plugin is treated as enabled. A transient API
|
||||
error must never brick navigation. The cost is that a disabled
|
||||
plugin's page is briefly reachable during an outage, which is
|
||||
acceptable because its API calls would fail anyway and the next
|
||||
successful fetch closes the gap.
|
||||
|
||||
4. **Router guard.** `router.beforeEach` awaits the cached fetch when the
|
||||
target route has `meta.plugin`. If that plugin is not enabled it
|
||||
redirects to `/` and raises an info toast. The endpoint is
|
||||
jwt-optional, so the guard works for both authenticated pages and the
|
||||
unauthenticated `/tv` kiosk route.
|
||||
|
||||
This is intentionally a thin layer. It does not change the plugin
|
||||
contract surface, so `__contract_version__` does not move: adding a core
|
||||
HTTP endpoint and tagging core-shipped routes are not plugin-contract
|
||||
changes. Plugins still ship no frontend code of their own.
|
||||
|
||||
### Non-goals for step 1
|
||||
|
||||
- No build-time or runtime loading of plugin-authored Vue code.
|
||||
- No per-permission or per-role route gating (that stays with the
|
||||
existing `requiresAuth` / `requiresAdmin` meta flags).
|
||||
- No removal of disabled routes from the route table; they remain
|
||||
registered and are intercepted by the guard. Keeping them registered
|
||||
avoids a rebuild when a plugin is toggled and keeps the redirect path
|
||||
simple.
|
||||
|
||||
## Future direction (PROPOSED, not implemented)
|
||||
|
||||
Step 1 gates routes that core already owns. The longer-term goal is a
|
||||
real frontend-plugin contract where a plugin ships its own frontend and
|
||||
core discovers it, mirroring the backend model. Sketch:
|
||||
|
||||
1. **Plugin-owned frontend tree.** Each plugin gains a
|
||||
`plugins/<name>/frontend/` directory holding its route module, views,
|
||||
and any plugin-specific components. Core stops carrying `views/usb`,
|
||||
`views/printers`, and so on.
|
||||
|
||||
2. **Build-time discovery.** The Vite build discovers plugin frontends
|
||||
with a glob over `plugins/*/frontend/routes.js` (analogous to today's
|
||||
`import.meta.glob('./routes/*.js')`), so a plugin's presence in the
|
||||
tree is what puts its routes in the bundle. Combined with the step-1
|
||||
enabled-list gate, a plugin that is absent from the build ships no
|
||||
code and a plugin that is present-but-disabled is route-gated at
|
||||
runtime.
|
||||
|
||||
3. **Shared component + registration contract.** Plugins register into
|
||||
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 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.
|
||||
|
||||
4. **Versioned frontend contract.** The shared-component and
|
||||
registration surface would be versioned the same way the backend
|
||||
contract is (ADR-002), so a plugin frontend can declare the core
|
||||
frontend range it needs.
|
||||
|
||||
### Tradeoffs of the future direction
|
||||
|
||||
- **Pro:** true plugin self-containment; a site can drop in or remove a
|
||||
plugin (frontend and backend together) without patching core; smaller
|
||||
core; clearer ownership.
|
||||
- **Con:** significant build-system work (per-plugin Vite entry
|
||||
discovery, code-splitting, dev-server HMR across the plugin tree); a
|
||||
new versioned frontend contract to maintain and document; a migration
|
||||
that moves ten plugins' worth of views out of core; risk of a leaky
|
||||
shared-component surface becoming an accidental contract. The payoff
|
||||
only matters once external (out-of-tree) plugins with their own
|
||||
frontends are a real requirement. Until then, step 1's route gating
|
||||
delivers the user-visible correctness (no reachable dead pages) at a
|
||||
fraction of the cost.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Disabling a backend plugin now makes its frontend routes redirect to
|
||||
the dashboard instead of loading a broken shell. Behavior matches the
|
||||
already-dynamic navigation.
|
||||
- One extra lightweight request at app boot (`GET /api/plugins/enabled`),
|
||||
cached for the session.
|
||||
- Fail-open means gating is a UX guardrail, not a security control. It is
|
||||
not a substitute for backend authorization: the API still enforces auth
|
||||
and the disabled plugin's endpoints are simply unregistered. Never rely
|
||||
on route gating to protect data.
|
||||
- The future frontend-plugin contract remains open work; this ADR records
|
||||
the direction and its cost so a later decision can pick it up.
|
||||
320
docs/adr/ADR-010-frontend-plugin-hooks.md
Normal file
320
docs/adr/ADR-010-frontend-plugin-hooks.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# ADR-010: Frontend plugin hook contract
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-11
|
||||
- **Accepted:** 2026-07-11
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
## Context
|
||||
|
||||
The backend plugin contract is settled (ADR-002 surface, ADR-006 collector,
|
||||
ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is
|
||||
the frontend: how a plugin contributes UI without hand-editing core Vue files.
|
||||
CONTRACT-STABILITY.md names this the single biggest churn item ("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").
|
||||
|
||||
The `measuringtools` plugin (bundled 2026-07-11, whose construction is narrated
|
||||
in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly
|
||||
**two hand edits to core frontend files**:
|
||||
|
||||
1. `frontend/src/api/index.js` - an api-client block appended after
|
||||
`warrantyApi` (PLUGIN-GUIDE.md section 9). The scaffolder now emits a
|
||||
paste-in snippet, but it is still a hand edit to a shared, churn-heavy file.
|
||||
2. `frontend/src/views/settings/settingsNav.js` - a card entry in the hardcoded
|
||||
`settingsGroups` catalog (the "Measuring Tools" group, ~line 51), consumed by
|
||||
`SettingsLayout` (left rail) and `SettingsIndex` (landing overview).
|
||||
|
||||
Everything else integrated with **zero** core edits, because a mechanism already
|
||||
existed for it:
|
||||
|
||||
| Capability | Existing mechanism |
|
||||
|---|---|
|
||||
| Views / routes | Auto-discovery: `frontend/src/router/index.js` globs `router/routes/*.js` via `import.meta.glob`; a route starting `settings/` auto-nests under the settings shell. |
|
||||
| Disabled-plugin gating | `meta: { plugin: '<name>' }` per ADR-009; router guard redirects when the backend plugin is off. |
|
||||
| Sidebar / dashboard / reports | Declarative backend hooks rendered by core: `get_navigation_items`, `get_dashboard_widgets`, `get_reports` on `BasePlugin` (shopdb/plugins/base.py), merged by `shopdb/core/api/dashboard.py` and `shopdb/core/api/reports.py`. |
|
||||
| Map placement | Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code. |
|
||||
|
||||
Capabilities a plugin **cannot have at all today** - no hook exists, and adding
|
||||
one would mean forking a core view:
|
||||
|
||||
3. **Asset-detail extension panels.** A warranty-coverage section on a PC or
|
||||
printer detail page, a calibration-status card on a measuring tool. Today
|
||||
`WarrantyPanel.vue` is composed in by hand-editing each detail view.
|
||||
4. **Map marker / overlay contributions.** A calibration-due badge on the
|
||||
shop-floor map (`frontend/src/views/map/MapView.vue`). The map draws type
|
||||
colors but has no plugin decoration path.
|
||||
5. **Search-result rendering / routing for plugin asset types.** Global search
|
||||
returns assets, but core hardcodes how each type renders and where its detail
|
||||
link points; a plugin asset type has no way to declare its icon or route.
|
||||
|
||||
There is also an unsolved distribution wrinkle from ADR-003: external plugins
|
||||
symlink into `plugins/<name>/` backend-only, but any frontend file they carry
|
||||
must be physically copied into `frontend/src/`, because Vite compiles the tree
|
||||
at build time and cannot reach outside it.
|
||||
|
||||
## Options considered
|
||||
|
||||
### A. Runtime dynamic component registration
|
||||
|
||||
Plugins ship real Vue components that core loads and mounts at runtime into
|
||||
named extension points (an `iconMap` registration, a panel registry, a marker
|
||||
renderer registry). This is the richest model and the one ADR-009's "Future
|
||||
direction" sketched.
|
||||
|
||||
- Pro: a plugin can render anything; no core generic-renderer ceiling.
|
||||
- Con: requires runtime module loading of plugin-authored code (dynamic import
|
||||
of built chunks), a versioned shared-component surface that becomes an
|
||||
accidental contract the moment it leaks, and it does nothing about the
|
||||
build-time-only reach of Vite for external plugins. It is the most code for
|
||||
the least near-term payoff. ADR-009 already priced this and deferred it.
|
||||
|
||||
### B. Declarative data-only hooks rendered by core generic components
|
||||
|
||||
Plugins return plain dicts from new `BasePlugin` hooks; a generic core component
|
||||
renders them. This is the exact precedent already proven three times:
|
||||
`get_navigation_items`, `get_dashboard_widgets`, and `get_reports` (0.6.0) all
|
||||
return dicts that a core consumer endpoint merges and a core Vue component
|
||||
renders. No plugin ships frontend code.
|
||||
|
||||
- Pro: additive, minor-bump changes under ADR-002; identical access pattern to
|
||||
the existing consumers (skip disabled, fail-loud in dev, isolate in prod);
|
||||
works untouched for external symlink-only plugins, because the data crosses
|
||||
the wire and core owns the renderer.
|
||||
- Con: bounded to what a generic renderer can draw. A panel that needs a bespoke
|
||||
chart or a custom-interaction map overlay does not fit.
|
||||
|
||||
### C. Build-time file-convention discovery
|
||||
|
||||
Extend the `import.meta.glob` precedent from `./routes/*.js` to a plugin-owned
|
||||
frontend tree (`plugins/*/frontend/`), so a plugin's real components (full
|
||||
list/detail/form views, its api-client module) live with the plugin and the
|
||||
build picks them up by convention. This is ADR-009 "Future direction" steps 1-2.
|
||||
|
||||
- Pro: true self-containment for genuine components; the natural home for the
|
||||
api-client block (friction 1) and full views.
|
||||
- Con: significant build-system work (per-plugin Vite entry discovery,
|
||||
code-splitting, dev-server HMR across the tree) and it does not by itself
|
||||
solve the external-plugin wrinkle - a symlinked out-of-tree `frontend/` is
|
||||
still outside Vite's compiled root. Only pays off once out-of-tree plugins
|
||||
with frontends are a real requirement.
|
||||
|
||||
### Hybrid
|
||||
|
||||
The evidence splits cleanly. The five friction points fall into two buckets:
|
||||
surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5),
|
||||
and surfaces where a real component is genuinely unavoidable (full views, and
|
||||
the api-client module in friction 1). Option B fits the first bucket exactly and
|
||||
is cheap and additive. Option C is the right long-term answer for the second but
|
||||
is expensive and, per ADR-009, gated on external-plugin demand. Option A buys
|
||||
nothing B does not, at the highest cost. The decision is B now, C deferred, A
|
||||
not pursued.
|
||||
|
||||
## Decision
|
||||
|
||||
**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
|
||||
component registration (Option A).
|
||||
|
||||
### New hooks (Option B, data-only, additive)
|
||||
|
||||
Each is a new optional `BasePlugin` method returning a list of dicts, merged by
|
||||
a core consumer endpoint using the same pattern as `get_reports` (inject
|
||||
`plugin` name, skip disabled, re-raise in dev/test, log-and-isolate in prod),
|
||||
and rendered by a generic core component. Icon values are string keys mapped to
|
||||
Lucide components core-side, exactly like `get_navigation_items`.
|
||||
|
||||
**`get_settings_cards`** - resolves friction point 2 (settingsNav.js hand edit).
|
||||
|
||||
```
|
||||
{
|
||||
'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
|
||||
}
|
||||
```
|
||||
|
||||
Consumer: a new `GET /api/settings/cards` merges enabled plugins' cards into the
|
||||
core `settingsGroups` catalog; `SettingsLayout` and `SettingsIndex` read the
|
||||
merged catalog instead of the hardcoded JS array. The catalog's own core groups
|
||||
stay in `settingsNav.js`; plugin groups are appended.
|
||||
|
||||
**`get_asset_panels`** - resolves friction point 3 (asset-detail panels).
|
||||
|
||||
```
|
||||
{
|
||||
'id': 'calibration',
|
||||
'title': 'Calibration',
|
||||
'assettypes': ['measuring_tool'], # detail pages it appears on; ['*'] = all
|
||||
'endpoint': '/api/measuringtools/{assetid}/calibration-panel',
|
||||
'render': 'keyvalue', # 'keyvalue' | 'table' | 'badge'
|
||||
'position': 20,
|
||||
}
|
||||
```
|
||||
|
||||
Consumer: `GET /api/assets/{assetid}/panels` returns the panels whose
|
||||
`assettypes` match that asset's type; a generic `AssetPanel` component on the
|
||||
detail page fetches each `endpoint` and renders it in the declared style. This
|
||||
covers the warranty-coverage and calibration-status cases. A panel that needs
|
||||
bespoke UI (a chart) is out of scope for the data-only hook and falls to the
|
||||
deferred component mechanism below - stated honestly, not hidden.
|
||||
|
||||
**`get_map_overlays`** - resolves friction point 4 (map marker/overlay badges).
|
||||
|
||||
```
|
||||
{
|
||||
'id': 'calibration-due',
|
||||
'label': 'Calibration due', # legend label
|
||||
'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
|
||||
'style': 'badge', # 'badge' | 'ring'
|
||||
'legend': True,
|
||||
}
|
||||
```
|
||||
|
||||
Consumer: `MapView.vue` fetches enabled plugins' overlay endpoints and decorates
|
||||
the already-placed markers; legend entries append to the existing legend. The
|
||||
map stays data-driven; plugins add decoration data, not map code.
|
||||
|
||||
**`get_asset_presentation`** - resolves friction point 5 (search rendering /
|
||||
routing) and, as a bonus, removes the `AppLayout.vue` iconMap hand edit called
|
||||
out in PLUGIN-GUIDE.md section 9.
|
||||
|
||||
```
|
||||
{
|
||||
'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns
|
||||
'icon': 'ruler',
|
||||
'label': 'Measuring Tool',
|
||||
'route': '/measuringtools/{assetid}', # detail-route pattern
|
||||
}
|
||||
```
|
||||
|
||||
Consumer: `GET /api/assets/presentation` returns the type-to-presentation map;
|
||||
global-search result rows and any asset cross-link use it to pick the icon and
|
||||
build the detail link, so core never hardcodes a plugin's route or icon.
|
||||
|
||||
### Friction map
|
||||
|
||||
| Friction point | Mechanism | Hook / change |
|
||||
|---|---|---|
|
||||
| 1. api-client block in api/index.js | C (deferred) | plugin-owned `frontend/` tree, glob-discovered; scaffolder snippet is the near-term mitigation |
|
||||
| 2. settingsNav.js card entry | B (now) | `get_settings_cards` |
|
||||
| 3. asset-detail panels | B (now) | `get_asset_panels` (bespoke panels -> C, deferred) |
|
||||
| 4. map markers / overlays | B (now) | `get_map_overlays` (bespoke overlays -> C, deferred) |
|
||||
| 5. search-result rendering / routing | B (now) | `get_asset_presentation` |
|
||||
| views (full list/detail/form) | C (deferred) | already glob-discovered under core `routes/*.js`; long-term move to `plugins/*/frontend/` per ADR-009 |
|
||||
|
||||
### Deferred: file-convention frontend tree (Option C)
|
||||
|
||||
Extending `import.meta.glob` from `./routes/*.js` to `plugins/*/frontend/` (so
|
||||
views and the api-client module live with the plugin) is ADR-009 "Future
|
||||
direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is
|
||||
build-system-heavy and the payoff only lands once out-of-tree plugins with their
|
||||
own frontends are a real requirement. Until then, friction 1 stays mitigated by
|
||||
the scaffolder snippet, and full views keep shipping in the core bundle and
|
||||
gated by ADR-009 `meta.plugin`.
|
||||
|
||||
This is also the honest limit on the external-plugin wrinkle. The four data-only
|
||||
hooks need **zero** frontend files from a plugin, so an external symlink-only
|
||||
(backend) plugin gets settings cards, detail panels, map overlays, and search
|
||||
presentation with no copy-into-`frontend/src/` step at all. The wrinkle survives
|
||||
only for the residual component-backed cases, which is exactly the deferred
|
||||
Option C work; pushing external-repo frontend distribution to that later ADR
|
||||
matches ADR-003's posture of deferring out-of-tree packaging until two sites run
|
||||
their own plugins.
|
||||
|
||||
### Contract-version impact (ADR-002)
|
||||
|
||||
Each of the four hooks is a new optional `BasePlugin` method - an additive
|
||||
change, so a **minor** bump per ADR-002, the same classification `get_reports`
|
||||
took at 0.6.0. Landing all four together is a single minor bump (proposed
|
||||
**0.7.0**); landing them incrementally is one minor bump each. Adding the core
|
||||
consumer endpoints and the generic renderer components is core-internal and does
|
||||
not itself move `__contract_version__`. The deferred Option C introduces a
|
||||
separate, versioned **frontend** contract (ADR-009 step 4), tracked apart from
|
||||
the backend `__contract_version__`; it is not part of this proposal's bumps.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a
|
||||
generic renderer can serve, using the already-proven declarative pattern - low
|
||||
risk, low cost, additive-only.
|
||||
- External symlink-only plugins get four presentation surfaces with no
|
||||
copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle.
|
||||
- Removes three of the two-plus hand edits the exemplar needed (settingsNav
|
||||
card, plus the AppLayout iconMap edit from section 9), moving them to data.
|
||||
- Docs-drift guard forces documentation: `tests/test_docs_contract.py` fails if
|
||||
any new public `BasePlugin` hook is missing from `docs/PLUGIN-HOOKS.md`, so the
|
||||
hooks cannot ship undocumented.
|
||||
|
||||
### Negative
|
||||
|
||||
- Four more hooks and four more core consumer endpoints to maintain, each with
|
||||
the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline.
|
||||
- The generic renderers set a ceiling: bespoke panels and overlays still have no
|
||||
home until Option C lands, so the contract is honest-but-partial, not total.
|
||||
- One extra request per surface on the pages that use it (a detail page fetches
|
||||
its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch.
|
||||
|
||||
### Neutral
|
||||
|
||||
- Bundled plugins keep their current in-core views; migrating them to the hooks
|
||||
is opt-in and incremental (see Adoption plan), not a flag-day rewrite.
|
||||
- `meta.plugin` route gating (ADR-009) is unchanged and still gates the views;
|
||||
these hooks add presentation, not routing.
|
||||
- Contract tests (`tests/test_plugin_contract.py`) already assert every public
|
||||
hook is exercised; the new hooks slot into that harness.
|
||||
|
||||
## Adoption plan
|
||||
|
||||
Prove the contract on bundled plugins before declaring it settled for sister
|
||||
sites.
|
||||
|
||||
1. **`get_asset_panels` first, via `warranty`.** Warranty is asset-general and
|
||||
already composes `WarrantyPanel.vue` onto multiple detail pages by hand, with
|
||||
a clean per-asset endpoint behind it. Migrating it to a declarative
|
||||
`get_asset_panels` entry rendered by the generic `AssetPanel` is the
|
||||
lowest-risk proof and immediately removes hand edits from every detail view
|
||||
that shows warranty. This is the recommended first migration.
|
||||
2. **`get_settings_cards`, `get_map_overlays`, `get_asset_presentation` via
|
||||
`measuringtools`.** The guide exemplar already needs a settings card, a
|
||||
calibration-due map badge, and search routing for its `measuring_tool` type,
|
||||
so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes
|
||||
the reference for the new hooks.
|
||||
3. Only after both plugins run on the hooks in a real build: bump
|
||||
`__contract_version__` to 0.7.0, document the hooks in
|
||||
`docs/PLUGIN-HOOKS.md`, and mark this ADR ACCEPTED.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `get_asset_panels` `render` styles stay a small closed set
|
||||
(`keyvalue` / `table` / `badge`), or grow? A closed set keeps the renderer
|
||||
generic; growth pressure is the signal that a case actually needs Option C.
|
||||
- Should the four consumer endpoints collapse into one bundled
|
||||
`GET /api/plugins/frontend-contributions` call to save round-trips, or stay
|
||||
separate per surface for cache locality? Defer until the per-surface request
|
||||
cost is measured.
|
||||
- When Option C lands, does the api-client module move under
|
||||
`plugins/*/frontend/` or get replaced entirely by a generated client from the
|
||||
backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract
|
||||
follow-up.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-001 (asset model as the map/search data source)
|
||||
- ADR-002 (bump classification for the new hooks)
|
||||
- ADR-003 (external-plugin distribution posture)
|
||||
- ADR-009 (route gating; "Future direction" is the deferred Option C)
|
||||
- docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits)
|
||||
- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers)
|
||||
- shopdb/plugins/base.py (existing declarative hooks this pattern extends)
|
||||
- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent)
|
||||
</content>
|
||||
</invoke>
|
||||
67
docs/adr/ADR-011-machines-rename.md
Normal file
67
docs/adr/ADR-011-machines-rename.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# ADR-011: Rename the equipment domain to machines; retype the models catalog with modeltypes
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-11
|
||||
- **Deciders:** cproudlock
|
||||
- **Relates to:** ADR-005 (equipment vs measuringtools scope), ADR-008 (plugin migration ownership)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-005 split the old "equipment" concept: gage-lab instruments moved to the
|
||||
measuringtools plugin, leaving the equipment plugin holding exactly one thing -
|
||||
shop-floor machines (lathes, mills, CMMs, grinders, furnaces). The UI, the
|
||||
routes (`/machines`), the frontend view folder (`views/machines/`), and even
|
||||
the data vocabulary (asset numbers headed "MACHINE #") already said machines.
|
||||
Only the code identity still said equipment: the plugin name, the API prefix,
|
||||
the `equipment`/`equipmenttypes` tables, and the `assettype='equipment'` data
|
||||
value. Every future contributor would pay a small permanent translation tax.
|
||||
|
||||
A display-label-only swap was shipped first (2026-07-11) and immediately showed
|
||||
the dissonance: UI Machines over code equipment forever.
|
||||
|
||||
Separately, the legacy `machinetypes` table survived the Machine-model
|
||||
retirement for one real reason: it types the vendor **models catalog**
|
||||
(`models.machinetypeid`, 95 of 127 models) with fine-grained, cross-category
|
||||
types (Lathe, Switch, Laser Printer). Its name was a misnomer - it never typed
|
||||
machines specifically - and it squatted on the name the machines plugin's
|
||||
subtype table wants.
|
||||
|
||||
The economics favor doing this now and never again: the plugin contract is
|
||||
pre-1.0 (breaking changes are cheap per ADR-002), no sister site has adopted
|
||||
yet, there is no production instance, and the test suite plus CI give the
|
||||
strongest safety net the project has had.
|
||||
|
||||
## Decision
|
||||
|
||||
Rename the domain in one coordinated pass:
|
||||
|
||||
| Current | New | Rationale |
|
||||
|---|---|---|
|
||||
| `plugins/equipment/`, `/api/equipment`, `equipment.*` perms | `plugins/machines/`, `/api/machines`, `machines.*` | plugin identity matches the domain |
|
||||
| `equipment` table, `equipmentid` | `machines`, `machineid` | main entity |
|
||||
| `equipmenttypes`, `equipmenttypeid`, `equipmenttype` | `machinetypes`, `machinetypeid`, `machinetype` | subtypes take the freed name |
|
||||
| legacy `machinetypes`, `models.machinetypeid` | `modeltypes`, `models.modeltypeid` | role-accurate: it types the models catalog, losslessly (no collapse to the 4 asset types) |
|
||||
| `assettypes.assettype = 'equipment'` (data) | `'machine'` | the string compare sites flip with it |
|
||||
|
||||
Migration placement follows ADR-008: the modeltypes rename and the data flips
|
||||
land in the core chain (core tables, core data); the plugin table renames land
|
||||
in the machines plugin's own chain, idempotently guarded so fresh installs
|
||||
(whose core chain still creates the old names at `7c04`) rename on first
|
||||
`flask plugin upgrade-all`. The `alembic_version_equipment` version table is
|
||||
renamed to `alembic_version_machines` by the core migration so the plugin chain
|
||||
resumes seamlessly on existing installs. The plugin registry auto-migrates an
|
||||
`equipment` key to `machines` on load.
|
||||
|
||||
Historical migrations and accepted ADRs are not rewritten; old ids and ADR-005
|
||||
wording remain as history.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Upgrading instances run `flask db upgrade` then `flask plugin upgrade-all`,
|
||||
same as every deploy; both migrations are idempotent.
|
||||
- `/api/equipment` and `equipmentApi` cease to exist - a breaking change made
|
||||
while there is nobody to break. After contract 1.0 this class of rename
|
||||
would be a major-version event; this is the last cheap moment.
|
||||
- The "Machine Types" settings card becomes "Model Types" and finally says
|
||||
what it does; the machines plugin's subtype card owns the machine-types name.
|
||||
- Contributors stop translating between UI vocabulary and code vocabulary.
|
||||
123
docs/adr/ADR-012-geenforce-manifest-ownership.md
Normal file
123
docs/adr/ADR-012-geenforce-manifest-ownership.md
Normal 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).
|
||||
@@ -20,6 +20,11 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED |
|
||||
| [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | ACCEPTED |
|
||||
| [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 | 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
|
||||
|
||||
|
||||
648
docs/proposals/ge-enforce-plugin.md
Normal file
648
docs/proposals/ge-enforce-plugin.md
Normal 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.
|
||||
208
docs/proposals/printedparts-plugin.md
Normal file
208
docs/proposals/printedparts-plugin.md
Normal 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).
|
||||
2301
frontend/package-lock.json
generated
2301
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"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",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
@@ -24,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,48 +66,51 @@ export const authApi = {
|
||||
return api.post('/auth/refresh', {}, {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||
})
|
||||
},
|
||||
changePassword(payload) {
|
||||
return api.post('/auth/change-password', payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Equipment API (plugin)
|
||||
export const equipmentApi = {
|
||||
// Machines API (plugin)
|
||||
export const machinesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/equipment', { params })
|
||||
return api.get('/machines', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/equipment/${id}`)
|
||||
return api.get(`/machines/${id}`)
|
||||
},
|
||||
getByAsset(assetId) {
|
||||
return api.get(`/equipment/by-asset/${assetId}`)
|
||||
return api.get(`/machines/by-asset/${assetId}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/equipment', data)
|
||||
return api.post('/machines', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/equipment/${id}`, data)
|
||||
return api.put(`/machines/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/equipment/${id}`)
|
||||
return api.delete(`/machines/${id}`)
|
||||
},
|
||||
dashboardSummary() {
|
||||
return api.get('/equipment/dashboard/summary')
|
||||
return api.get('/machines/dashboard/summary')
|
||||
},
|
||||
// Equipment types
|
||||
// Machine types
|
||||
types: {
|
||||
list(params = {}) {
|
||||
return api.get('/equipment/types', { params })
|
||||
return api.get('/machines/types', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/equipment/types/${id}`)
|
||||
return api.get(`/machines/types/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/equipment/types', data)
|
||||
return api.post('/machines/types', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/equipment/types/${id}`, data)
|
||||
return api.put(`/machines/types/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/equipment/types/${id}`)
|
||||
return api.delete(`/machines/types/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,19 +192,19 @@ export const relationshipTypesApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Machine Types API
|
||||
export const machinetypesApi = {
|
||||
// Model Types API (the vendor models catalog: modeltypeid)
|
||||
export const modeltypesApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/machinetypes', { params })
|
||||
return api.get('/modeltypes', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/machinetypes', data)
|
||||
return api.post('/modeltypes', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/machinetypes/${id}`, data)
|
||||
return api.put(`/modeltypes/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/machinetypes/${id}`)
|
||||
return api.delete(`/modeltypes/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,9 +685,6 @@ export const usbApi = {
|
||||
update(id, data) {
|
||||
return api.put(`/usb/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/usb/${id}`)
|
||||
},
|
||||
checkout(id, data) {
|
||||
return api.post(`/usb/${id}/checkout`, data)
|
||||
},
|
||||
@@ -658,25 +694,10 @@ export const usbApi = {
|
||||
getHistory(id, params = {}) {
|
||||
return api.get(`/usb/${id}/history`, { params })
|
||||
},
|
||||
getAvailable() {
|
||||
return api.get('/usb/available')
|
||||
},
|
||||
getCheckedOut() {
|
||||
return api.get('/usb/checkedout')
|
||||
},
|
||||
getUserCheckouts(userId) {
|
||||
return api.get(`/usb/user/${userId}`)
|
||||
},
|
||||
dashboardSummary() {
|
||||
return api.get('/usb/dashboard/summary')
|
||||
},
|
||||
types: {
|
||||
list() {
|
||||
return api.get('/usb/types')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/usb/types', data)
|
||||
}
|
||||
// check-out log rows for one badge; activeonly = only currently-held devices
|
||||
getUserCheckouts(badge, activeonly = true) {
|
||||
const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts'
|
||||
return api.get(path, { params: { badge } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,8 +706,8 @@ export const reportsApi = {
|
||||
list() {
|
||||
return api.get('/reports')
|
||||
},
|
||||
equipmentByType(params = {}) {
|
||||
return api.get('/reports/equipment-by-type', { params })
|
||||
machinesByType(params = {}) {
|
||||
return api.get('/reports/machines-by-type', { params })
|
||||
},
|
||||
assetsByStatus(params = {}) {
|
||||
return api.get('/reports/assets-by-status', { params })
|
||||
@@ -694,9 +715,6 @@ export const reportsApi = {
|
||||
kbPopularity(params = {}) {
|
||||
return api.get('/reports/kb-popularity', { params })
|
||||
},
|
||||
warrantyStatus(params = {}) {
|
||||
return api.get('/reports/warranty-status', { params })
|
||||
},
|
||||
softwareCompliance(params = {}) {
|
||||
return api.get('/reports/software-compliance', { params })
|
||||
},
|
||||
@@ -705,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +799,10 @@ export const pluginsApi = {
|
||||
list() {
|
||||
return api.get('/plugins')
|
||||
},
|
||||
// Flat array of enabled plugin names. jwt-optional, safe for kiosk routes.
|
||||
enabled() {
|
||||
return api.get('/plugins/enabled')
|
||||
},
|
||||
setEnabled(name, enabled) {
|
||||
return api.put(`/plugins/${name}`, { enabled })
|
||||
}
|
||||
@@ -800,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)
|
||||
},
|
||||
@@ -889,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
|
||||
@@ -1026,3 +1083,46 @@ export const warrantyApi = {
|
||||
return api.get('/warranty/report')
|
||||
}
|
||||
}
|
||||
|
||||
// Measuring tools API (plugin)
|
||||
export const measuringtoolsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/${id}`)
|
||||
},
|
||||
getByAsset(assetid) {
|
||||
return api.get(`/measuringtools/by-asset/${assetid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/${id}`)
|
||||
},
|
||||
calibrationReport() {
|
||||
return api.get('/measuringtools/report/calibration')
|
||||
},
|
||||
// Measuring-tool types
|
||||
types: {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools/types', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/types/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools/types', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/types/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/types/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Reset and base styles */
|
||||
@import url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap');
|
||||
/* Inter is bundled locally via @fontsource-variable/inter (imported in main.js) */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
@@ -370,6 +370,8 @@ th, td {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
border-top: 1px solid var(--border);
|
||||
/* line up digits in columns (Inter supports tabular figures) */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Cap a long free-text column (e.g. Description) so it truncates with an
|
||||
@@ -622,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 */
|
||||
@@ -939,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)
|
||||
@@ -974,7 +985,7 @@ td.actions {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg);
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -1049,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 */
|
||||
@@ -1068,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 {
|
||||
@@ -1081,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;
|
||||
@@ -1112,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;
|
||||
@@ -1135,26 +1376,7 @@ td.actions {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Connected Device */
|
||||
.connected-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.device-link:hover .device-name {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
/* Device icon (network detail) */
|
||||
.device-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -1171,75 +1393,6 @@ td.actions {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.device-alias {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.connection-type {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 11px;
|
||||
background: rgba(4, 185, 98, 0.2);
|
||||
color: var(--success);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Equipment List */
|
||||
.equipment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.equipment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.equipment-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.equipment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.equipment-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.equipment-alias {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 11px;
|
||||
background: rgba(4, 185, 98, 0.2);
|
||||
color: var(--success);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Network */
|
||||
.network-list {
|
||||
display: flex;
|
||||
@@ -1249,7 +1402,8 @@ td.actions {
|
||||
|
||||
.network-item {
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1345,7 +1499,7 @@ td.actions {
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
column-count: 1;
|
||||
}
|
||||
|
||||
.audit-footer {
|
||||
@@ -1572,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 */
|
||||
@@ -1597,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); }
|
||||
|
||||
@@ -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?.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?.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"
|
||||
>
|
||||
×
|
||||
@@ -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?.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?.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">-></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"><-</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"
|
||||
>
|
||||
×
|
||||
@@ -173,7 +187,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
|
||||
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
|
||||
import { assetsApi, relationshipTypesApi } from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -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) {
|
||||
@@ -384,10 +463,11 @@ function closeModal() {
|
||||
|
||||
function getAssetIcon(assettype) {
|
||||
const icons = {
|
||||
'equipment': Cog,
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
@@ -396,21 +476,22 @@ function getAssetRoute(asset) {
|
||||
if (!asset) return '#'
|
||||
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network'
|
||||
'network_device': '/network',
|
||||
'measuring_tool': '/measuringtools'
|
||||
}
|
||||
|
||||
const basePath = routeMap[asset.assettype] || '/assets'
|
||||
// serializer sends assettypename; old shape used assettype
|
||||
const typename = asset.assettypename || asset.assettype
|
||||
const basePath = routeMap[typename] || '/assets'
|
||||
|
||||
// Use typedata ID if available
|
||||
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
|
||||
return `/network/${asset.typedata.networkdeviceid}`
|
||||
}
|
||||
|
||||
// For equipment/computer/printer, use machineid from typedata or assetid
|
||||
const id = asset.typedata?.machineid || asset.assetid
|
||||
// pluginid is the extension-table id the detail routes key on
|
||||
const id = asset.pluginid
|
||||
|| asset.typedata?.networkdeviceid
|
||||
|| asset.typedata?.machineid
|
||||
|| asset.assetid
|
||||
return `${basePath}/${id}`
|
||||
}
|
||||
</script>
|
||||
@@ -421,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 {
|
||||
@@ -497,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;
|
||||
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
53
frontend/src/components/EmailReportButton.vue
Normal 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>
|
||||
File diff suppressed because it is too large
Load Diff
42
frontend/src/composables/enabledPlugins.js
Normal file
42
frontend/src/composables/enabledPlugins.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
|
||||
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
|
||||
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
|
||||
// enabled, so an API blip can never brick navigation.
|
||||
import { pluginsApi } from '../api'
|
||||
|
||||
let enabledPromise = null
|
||||
let enabledSet = null // resolved Set<string>, or null while pending / on failure
|
||||
|
||||
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
|
||||
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
||||
export function loadEnabledPlugins() {
|
||||
if (!enabledPromise) {
|
||||
enabledPromise = pluginsApi.enabled()
|
||||
.then(response => {
|
||||
const names = response?.data?.data
|
||||
// Only trust a real array; anything else -> fail open (null).
|
||||
enabledSet = Array.isArray(names) ? new Set(names) : null
|
||||
return enabledSet
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail open: leave enabledSet null so isPluginEnabled returns true.
|
||||
enabledSet = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return enabledPromise
|
||||
}
|
||||
|
||||
// True if the plugin is enabled OR the list is unknown (fail-open). A null
|
||||
// enabledSet means we never got a trustworthy answer, so allow everything.
|
||||
export function isPluginEnabled(name) {
|
||||
if (!name) return true
|
||||
if (enabledSet === null) return true
|
||||
return enabledSet.has(name)
|
||||
}
|
||||
|
||||
// Test/hot-reload hook: forget the cached result so the next call refetches.
|
||||
export function resetEnabledPlugins() {
|
||||
enabledPromise = null
|
||||
enabledSet = null
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const state = reactive({
|
||||
|
||||
let inflight = null
|
||||
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(equipment|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)
|
||||
|
||||
102
frontend/src/composables/listQuery.js
Normal file
102
frontend/src/composables/listQuery.js
Normal 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 }
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
86
frontend/src/composables/settingsCatalog.js
Normal file
86
frontend/src/composables/settingsCatalog.js
Normal 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 }
|
||||
}
|
||||
359
frontend/src/composables/systemSettings.js
Normal file
359
frontend/src/composables/systemSettings.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// Locally-bundled Inter (air-gap safe, no Google Fonts fetch)
|
||||
import '@fontsource-variable/inter'
|
||||
|
||||
// Initialize theme on app load
|
||||
import './stores/theme'
|
||||
import { applyBranding } from './utils/siteSettings'
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAuthStore } from '../stores/auth'
|
||||
import AppLayout from '../views/AppLayout.vue'
|
||||
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
||||
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
|
||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||
import { useToast } from '../composables/toast'
|
||||
|
||||
// Auto-discover all route modules from routes/ directory
|
||||
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
||||
@@ -43,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',
|
||||
@@ -59,28 +68,46 @@ const routes = [
|
||||
{
|
||||
path: '/tv',
|
||||
name: 'tv',
|
||||
component: () => import('../views/TVDashboard.vue')
|
||||
component: () => import('../views/TVDashboard.vue'),
|
||||
meta: { plugin: 'slides' }
|
||||
},
|
||||
// Print pages (standalone, no sidebar/header)
|
||||
{
|
||||
path: '/print/equipment-badge/:id',
|
||||
name: 'print-equipment-badge',
|
||||
component: () => import('../views/print/EquipmentBadge.vue')
|
||||
path: '/print/machine-badge/:id',
|
||||
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',
|
||||
component: () => import('../views/print/PrinterQRBatch.vue')
|
||||
component: () => import('../views/print/PrinterQRBatch.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr/:id',
|
||||
name: 'print-printer-qr-single',
|
||||
component: () => import('../views/print/PrinterQRSingle.vue')
|
||||
component: () => import('../views/print/PrinterQRSingle.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/usb-labels',
|
||||
name: 'print-usb-labels',
|
||||
component: () => import('../views/print/USBLabelBatch.vue')
|
||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -90,7 +117,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
@@ -99,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('/')
|
||||
@@ -108,6 +136,24 @@ 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).
|
||||
if (to.meta.plugin) {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled(to.meta.plugin)) {
|
||||
useToast().info(`The ${to.meta.plugin} feature is not enabled.`)
|
||||
return next('/')
|
||||
}
|
||||
}
|
||||
|
||||
// First-run: steer a fresh admin into the setup wizard (once, until finished).
|
||||
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
||||
if (!isSetupLoaded()) {
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('../../views/pcs/PCsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('../../views/pcs/PCDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('../../views/settings/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('../../views/settings/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('../../views/settings/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('../../views/pcs/PCsList.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('../../views/pcs/PCDetail.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('../../views/settings/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('../../views/settings/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('../../views/settings/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,17 +31,20 @@ export default [
|
||||
{
|
||||
path: 'reports/pc-relationships',
|
||||
name: 'report-pc-relationships',
|
||||
component: () => import('../../views/reports/PCRelationshipsReport.vue')
|
||||
component: () => import('../../views/reports/PCRelationshipsReport.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner',
|
||||
name: 'toner-report',
|
||||
component: () => import('../../views/reports/TonerReport.vue')
|
||||
component: () => import('../../views/reports/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'employees/:sso',
|
||||
name: 'employee-detail',
|
||||
component: () => import('../../views/employees/EmployeeDetail.vue')
|
||||
component: () => import('../../views/employees/EmployeeDetail.vue'),
|
||||
meta: { plugin: 'employees' }
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
@@ -57,9 +60,9 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/machinetypes',
|
||||
name: 'machinetypes',
|
||||
component: () => import('../../views/settings/MachineTypesList.vue'),
|
||||
path: 'settings/modeltypes',
|
||||
name: 'modeltypes',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
@@ -86,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',
|
||||
@@ -102,12 +111,12 @@ export default [
|
||||
path: 'settings/slides',
|
||||
name: 'slide-manager',
|
||||
component: () => import('../../views/settings/SlideManager.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' }
|
||||
},
|
||||
{
|
||||
path: 'settings/equipmenttypes',
|
||||
name: 'equipment-types',
|
||||
component: () => import('../../views/settings/EquipmentTypesList.vue'),
|
||||
path: 'settings/machinetypes',
|
||||
name: 'machinetypes',
|
||||
component: () => import('../../views/settings/MachineTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
@@ -152,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 }
|
||||
},
|
||||
{
|
||||
|
||||
30
frontend/src/router/routes/geenforce.js
Normal file
30
frontend/src/router/routes/geenforce.js
Normal 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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Knowledge Base routes (core feature)
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue')
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Knowledge Base plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Equipment plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: () => import('../../views/machines/MachinesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: () => import('../../views/machines/MachineDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Machines plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: () => import('../../views/machines/MachinesList.vue'),
|
||||
meta: { plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: () => import('../../views/machines/MachineDetail.vue'),
|
||||
meta: { plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'machines' }
|
||||
}
|
||||
]
|
||||
54
frontend/src/router/routes/measuringtools.js
Normal file
54
frontend/src/router/routes/measuringtools.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Measuring-tools plugin routes.
|
||||
*
|
||||
* Every route carries meta.plugin = 'measuringtools' so the ADR-009 router
|
||||
* guard redirects to the dashboard when the backend plugin is disabled. List
|
||||
* and detail are public (no requiresAuth, matching the other asset types); the
|
||||
* form requires auth; the settings subtype page requires admin.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'measuringtools',
|
||||
name: 'measuringtools',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/new',
|
||||
name: 'measuringtool-new',
|
||||
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',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/:id/edit',
|
||||
name: 'measuringtool-edit',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'reports/calibration',
|
||||
name: 'calibration-report',
|
||||
component: () => import('../../views/reports/CalibrationReport.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'settings/measuringtooltypes',
|
||||
name: 'measuringtooltypes',
|
||||
component: () => import('../../views/settings/MeasuringToolTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
|
||||
}
|
||||
]
|
||||
@@ -1,40 +1,53 @@
|
||||
/**
|
||||
* Network plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkDevicesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'network/new',
|
||||
name: 'network-new',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'network/:id',
|
||||
name: 'network-detail',
|
||||
component: () => import('../../views/network/NetworkDeviceDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'network/:id/edit',
|
||||
name: 'network-edit',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// Network-specific settings
|
||||
{
|
||||
path: 'settings/vlans',
|
||||
name: 'vlans',
|
||||
component: () => import('../../views/settings/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/subnets',
|
||||
name: 'subnets',
|
||||
component: () => import('../../views/settings/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Network plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkHub.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/new',
|
||||
name: 'network-new',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id',
|
||||
name: 'network-detail',
|
||||
component: () => import('../../views/network/NetworkDeviceDetail.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id/edit',
|
||||
name: 'network-edit',
|
||||
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',
|
||||
name: 'vlans',
|
||||
component: () => import('../../views/settings/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'settings/subnets',
|
||||
name: 'subnets',
|
||||
component: () => import('../../views/settings/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('../../views/notifications/NotificationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('../../views/notifications/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../../views/CalendarView.vue')
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('../../views/notifications/NotificationsList.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('../../views/notifications/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../../views/CalendarView.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
}
|
||||
]
|
||||
|
||||
35
frontend/src/router/routes/printedparts.js
Normal file
35
frontend/src/router/routes/printedparts.js
Normal 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/PrintedpartsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
@@ -1,40 +1,42 @@
|
||||
/**
|
||||
* Printers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: () => import('../../views/printers/PrintersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: () => import('../../views/printers/PrinterDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
name: 'printer-drivers',
|
||||
component: () => import('../../views/settings/PrinterDriversList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Printers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: () => import('../../views/printers/PrintersList.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: () => import('../../views/printers/PrinterDetail.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
name: 'printer-drivers',
|
||||
component: () => import('../../views/settings/PrinterDriversList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* USB plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'usb',
|
||||
name: 'usb',
|
||||
component: () => import('../../views/usb/USBList.vue')
|
||||
},
|
||||
{
|
||||
path: 'usb/new',
|
||||
name: 'usb-new',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id',
|
||||
name: 'usb-detail',
|
||||
component: () => import('../../views/usb/USBDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'usb/:id/edit',
|
||||
name: 'usb-edit',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* USB plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'usb',
|
||||
name: 'usb',
|
||||
component: () => import('../../views/usb/USBList.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/new',
|
||||
name: 'usb-new',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id',
|
||||
name: 'usb-detail',
|
||||
component: () => import('../../views/usb/USBDetail.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id/edit',
|
||||
name: 'usb-edit',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,11 +6,12 @@ export default [
|
||||
path: 'warranties',
|
||||
name: 'warranties',
|
||||
component: () => import('../../views/warranty/WarrantiesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { plugin: 'warranty' }
|
||||
},
|
||||
{
|
||||
path: 'reports/warranty',
|
||||
name: 'warranty-report',
|
||||
component: () => import('../../views/reports/WarrantyReport.vue')
|
||||
component: () => import('../../views/reports/WarrantyReport.vue'),
|
||||
meta: { plugin: 'warranty' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -4,28 +4,31 @@
|
||||
// because the API sends the underscore form and some map data the spaced form.
|
||||
|
||||
const ASSET_TYPE_LABELS = {
|
||||
'equipment': 'Equipment',
|
||||
'machine': 'Machines',
|
||||
'computer': 'Computers',
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'network device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ROUTES = {
|
||||
'equipment': '/machines',
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network',
|
||||
'measuring_tool': '/measuringtools',
|
||||
}
|
||||
|
||||
// Plugin-specific id field inside asset.typedata for each asset type.
|
||||
const ASSET_TYPE_ID_KEYS = {
|
||||
'equipment': 'equipmentid',
|
||||
'machine': 'machineid',
|
||||
'computer': 'computerid',
|
||||
'printer': 'printerid',
|
||||
'network_device': 'networkdeviceid',
|
||||
'network device': 'networkdeviceid',
|
||||
'measuring_tool': 'measuringtoolid',
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
|
||||
25
frontend/src/utils/basePath.js
Normal file
25
frontend/src/utils/basePath.js
Normal 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
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
// ShopFloorMap.vue - keep the two in sync if the palette changes.
|
||||
|
||||
export const assetTypeColorsMap = {
|
||||
equipment: '#F44336', // Red
|
||||
machine: '#F44336', // Red
|
||||
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'
|
||||
@@ -15,7 +17,7 @@ const DEFAULT_COLOR = '#BDBDBD'
|
||||
// Canonical form for comparing asset-type strings. The API sends the machine
|
||||
// type as 'network_device' but subtype keys and labels use 'network device',
|
||||
// so normalize underscores to spaces before any comparison. Without this,
|
||||
// network-device subtypes silently fail to match (equipment/computer/printer
|
||||
// network-device subtypes silently fail to match (machine/computer/printer
|
||||
// are single words and were unaffected, which is why only network broke).
|
||||
export function normalizeAssetType(assettype) {
|
||||
return (assettype || '').toLowerCase().replace(/_/g, ' ')
|
||||
@@ -33,10 +35,11 @@ export function getAssetTypeColor(assettype) {
|
||||
export function getSubtypeId(asset) {
|
||||
if (!asset || !asset.typedata) return null
|
||||
const typeLower = normalizeAssetType(asset.assettype)
|
||||
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
|
||||
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 equipment inspection badges.
|
||||
// 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.
|
||||
@@ -65,6 +67,35 @@ export async function getBrandPrimaryColor() {
|
||||
return getSetting('brand_primary_color', '')
|
||||
}
|
||||
|
||||
// Primary hover/active color (--primary-dark). Empty = derive from primary.
|
||||
export async function getBrandPrimaryDarkColor() {
|
||||
return getSetting('brand_primary_dark_color', '')
|
||||
}
|
||||
|
||||
// Accent color (--secondary). Empty = built-in palette from style.css.
|
||||
export async function getBrandAccentColor() {
|
||||
return getSetting('brand_accent_color', '')
|
||||
}
|
||||
|
||||
// Sidebar background color (--sidebar-bg). Empty = built-in palette.
|
||||
export async function getBrandSidebarColor() {
|
||||
return getSetting('brand_sidebar_color', '')
|
||||
}
|
||||
|
||||
// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker).
|
||||
// Returns null for anything that is not a 6-digit hex so callers can skip it.
|
||||
function darkenHexColor(hex, fraction) {
|
||||
const match = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
||||
if (!match) return null
|
||||
const num = parseInt(match[1], 16)
|
||||
const scale = 1 - fraction
|
||||
const red = Math.round(((num >> 16) & 0xff) * scale)
|
||||
const green = Math.round(((num >> 8) & 0xff) * scale)
|
||||
const blue = Math.round((num & 0xff) * scale)
|
||||
const toHex = (channel) => channel.toString(16).padStart(2, '0')
|
||||
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
|
||||
}
|
||||
|
||||
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
|
||||
// templates, and the global-search URL template (all with a {ticket}
|
||||
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
|
||||
@@ -91,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() {
|
||||
@@ -104,8 +145,25 @@ export async function applyBranding() {
|
||||
}
|
||||
link.href = favicon
|
||||
}
|
||||
const rootStyle = document.documentElement.style
|
||||
const primaryColor = await getBrandPrimaryColor()
|
||||
if (primaryColor) {
|
||||
document.documentElement.style.setProperty('--primary', primaryColor)
|
||||
rootStyle.setProperty('--primary', primaryColor)
|
||||
}
|
||||
// Hover color: use the explicit value, else darken primary ~15%.
|
||||
const primaryDarkColor = await getBrandPrimaryDarkColor()
|
||||
if (primaryDarkColor) {
|
||||
rootStyle.setProperty('--primary-dark', primaryDarkColor)
|
||||
} else if (primaryColor) {
|
||||
const derived = darkenHexColor(primaryColor, 0.15)
|
||||
if (derived) rootStyle.setProperty('--primary-dark', derived)
|
||||
}
|
||||
const accentColor = await getBrandAccentColor()
|
||||
if (accentColor) {
|
||||
rootStyle.setProperty('--secondary', accentColor)
|
||||
}
|
||||
const sidebarColor = await getBrandSidebarColor()
|
||||
if (sidebarColor) {
|
||||
rootStyle.setProperty('--sidebar-bg', sidebarColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
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) {
|
||||
@@ -127,6 +146,7 @@ const iconMap = {
|
||||
'bar-chart-3': BarChart3,
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
@@ -135,7 +155,7 @@ const defaultNav = [
|
||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
|
||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
|
||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
||||
{ name: 'Equipment', icon: 'cog', route: '/machines', position: 10 },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
||||
@@ -228,8 +248,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>
|
||||
|
||||
90
frontend/src/views/ChangePassword.vue
Normal file
90
frontend/src/views/ChangePassword.vue
Normal 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>
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Active</div>
|
||||
<div class="value">{{ stats.activemachines || 0 }}</div>
|
||||
<div class="value">{{ stats.activeassets || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">In Repair</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
<h3>Assets</h3>
|
||||
<select v-model="filterType" class="form-control">
|
||||
<option value="">All Types</option>
|
||||
<option value="equipment">Equipment</option>
|
||||
<option value="machine">Machines</option>
|
||||
<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'
|
||||
@@ -155,10 +156,11 @@ async function loadAssets() {
|
||||
|
||||
function getTypeIcon(assettype) {
|
||||
const icons = {
|
||||
'equipment': Cog,
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ const subtypeLabel = computed(() => {
|
||||
if (!selectedType.value) return 'Select type first'
|
||||
if (!currentSubtypes.value.length) return 'No subtypes'
|
||||
const labels = {
|
||||
'equipment': 'All Machine Types',
|
||||
'machine': 'All Machine Types',
|
||||
'computer': 'All Computer Types',
|
||||
'network device': 'All Device Types',
|
||||
'printer': 'All Printer Types'
|
||||
@@ -182,8 +182,8 @@ const filteredAssets = computed(() => {
|
||||
result = result.filter(a => {
|
||||
if (!a.typedata) return false
|
||||
// Check different ID fields based on asset type
|
||||
if (typeLower === 'equipment') {
|
||||
return a.typedata.equipmenttypeid === subtypeId
|
||||
if (typeLower === 'machine') {
|
||||
return a.typedata.machinetypeid === subtypeId
|
||||
} else if (typeLower === 'computer') {
|
||||
return a.typedata.computertypeid === subtypeId
|
||||
} else if (typeLower === 'network device') {
|
||||
|
||||
@@ -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">
|
||||
@@ -109,14 +109,14 @@ const highlightId = ref(null)
|
||||
const copiedId = ref(null)
|
||||
|
||||
const typeLabels = {
|
||||
machine: 'Equipment',
|
||||
equipment: 'Equipment',
|
||||
machine: 'Machine',
|
||||
pc: 'PC',
|
||||
computer: 'PC',
|
||||
application: 'App',
|
||||
knowledgebase: 'KB',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
employee: 'Employee',
|
||||
notification: 'Notice',
|
||||
subnet: 'Subnet'
|
||||
@@ -124,10 +124,11 @@ const typeLabels = {
|
||||
|
||||
const filterTypeMap = {
|
||||
all: null,
|
||||
equipment: ['equipment'],
|
||||
machines: ['machine'],
|
||||
computers: ['computer'],
|
||||
printers: ['printer'],
|
||||
network: ['network_device', 'subnet'],
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
notifications: ['notification'],
|
||||
@@ -136,10 +137,11 @@ const filterTypeMap = {
|
||||
|
||||
const filterList = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'machines', label: 'Machines' },
|
||||
{ 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' },
|
||||
@@ -383,52 +385,33 @@ watch(results, () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-type.machine,
|
||||
.result-type.equipment {
|
||||
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;
|
||||
@@ -458,10 +441,6 @@ watch(results, () => {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.result-location::before {
|
||||
content: '\1F4CD ';
|
||||
}
|
||||
|
||||
.result-ticket {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
@@ -484,51 +463,17 @@ watch(results, () => {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.result-type.machine,
|
||||
.result-type.equipment {
|
||||
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>
|
||||
|
||||
@@ -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 > 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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
|
||||
📄
|
||||
</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">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
|
||||
📄
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
@@ -75,24 +75,20 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Purpose</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
|
||||
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${device.usbdeviceid}`">
|
||||
{{ device.displayname }}
|
||||
<router-link :to="`/usb/${checkout.device_id}`">
|
||||
{{ checkout.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ device.serialnumber }}</td>
|
||||
<td>{{ formatDate(device.checkoutdate) }}</td>
|
||||
<td>{{ device.checkoutpurpose || '-' }}</td>
|
||||
<td>{{ formatDate(checkout.timestamp) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
|
||||
Check In
|
||||
</button>
|
||||
</td>
|
||||
@@ -103,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,23 +111,35 @@
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Checked In</th>
|
||||
<th>Purpose</th>
|
||||
<th>Device Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
|
||||
<tr v-for="record in displayedHistory" :key="record.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${record.usbdeviceid}`">
|
||||
{{ record.devicename || `Device #${record.usbdeviceid}` }}
|
||||
<router-link :to="`/usb/${record.device_id}`">
|
||||
{{ record.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ formatDate(record.checkoutdate) }}</td>
|
||||
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
|
||||
<td>{{ record.purpose || '-' }}</td>
|
||||
<td>{{ formatDate(record.timestamp) }}</td>
|
||||
<td>{{ record.device_status || '-' }}</td>
|
||||
</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>
|
||||
@@ -142,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()
|
||||
|
||||
@@ -151,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)
|
||||
@@ -167,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()
|
||||
@@ -181,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() {
|
||||
@@ -213,6 +239,7 @@ async function loadRecognitions() {
|
||||
async function loadUSBDevices() {
|
||||
usbLoading.value = true
|
||||
try {
|
||||
// active check-out log rows for this badge
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso)
|
||||
usbDevices.value = response.data.data || []
|
||||
} catch (err) {
|
||||
@@ -225,10 +252,9 @@ async function loadUSBDevices() {
|
||||
async function loadCheckoutHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
// Get user's checkout history (all past checkouts)
|
||||
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
|
||||
// Filter to only show returned items (not currently checked out)
|
||||
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
|
||||
// all check-out log rows for this badge, newest first
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso, false)
|
||||
checkoutHistory.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading checkout history:', err)
|
||||
checkoutHistory.value = []
|
||||
@@ -237,11 +263,11 @@ async function loadCheckoutHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkinDevice(device) {
|
||||
if (!confirm(`Check in ${device.displayname}?`)) return
|
||||
async function checkinDevice(checkout) {
|
||||
if (!confirm(`Check in ${checkout.device_id}?`)) return
|
||||
|
||||
try {
|
||||
await usbApi.checkin(device.usbdeviceid)
|
||||
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
|
||||
await loadUSBDevices()
|
||||
await loadCheckoutHistory()
|
||||
} catch (err) {
|
||||
|
||||
161
frontend/src/views/geenforce/EnforcementReports.vue
Normal file
161
frontend/src/views/geenforce/EnforcementReports.vue
Normal 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>
|
||||
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal file
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal 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>
|
||||
1051
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
1051
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
183
frontend/src/views/geenforce/entryForm.js
Normal file
183
frontend/src/views/geenforce/entryForm.js
Normal 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
|
||||
}
|
||||
321
frontend/src/views/geenforce/entryForm.spec.js
Normal file
321
frontend/src/views/geenforce/entryForm.spec.js
Normal 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('')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,343 +1,321 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Equipment Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/equipment-badge/${equipment?.equipment?.equipmentid}`" class="btn btn-secondary" v-if="equipment" target="_blank">
|
||||
Print Badge
|
||||
</router-link>
|
||||
<router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="equipment">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ equipment.assetnumber }}</h1>
|
||||
<span v-if="equipment.name" class="hero-alias">{{ equipment.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ equipment.assettypename || 'Equipment' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(equipment.statusname)">
|
||||
{{ equipment.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="equipment.equipment?.equipmenttypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ equipment.equipment.equipmenttypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="equipment.equipment?.vendorname">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ equipment.equipment.vendorname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="equipment.equipment?.modelname">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ equipment.equipment.modelname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="equipment.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ equipment.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ equipment.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="equipment.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ equipment.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'equipment') && equipment.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'equipment') && equipment.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="equipment.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ equipment.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ equipment.equipment?.equipmenttypename || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ equipment.equipment?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ equipment.equipment?.modelname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controller Section (for CNC machines) -->
|
||||
<div class="section-card" v-if="equipment.equipment?.controllervendorname || equipment.equipment?.controllermodelname">
|
||||
<h3 class="section-title">Controller</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ equipment.equipment?.controllervendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ equipment.equipment?.controllermodelname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Configuration -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Configuration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Requires Manual Config</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: equipment.equipment?.requiresmanualconfig }">
|
||||
{{ equipment.equipment?.requiresmanualconfig ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Section -->
|
||||
<div class="section-card" v-if="equipment.equipment?.lastmaintenancedate || equipment.equipment?.nextmaintenancedate">
|
||||
<h3 class="section-title">Maintenance</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="equipment.equipment?.lastmaintenancedate">
|
||||
<span class="info-label">Last Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(equipment.equipment.lastmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="equipment.equipment?.nextmaintenancedate">
|
||||
<span class="info-label">Next Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(equipment.equipment.nextmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="equipment.equipment?.maintenanceintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ equipment.equipment.maintenanceintervaldays }} days</span>
|
||||
</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="equipment.mapx != null && equipment.mapy != null"
|
||||
:left="equipment.mapx"
|
||||
:top="equipment.mapy"
|
||||
:machineName="equipment.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ equipment.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ equipment.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected PC -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Connected PC</h3>
|
||||
<div v-if="!controllingPc" class="empty-message">
|
||||
No controlling PC assigned
|
||||
</div>
|
||||
<div v-else class="connected-device">
|
||||
<router-link :to="`/pcs/${controllingPc.pluginid || controllingPc.assetid}`" class="device-link">
|
||||
<div class="device-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<span class="device-name">{{ controllingPc.assetnumber }}</span>
|
||||
<span class="device-alias" v-if="controllingPc.name">{{ controllingPc.name }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<span class="connection-type">{{ controllingPc.relationshipType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="equipment.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="equipment.assetid" :items="warranties" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="equipment.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ equipment.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(equipment.createddate) }}<template v-if="equipment.createdby"> by {{ equipment.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(equipment.modifieddate) }}<template v-if="equipment.modifiedby"> by {{ equipment.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Equipment not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { equipmentApi, assetsApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const equipment = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => equipment.value?.assetid)
|
||||
const relationships = ref({ incoming: [], outgoing: [] })
|
||||
|
||||
const controllingPc = computed(() => {
|
||||
// For equipment, find a related computer in any "Controls" relationship
|
||||
// Check both incoming (computer controls this) and outgoing (legacy data may have equipment -> computer)
|
||||
|
||||
// First check incoming - computer as source controlling this equipment
|
||||
for (const rel of relationships.value.incoming || []) {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
|
||||
return {
|
||||
...rel.sourceasset,
|
||||
relationshipType: rel.relationshiptypename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check outgoing - legacy data may have equipment -> computer Controls relationships
|
||||
for (const rel of relationships.value.outgoing || []) {
|
||||
if (rel.targetasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
|
||||
return {
|
||||
...rel.targetasset,
|
||||
relationshipType: rel.relationshiptypename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await equipmentApi.get(route.params.id)
|
||||
equipment.value = response.data.data
|
||||
|
||||
// Load relationships using asset ID
|
||||
if (equipment.value?.assetid) {
|
||||
try {
|
||||
const relResponse = await assetsApi.getRelationships(equipment.value.assetid)
|
||||
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
|
||||
} catch (e) {
|
||||
console.log('Relationships not available')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading equipment:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
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 === 'spare') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'disposed') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Machine Details</h2>
|
||||
<div class="header-actions">
|
||||
<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>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ machine.assettypename || 'Machine' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
|
||||
{{ machine.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="machine.machine?.machinetypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.machine?.vendorname">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.machine?.modelname">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ machine.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ machine.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ machine.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ machine.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ machine.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controller Section (for CNC machines) -->
|
||||
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
|
||||
<h3 class="section-title">Controller</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
|
||||
</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>
|
||||
|
||||
<!-- Machine Configuration -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Configuration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Requires Manual Config</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
|
||||
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Section -->
|
||||
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
|
||||
<h3 class="section-title">Maintenance</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
|
||||
<span class="info-label">Last Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
|
||||
<span class="info-label">Next Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="machine.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (dualpath, controls, ...) -->
|
||||
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="machine.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ machine.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
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 machine = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
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 === 'spare') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'disposed') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
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;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Equipment' : 'New Equipment' }}</h2>
|
||||
<h2>{{ isEdit ? 'Edit Machine' : 'New Machine' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveEquipment">
|
||||
<form v-else @submit.prevent="saveMachine">
|
||||
<!-- Identity Section -->
|
||||
<h3 class="form-section-title">Identity</h3>
|
||||
<div class="form-row">
|
||||
@@ -34,8 +34,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'equipment') || isEnabled('maintenancereference', 'equipment')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'equipment')">
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
@@ -46,7 +46,7 @@
|
||||
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'equipment')">
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
@@ -88,23 +88,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Hardware Section -->
|
||||
<h3 class="form-section-title">Equipment Hardware</h3>
|
||||
<!-- Machine Hardware Section -->
|
||||
<h3 class="form-section-title">Machine Hardware</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmenttypeid">Equipment Type</label>
|
||||
<label for="machinetypeid">Machine Type</label>
|
||||
<select
|
||||
id="equipmenttypeid"
|
||||
v-model="form.equipmenttypeid"
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="et in equipmentTypes"
|
||||
:key="et.equipmenttypeid"
|
||||
:value="et.equipmenttypeid"
|
||||
v-for="mt in machineTypes"
|
||||
:key="mt.machinetypeid"
|
||||
:value="mt.machinetypeid"
|
||||
>
|
||||
{{ et.equipmenttype }}
|
||||
{{ mt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -294,7 +294,7 @@
|
||||
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">Select the PC that controls this equipment</small>
|
||||
<small class="form-help">Select the PC that controls this machine</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="controllingPcId">
|
||||
@@ -312,7 +312,7 @@
|
||||
{{ rt.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">How the PC connects to this equipment</small>
|
||||
<small class="form-help">How the PC connects to this machine</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,14 +328,14 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Site-defined custom fields for equipment -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="EQUIPMENT_ASSETTYPEID" :assetid="currentAssetId" />
|
||||
<!-- Site-defined custom fields for machines -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Equipment' }}
|
||||
{{ saving ? 'Saving...' : 'Save Machine' }}
|
||||
</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
@@ -347,7 +347,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { equipmentApi, 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'
|
||||
@@ -362,8 +362,8 @@ const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Seeded asset-type id for equipment (see /api/assets/types).
|
||||
const EQUIPMENT_ASSETTYPEID = 1
|
||||
// Seeded asset-type id for machines (see /api/assets/types).
|
||||
const MACHINE_ASSETTYPEID = 1
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
@@ -380,7 +380,7 @@ const form = ref({
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: 1,
|
||||
equipmenttypeid: '',
|
||||
machinetypeid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
controllervendorid: '',
|
||||
@@ -394,7 +394,7 @@ const form = ref({
|
||||
mapy: null
|
||||
})
|
||||
|
||||
const equipmentTypes = ref([])
|
||||
const machineTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
@@ -405,9 +405,9 @@ const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
const currentEquipment = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected equipment vendor
|
||||
// Filter models by selected machine vendor
|
||||
const filteredModels = computed(() => {
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
@@ -443,7 +443,7 @@ onMounted(async () => {
|
||||
try {
|
||||
// Load reference data in parallel
|
||||
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
|
||||
equipmentApi.types.list(),
|
||||
machinesApi.types.list(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 500 }),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
@@ -453,7 +453,7 @@ onMounted(async () => {
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
])
|
||||
|
||||
equipmentTypes.value = typesRes.data.data || []
|
||||
machineTypes.value = typesRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
@@ -463,28 +463,25 @@ 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' }]
|
||||
}
|
||||
|
||||
// Set default relationship type to "Controls" if available
|
||||
const controlsType = relationshipTypes.value.find(t => t.relationshiptype === 'Controls')
|
||||
// Set default relationship type to "Controls" if available (name is data, compare folded)
|
||||
const controlsType = relationshipTypes.value.find(t => (t.relationshiptype || '').toLowerCase() === 'controls')
|
||||
if (controlsType) {
|
||||
relationshipTypeId.value = controlsType.relationshiptypeid
|
||||
}
|
||||
|
||||
// Load equipment if editing
|
||||
// Load machine if editing
|
||||
if (isEdit.value) {
|
||||
const response = await equipmentApi.get(route.params.id)
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
const data = response.data.data
|
||||
currentAssetId.value = data.assetid || null
|
||||
currentEquipment.value = data
|
||||
currentMachine.value = data
|
||||
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
@@ -493,15 +490,15 @@ onMounted(async () => {
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || 1,
|
||||
equipmenttypeid: data.equipment?.equipmenttypeid || '',
|
||||
vendorid: data.equipment?.vendorid || '',
|
||||
modelnumberid: data.equipment?.modelnumberid || '',
|
||||
controllervendorid: data.equipment?.controllervendorid || '',
|
||||
controllermodelid: data.equipment?.controllermodelid || '',
|
||||
machinetypeid: data.machine?.machinetypeid || '',
|
||||
vendorid: data.machine?.vendorid || '',
|
||||
modelnumberid: data.machine?.modelnumberid || '',
|
||||
controllervendorid: data.machine?.controllervendorid || '',
|
||||
controllermodelid: data.machine?.controllermodelid || '',
|
||||
locationid: data.locationid || '',
|
||||
businessunitid: data.businessunitid || '',
|
||||
requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
|
||||
islocationonly: data.equipment?.islocationonly || false,
|
||||
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
|
||||
islocationonly: data.machine?.islocationonly || false,
|
||||
notes: data.notes || '',
|
||||
mapx: data.mapx ?? null,
|
||||
mapy: data.mapy ?? null
|
||||
@@ -515,7 +512,7 @@ onMounted(async () => {
|
||||
|
||||
// Check incoming relationships for a controlling PC
|
||||
for (const rel of relationships.incoming || []) {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && rel.relationshiptypename === 'Controls') {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
|
||||
controllingPcId.value = rel.sourceasset.assetid
|
||||
relationshipTypeId.value = rel.relationshiptypeid
|
||||
existingRelationshipId.value = rel.assetrelationshipid
|
||||
@@ -523,7 +520,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load relationships')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,7 +549,7 @@ function clearMapPosition() {
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function saveEquipment() {
|
||||
async function saveMachine() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
@@ -565,7 +561,7 @@ async function saveEquipment() {
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || 1,
|
||||
equipmenttypeid: form.value.equipmenttypeid || null,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
controllervendorid: form.value.controllervendorid || null,
|
||||
@@ -579,17 +575,17 @@ async function saveEquipment() {
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
let savedEquipment
|
||||
let savedMachine
|
||||
let assetId
|
||||
|
||||
if (isEdit.value) {
|
||||
const response = await equipmentApi.update(route.params.id, data)
|
||||
savedEquipment = response.data.data
|
||||
assetId = savedEquipment.assetid
|
||||
const response = await machinesApi.update(route.params.id, data)
|
||||
savedMachine = response.data.data
|
||||
assetId = savedMachine.assetid
|
||||
} else {
|
||||
const response = await equipmentApi.create(data)
|
||||
savedEquipment = response.data.data
|
||||
assetId = savedEquipment.assetid
|
||||
const response = await machinesApi.create(data)
|
||||
savedMachine = response.data.data
|
||||
assetId = savedMachine.assetid
|
||||
}
|
||||
|
||||
// Handle relationship (controlling PC)
|
||||
@@ -604,10 +600,10 @@ async function saveEquipment() {
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
|
||||
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving equipment:', err)
|
||||
error.value = apiError(err, 'Failed to save equipment')
|
||||
console.error('Error saving machine:', err)
|
||||
error.value = apiError(err, 'Failed to save machine')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -632,7 +628,7 @@ async function saveRelationship(assetId) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
|
||||
// Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
|
||||
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Equipment</h2>
|
||||
<router-link to="/machines/new" class="btn btn-primary">Add Equipment</router-link>
|
||||
<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>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -11,7 +12,7 @@
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search equipment..."
|
||||
placeholder="Search machines..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
@@ -35,30 +36,32 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in equipment" :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.equipment?.equipmenttypename || '-' }}</td>
|
||||
<td>{{ item.equipment?.vendorname || '-' }}</td>
|
||||
<td>{{ item.machine?.machinetypename || '-' }}</td>
|
||||
<td>{{ item.machine?.vendorname || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/machines/${item.equipment?.equipmentid || item.assetid}`"
|
||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="equipment.length === 0">
|
||||
<tr v-if="machines.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No equipment found
|
||||
No machines found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -82,23 +85,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { equipmentApi } from '../../api'
|
||||
import { machinesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const equipment = ref([])
|
||||
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)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadEquipment()
|
||||
loadMachines()
|
||||
})
|
||||
|
||||
async function loadEquipment() {
|
||||
async function loadMachines() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
@@ -107,11 +110,11 @@ async function loadEquipment() {
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await equipmentApi.list(params)
|
||||
equipment.value = response.data.data || []
|
||||
const response = await machinesApi.list(params)
|
||||
machines.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading equipment:', error)
|
||||
console.error('Error loading machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -120,20 +123,20 @@ async function loadEquipment() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadEquipment()
|
||||
setSearch(search.value)
|
||||
loadMachines()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadEquipment()
|
||||
setPage(p)
|
||||
loadMachines()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadEquipment()
|
||||
setPage(1)
|
||||
loadMachines()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
210
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
210
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<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>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="tool">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ tool.assetnumber }}</h1>
|
||||
<span v-if="tool.name" class="hero-alias">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ tool.assettypename || 'Measuring Tool' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.measuringtooltypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ tool.measuringtool.measuringtooltypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.nextcalibrationdate">
|
||||
<span class="hero-detail-label">Next Calibration</span>
|
||||
<span class="hero-detail-value">{{ formatDate(tool.measuringtool.nextcalibrationdate) }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ tool.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number (Gage Tag)</span>
|
||||
<span class="info-value">{{ tool.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ tool.name }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.measuringtooltypename || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Calibration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="info-value">
|
||||
<span class="badge" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.lastcalibrationdate ? formatDate(tool.measuringtool.lastcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Next Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.nextcalibrationdate ? formatDate(tool.measuringtool.nextcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationintervaldays }} days</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationprovider">
|
||||
<span class="info-label">Provider</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationprovider }}</span>
|
||||
</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">{{ tool.locationname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ tool.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="tool.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (partof, connectedto, ...) -->
|
||||
<AssetRelationships v-if="tool.assetid" :assetid="tool.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="tool.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ tool.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDateTime(tool.createddate) }}<template v-if="tool.createdby"> by {{ tool.createdby }}</template></span>
|
||||
<span>Modified {{ formatDateTime(tool.modifieddate) }}<template v-if="tool.modifiedby"> by {{ tool.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Measuring tool not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
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)
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { if (!d) return '-'; return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 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)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
295
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
295
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Measuring Tool' : 'New Measuring Tool' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveTool">
|
||||
<!-- Identity Section -->
|
||||
<h3 class="form-section-title">Identity</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number (Gage Tag) *</label>
|
||||
<input id="assetnumber" v-model="form.assetnumber" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name / Alias</label>
|
||||
<input id="name" v-model="form.name" type="text" class="form-control" />
|
||||
<small class="form-help">Layperson-friendly label</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number (vendor)</label>
|
||||
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
|
||||
</div>
|
||||
<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>
|
||||
<select id="measuringtooltypeid" v-model="form.measuringtooltypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select id="statusid" v-model="form.statusid" class="form-control">
|
||||
<option value="">Select status...</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">{{ s.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<h3 class="form-section-title">Calibration</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="lastcalibrationdate">Last Calibration</label>
|
||||
<input id="lastcalibrationdate" v-model="form.lastcalibrationdate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nextcalibrationdate">Next Calibration</label>
|
||||
<input id="nextcalibrationdate" v-model="form.nextcalibrationdate" type="date" class="form-control" />
|
||||
<small class="form-help">Drives the derived calibration status (overdue / due soon / current)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calibrationintervaldays">Interval (days)</label>
|
||||
<input id="calibrationintervaldays" v-model.number="form.calibrationintervaldays" type="number" min="0" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calibrationprovider">Calibration Provider</label>
|
||||
<input id="calibrationprovider" v-model="form.calibrationprovider" type="text" class="form-control" />
|
||||
<small class="form-help">Lab or in-house team that performs the calibration</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<h3 class="form-section-title">Location & Organization</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select id="locationid" v-model="form.locationid" class="form-control">
|
||||
<option value="">Select location...</option>
|
||||
<option v-for="l in locations" :key="l.locationid" :value="l.locationid">{{ l.locationname }}</option>
|
||||
</select>
|
||||
<small class="form-help">Operation locations (e.g. "0615 Blisk Inspection") hold measuring tools</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
|
||||
<option value="">Select business unit...</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">{{ bu.businessunit }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<h3 class="form-section-title">Notes</h3>
|
||||
<div class="form-group">
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Additional notes..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Site-defined custom fields for measuring tools -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="assettypeid" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Measuring Tool' }}
|
||||
</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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
|
||||
// fields load for the measuring_tool asset type.
|
||||
const assettypeid = ref(null)
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: '',
|
||||
measuringtooltypeid: '',
|
||||
lastcalibrationdate: '',
|
||||
nextcalibrationdate: '',
|
||||
calibrationintervaldays: null,
|
||||
calibrationprovider: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const types = ref([])
|
||||
const statuses = ref([])
|
||||
const locations = ref([])
|
||||
const businessunits = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
locations.value = locResponse.data.data || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
assettypeid.value = assetType ? assetType.assettypeid : null
|
||||
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
const data = response.data.data
|
||||
currentAssetId.value = data.assetid || null
|
||||
const ext = data.measuringtool || {}
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || '',
|
||||
measuringtooltypeid: ext.measuringtooltypeid || '',
|
||||
lastcalibrationdate: ext.lastcalibrationdate || '',
|
||||
nextcalibrationdate: ext.nextcalibrationdate || '',
|
||||
calibrationintervaldays: ext.calibrationintervaldays ?? null,
|
||||
calibrationprovider: ext.calibrationprovider || '',
|
||||
locationid: data.locationid || '',
|
||||
businessunitid: data.businessunitid || '',
|
||||
notes: data.notes || '',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveTool() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
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,
|
||||
lastcalibrationdate: form.value.lastcalibrationdate || null,
|
||||
nextcalibrationdate: form.value.nextcalibrationdate || null,
|
||||
calibrationintervaldays: form.value.calibrationintervaldays ?? null,
|
||||
calibrationprovider: form.value.calibrationprovider || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
notes: form.value.notes || null,
|
||||
}
|
||||
|
||||
let saved
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.update(route.params.id, data)
|
||||
saved = response.data.data
|
||||
} else {
|
||||
const response = await measuringtoolsApi.create(data)
|
||||
saved = response.data.data
|
||||
}
|
||||
|
||||
const assetId = saved.assetid
|
||||
if (assetId && customFieldsRef.value) {
|
||||
try {
|
||||
await customFieldsRef.value.save(assetId)
|
||||
} catch (customFieldsErr) {
|
||||
console.error('Error saving custom fields:', customFieldsErr)
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/measuringtools/${saved.measuringtool?.measuringtoolid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving measuring tool:', err)
|
||||
error.value = apiError(err, 'Failed to save measuring tool')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section-title:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
173
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
173
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search measuring tools..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="typeid" class="form-control" @change="reload">
|
||||
<option value="">All types</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="calibrationstatus" class="form-control" @change="reload">
|
||||
<option value="">All calibration statuses</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="duesoon">Due soon</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="unknown">Unknown</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 #</th>
|
||||
<th>Name</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Next Calibration</th>
|
||||
<th>Calibration</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<td>{{ item.measuringtool?.measuringtooltypename || '-' }}</td>
|
||||
<td>{{ item.measuringtool?.nextcalibrationdate ? formatDate(item.measuringtool.nextcalibrationdate) : '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(item.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tools.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No measuring tools found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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 { 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 { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
||||
types.value = typesResponse.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading types:', err)
|
||||
}
|
||||
loadTools()
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (typeid.value) params.typeid = typeid.value
|
||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||
|
||||
const response = await measuringtoolsApi.list(params)
|
||||
tools.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tools:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadTools()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
72
frontend/src/views/network/NetworkHub.vue
Normal file
72
frontend/src/views/network/NetworkHub.vue
Normal 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>
|
||||
88
frontend/src/views/network/SubnetDetail.vue
Normal file
88
frontend/src/views/network/SubnetDetail.vue
Normal 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>
|
||||
75
frontend/src/views/network/SubnetsBrowse.vue
Normal file
75
frontend/src/views/network/SubnetsBrowse.vue
Normal 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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user