Compare commits
115 Commits
v0.6.0
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb367a38f9 | ||
|
|
d1c844d533 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -5,11 +5,19 @@
|
||||
# for the host distro). The backend job uses the system python3 in a venv
|
||||
# instead. setup-node works because node is resolved differently.
|
||||
#
|
||||
# Three jobs run on push and pull_request:
|
||||
# backend - pytest (tests use in-memory SQLite via TestingConfig, so no
|
||||
# database service is needed).
|
||||
# naming - the CONTRIBUTING.md naming/style gate.
|
||||
# frontend - Vue build.
|
||||
# Jobs run on push and pull_request:
|
||||
# backend - pytest (tests use in-memory SQLite via TestingConfig, so no
|
||||
# database service is needed).
|
||||
# naming - the CONTRIBUTING.md naming/style gate.
|
||||
# frontend - Vue build.
|
||||
# migrations-mysql - proves the REAL multi-site deploy path: a fresh
|
||||
# `flask db upgrade` + per-plugin install on utf8mb4 MySQL
|
||||
# from empty, idempotent on a second run. The pytest suite
|
||||
# only exercises SQLite create_all(), so without this a
|
||||
# regression in the Alembic chain on MySQL would ship
|
||||
# undetected. Needs a runner that supports service
|
||||
# containers; if yours does not, run these steps against a
|
||||
# host MySQL instead.
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -54,3 +62,60 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
migrations-mysql:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: shopdb_ci
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h localhost -uroot -proot"
|
||||
--health-interval=5s --health-timeout=5s --health-retries=20
|
||||
env:
|
||||
DATABASE_URL: mysql+pymysql://root:root@127.0.0.1:3306/shopdb_ci?charset=utf8mb4
|
||||
SECRET_KEY: ci-secret
|
||||
JWT_SECRET_KEY: ci-jwt-secret
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
- name: Force utf8mb4 on the CI database
|
||||
run: |
|
||||
mysql -h 127.0.0.1 -uroot -proot -e \
|
||||
"ALTER DATABASE shopdb_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
- name: Fresh core upgrade from empty
|
||||
run: .venv/bin/flask db upgrade
|
||||
- name: Install every bundled plugin (runs its chain)
|
||||
run: |
|
||||
for p in computers employees geenforce knowledgebase machines \
|
||||
measuringtools network notifications printers slides usb warranty; do
|
||||
.venv/bin/flask plugin install "$p"
|
||||
done
|
||||
- name: Assert schema built + utf8mb4, and a second upgrade is a no-op
|
||||
run: |
|
||||
.venv/bin/python - <<'PY'
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db
|
||||
from sqlalchemy import text
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
insp = db.inspect(db.engine)
|
||||
tables = insp.get_table_names()
|
||||
assert len(tables) >= 70, f'only {len(tables)} tables built'
|
||||
row = db.session.execute(text(
|
||||
"SELECT default_character_set_name FROM information_schema.schemata "
|
||||
"WHERE schema_name = 'shopdb_ci'")).first()
|
||||
assert row[0] == 'utf8mb4', f'charset is {row[0]}, not utf8mb4'
|
||||
print(f'OK: {len(tables)} tables, charset {row[0]}')
|
||||
PY
|
||||
- name: Second core upgrade must be a clean no-op
|
||||
run: .venv/bin/flask db upgrade
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -75,3 +75,4 @@ secrets.yml
|
||||
*_secret
|
||||
*_secrets
|
||||
credentials.json
|
||||
scripts/site_imports/wjf/idmap.json
|
||||
|
||||
343
CHANGELOG.md
343
CHANGELOG.md
@@ -10,6 +10,343 @@ ADR-007 and ADR-002.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Asset detail pages (machines, PCs, printers, network devices, measuring
|
||||
tools) now share one canonical card skeleton: Identity -> type-specific ->
|
||||
status -> Location & Organization -> domain -> Custom Fields -> Warranty ->
|
||||
Relationships -> Notes -> audit footer. The location card reads "Location &
|
||||
Organization" on every page. The network device page was rebuilt into the
|
||||
family (its "Asset Information" folded into Identity, "Record Info" converted
|
||||
to the standard audit footer). Printer Notes moved out of mid-page to
|
||||
second-to-last and the printer gained an audit footer. Template reordering
|
||||
only; no data or API changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- List pages keep the current page (and search term) in the URL query, so
|
||||
paging to page 9, opening an item, and hitting browser Back returns to page 9
|
||||
instead of resetting to page 1. Applies to all 18 list views via a shared
|
||||
useListQuery composable; page 1 with no search stays a bare path.
|
||||
- PC detail Installed Applications no longer 500s and silently disappears on
|
||||
real PCs (ComputerInstalledApp had no to_dict); the section renders app
|
||||
name, version, and description again.
|
||||
- Employee detail skips its USB panels when the usb plugin is disabled (no
|
||||
more 404 console noise).
|
||||
- Shopfloor kiosk header text is readable (light on the dark navy header).
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Collector-driven PC -> printer relationships. The computers collector schema
|
||||
gained optional `defaultprinter` (string) and `printers` (array of strings)
|
||||
fields carrying Win32_Printer identifiers. On ingest each identifier resolves
|
||||
to a printer asset (by windows name / share / hostname / asset number-name or
|
||||
a communications IP) and the PC is linked to it: the default via a
|
||||
`defaultprinter` relationship, the rest via `connectedto`. The links render in
|
||||
the shared Relationships card on both the PC and printer detail pages. The
|
||||
sync is idempotent and archives collector-created links to printers no longer
|
||||
reported (tagged `assetrelationships.label = 'collector:printers'`, so
|
||||
manually-created links are never touched); unresolved identifiers become
|
||||
response warnings, never failures. The collector response carries
|
||||
`printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md.
|
||||
- Searchable custom fields. Each custom-field definition gained a `searchable`
|
||||
flag (Settings > Custom Fields). When on, that field's stored values are
|
||||
matched by global search and a hit routes to the owning asset's detail page.
|
||||
The asset's `search_<type>_enabled` domain toggle still applies, and matches
|
||||
dedupe against built-in-field asset hits so an asset appears once. Inactive or
|
||||
non-searchable fields are never matched.
|
||||
- Single-label sheet-position printing. The single asset-label page
|
||||
(`/print/asset-label/:assettype/:id`) gained an Output control that toggles
|
||||
between the standalone label (unchanged default) and placing that one label at
|
||||
a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the
|
||||
other five cells blank. This prints a single label onto the correct physical
|
||||
spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the
|
||||
legacy shopdb behavior and complementing the batch page's start-cell offset.
|
||||
The ULINE 6-up cell layout and dimensions are replicated from
|
||||
`AssetLabelBatch.vue` (left untouched); encode resolution stays shared via
|
||||
`assetLabel.js`.
|
||||
|
||||
## [0.7.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`;
|
||||
`smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings
|
||||
settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and
|
||||
is a graceful no-op (logs a warning, returns False) when email is disabled or
|
||||
the host is unset. The SMTP password is never logged. Three flows use it:
|
||||
(1) New-user welcome + forced first-login password change. Admin-created users
|
||||
(POST `/api/users`) are flagged `mustchangepassword` (new `users` column,
|
||||
migration `7d23_user_mustchangepassword`, default false) and sent a
|
||||
best-effort welcome email with the facility name, username, temporary
|
||||
password, and sign-in link; the account is created even if mail fails
|
||||
(response carries a `warning`). Login returns `mustchangepassword`; the
|
||||
frontend forces the user through a new `/change-password` view (POST
|
||||
`/api/auth/change-password`, jwt-guarded) before the app, and changing the
|
||||
password clears the flag and resets lockout counters. A self-service "Change
|
||||
password" entry is also available from the user menu.
|
||||
(2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe
|
||||
and surfaces any SMTP error with the password scrubbed; wires up the Email /
|
||||
SMTP settings page "Send Test Email" button.
|
||||
(3) On-demand alert/report delivery. POST `/api/reports/email`
|
||||
(reports.export) mails `{subject, columns, rows}` as an HTML table to a
|
||||
supplied recipient or the site `alert_recipients`; an "Email report" button on
|
||||
the Warranty and Toner report pages posts the loaded rows. There is no
|
||||
scheduler: automation is an external cron hitting the endpoint with a scoped
|
||||
API token (PAT). Documented in `docs/CONFIG.md`.
|
||||
- Shared asset label/code generator: a single `/print/asset-label/<assettype>/<id>`
|
||||
page (public, like the other `/print/*` routes) that any asset detail page
|
||||
opens via a "Print Label" button (machines, computers, printers, network
|
||||
devices, measuring tools). A no-print controls panel toggles the layout
|
||||
(`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode),
|
||||
and what the code encodes: the asset page link, asset number, serial number,
|
||||
a per-type custom target template, or - for measuring tools by default - the
|
||||
tool's inspection location code so every tool at one operation shares one
|
||||
code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New
|
||||
`printing` settings seed and surface on the Printing & Labels settings page:
|
||||
`qr_target_machine`, `qr_target_computer`, `qr_target_network_device`,
|
||||
`qr_target_measuring_tool`; `label_default_style` (default `card`) and
|
||||
`label_default_codetype` (default `qr`); and a per-asset-type default for what
|
||||
the code encodes, `label_default_encodes_<type>` (machines default to their
|
||||
machine number, measuring tools to their inspection location code, the rest to
|
||||
a page link), all overridable on the label page itself. When the chosen field
|
||||
has no value (e.g. serial number on an asset with none), the label states so
|
||||
instead of rendering an empty code. Asset payloads now carry a derived
|
||||
`locationcode` (leading token of the resolved own/inherited location name).
|
||||
- Batch label sheets: a "Print Labels" button on each asset list page opens
|
||||
`/print/asset-label-batch/<assettype>`, a multi-select sheet that lays the
|
||||
chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up
|
||||
mini-label format), with a start-cell offset to reuse partial sheets. Shares
|
||||
the same code-type and encode settings/defaults as the single label, so a
|
||||
batch of measuring tools encodes each tool's inspection location code just
|
||||
like the single label does. Restores the ULINE batch printing the legacy
|
||||
shopdb had, generalized across all asset types.
|
||||
- Support-team contact UX: the settings Support Teams page now manages each
|
||||
team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
|
||||
Add/Edit/Delete) instead of an inline row expander, and the application
|
||||
detail Support card renders Email (`mailto:`) and Microsoft Teams chat
|
||||
(`teams.microsoft.com/l/chat`) action buttons for every contact that has an
|
||||
SSO. Both link targets derive as `sso@<domain>` from a new `site` setting
|
||||
`contact_email_domain` (default `geaerospace.com`; blank hides the buttons),
|
||||
surfaced in Site & Facility settings under Naming & Patterns and read on the
|
||||
frontend via `getContactEmailDomain()`.
|
||||
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
|
||||
permissions its own routes enforce, instead of core accumulating every
|
||||
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
|
||||
core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core
|
||||
sets (assets, applications, reports, settings, users, audit, apitokens,
|
||||
collector); the 36 permissions for machines, computers, printers, network,
|
||||
knowledgebase, notifications, usb, warranty, and measuringtools moved into
|
||||
each owning plugin's hook. New core helper `full_permission_catalog()` merges
|
||||
core plus every ENABLED plugin's permissions and backs all three consumers:
|
||||
`flask seed permissions`, the role grid (`GET /api/users/permissions`), and
|
||||
API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install
|
||||
and enable seed the plugin's own permissions idempotently. A disabled plugin
|
||||
drops out of the catalog (no new scope grants or role assignments), but its
|
||||
existing `Permission` rows and role links persist so current roles keep
|
||||
working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md`
|
||||
permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md`
|
||||
hooks table row.
|
||||
- Personal API tokens (PATs) so scripts and integrations authenticate without
|
||||
the hourly-expiring login JWT (immediate consumer: long legacy-import runs
|
||||
that die when the JWT expires mid-run). New core `apitokens` table + migration
|
||||
`7d21_apitokens` (stores only the sha256 hash of each secret; the full secret
|
||||
`shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint
|
||||
`/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate;
|
||||
revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode
|
||||
by a before_request shim that mints a request-scoped JWT for the token's
|
||||
owner, so the entire existing auth+authz stack (jwt_required,
|
||||
require_permission, require_role, import mode, current_user) authenticates the
|
||||
PAT as its owner with zero decorator changes; an invalid, revoked, or expired
|
||||
PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one
|
||||
write per 60s). Any authenticated user manages their own tokens; admins may
|
||||
list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`)
|
||||
with a create modal that reveals the secret once (copy button) and an admin
|
||||
All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to
|
||||
recommend a PAT for imports. Core feature; no plugin-contract change.
|
||||
- Optional permission scopes on personal API tokens. A token MAY carry a scopes
|
||||
list (permission names, migration `7d22_apitokens_scopes` adds the nullable
|
||||
`apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its
|
||||
owner). A scoped token grants ONLY the listed permissions, intersected with
|
||||
what the owner actually holds at use time, and SUSPENDS the admin-role bypass,
|
||||
so a scoped token minted by an admin is genuinely limited: it is denied on
|
||||
role-gated (`require_role`) endpoints and gets no import mode. The shim mints
|
||||
the request JWT with a `patscopes` claim that `require_permission`,
|
||||
`require_role`, and `import_mode_active` read; normal login JWTs carry no such
|
||||
claim and are unaffected (zero regression). Scopes are validated at write time
|
||||
against the token OWNER's permissions (the scope ceiling - a token can never
|
||||
grant more than its owner holds; when an admin edits another user's token the
|
||||
ceiling is that owner's permissions), rejecting unknown or unheld names 400.
|
||||
Minting/managing tokens now requires the new `apitokens.create` permission
|
||||
(category `apitokens`; admins hold it by default, grantable via the roles UI)
|
||||
rather than being open to any authenticated user. The Settings > API Tokens
|
||||
create/edit modals gain a "Restrict permissions" section (a category-grouped
|
||||
checkbox grid limited to the permissions the creator holds) and the token
|
||||
lists show a full-access / N-permissions access chip.
|
||||
- Managed collector service tokens: the collector ingest API
|
||||
(`/api/collector/<plugin>` + the legacy `/pc` `/apps` `/heartbeat` `/bulk`
|
||||
`/status` endpoints) now ALSO accepts a managed API token scoped to the new
|
||||
`collector.ingest` permission (category `collector`), on top of the existing
|
||||
`COLLECTOR_API_KEY[_<PLUGIN>]` env keys (which stay supported as a
|
||||
bootstrap/legacy fallback - nothing breaks). The token may be presented in
|
||||
`X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer`
|
||||
token; both transports validate the PAT the same way the login shim does
|
||||
(hash lookup, active, unexpired, active owner) via a shared
|
||||
`resolve_api_token` helper refactored out of `apitoken_auth.py`, require
|
||||
`collector.ingest` in the token's scope list AND that the owner holds it, and
|
||||
stamp `lastusedat` (same 60s throttle). A token scoped to ONLY
|
||||
`collector.ingest` is a collector service token: it authorizes the collector
|
||||
API and NOTHING else - the existing scoped-token machinery denies it on every
|
||||
permission- and role-gated route and on import mode, so a leaked collector
|
||||
token cannot touch the regular API. Recommended flow (documented): an admin
|
||||
mints the scoped token (the scope suspends the admin bypass, containing it);
|
||||
rotate by minting a new one, deploying via `site-config.json`, watching
|
||||
`lastusedat`, then revoking the old. The Settings > API Tokens create modal
|
||||
gains a "Collector service token" quick-preset (pre-selects only
|
||||
`collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed
|
||||
collector tokens" section) and `docs/CONFIG.md`. Core feature; no
|
||||
plugin-contract change.
|
||||
- Vendor-model photos on asset detail heroes: computers and printers now
|
||||
surface the linked model's `imageurl` in their extension payloads (the
|
||||
field machines already exposed), and the machine, PC, printer, network
|
||||
device, and measuring tool detail pages render the photo in the hero card
|
||||
when present (hidden cleanly when absent). Network devices and measuring
|
||||
tools have no model link yet, so their heroes stay photo-less until one
|
||||
is added.
|
||||
- Dualpath "single machine" site toggle (`dualpath_single_machine`, default
|
||||
on). A Dualpath relationship pair is one physical dual-bay machine (single
|
||||
controller, bay-selector switch); when on, the machines list, dashboard and
|
||||
machines-by-type counts, and the floor map collapse each pair to one entry
|
||||
(the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden)
|
||||
and show a combined `2007 / 2008` label. The data model is unchanged (both
|
||||
bay records always exist); detail pages stay per-bay and always show a
|
||||
sibling-bay banner regardless of the toggle. Contract surface (plugin
|
||||
contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers
|
||||
`resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by
|
||||
the machines plugin to collapse pairs contract-purely.
|
||||
- Relationship propagation, wired and data-driven: relationship types
|
||||
declare propagation-through pairs (relationshiptypepropagations M:N,
|
||||
replacing the never-consumed single column); creating a controls link on
|
||||
one Dualpath bay auto-creates it on the partner bay, and
|
||||
`flask relationships propagate` backfills existing data.
|
||||
- Employee photos, mode-aware: self-hosted directory employees support
|
||||
upload/replace/delete (admin), served publicly for kiosk cards; external
|
||||
directory mode passes the HR-supplied picture URL through read-only. One
|
||||
resolver feeds the shopfloor recognition/recert cards and the employee
|
||||
detail hero in either mode.
|
||||
- Vendor-model photo management. New admin-gated core endpoints
|
||||
`POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
|
||||
one image per model, replace semantics) and
|
||||
`DELETE /api/models/<modelid>/image`, plus the public
|
||||
`GET /api/models/image/<filename>` serve route. Uploads land in
|
||||
`instance/modelimages/` (survives upgrades, backed up with the rest of
|
||||
`instance/`) and set `models.imageurl` to the served URL; the manual Image URL
|
||||
field still accepts external URLs and the shipped `/images/models/*` assets
|
||||
(upload is additive). Delete only removes files we own under the instance dir.
|
||||
The Models settings page grows a thumbnail, Upload/Replace, and Remove
|
||||
controls in the edit modal. Asset hero images (e.g. the machine badge) read
|
||||
`imageurl` unchanged, so uploaded photos render with no consumer changes.
|
||||
|
||||
- Application support teams with contacts, replacing the legacy
|
||||
supportteams/appowners pair. New core `supportteamcontacts` table (multiple
|
||||
named contacts per team, ordered by `sortorder`); `supportteams` keeps
|
||||
`teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and
|
||||
sheds the single-owner `appownerid` FK. New core blueprint at
|
||||
`/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname`
|
||||
exact-match lookup for import; delete a team 409s while any application still
|
||||
references it). Migration `7d18_supportteamcontacts` migrates each legacy
|
||||
team's app owner into one contact. Application payloads now flatten
|
||||
`supportteamname`, `teamurl`, and the team's active `contacts`; a Support
|
||||
card on the application detail page and a new `settings/supportteams`
|
||||
management page render them.
|
||||
|
||||
- Import mode: a complete, idempotent HTTP migration surface so a migration script
|
||||
can import the classic ASP shopdb through the API alone (no direct DB writes).
|
||||
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
|
||||
`shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is
|
||||
an admin AND sends header `X-Import-Mode: true`, create/update endpoints
|
||||
accept optional `createddate` / `modifieddate` (ISO or legacy
|
||||
`YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now.
|
||||
Non-admin or missing header: the fields are ignored exactly as before.
|
||||
Wired into every timestamped import target: assets (all five type plugins),
|
||||
vendors, models, modeltypes, businessunits, locations, operating systems,
|
||||
applications, knowledge base, USB devices, and asset relationships.
|
||||
- Natural-key exact-match lookup filters for the documented
|
||||
lookup-then-upsert idempotency recipe: `assetnumber` on all five asset
|
||||
plugin list endpoints; `vendor`, `modelnumber`, `modeltype`,
|
||||
`businessunit`, `locationname`, `osname`/`osversion`, `appname`,
|
||||
knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`,
|
||||
and notification `ticketnumber`.
|
||||
- Backdated event history: in import mode the selfhosted USB checkout/checkin
|
||||
endpoints accept optional `checkouttime` / `checkintime` overrides so
|
||||
migrated `usbcheckouts` rows keep their real event times.
|
||||
- New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch`
|
||||
legacy schema: order of operations, a full table-by-table mapping, honest
|
||||
no-target list with dispositions, a worked idempotent Python importer, and
|
||||
row-count parity checks.
|
||||
|
||||
### Changed
|
||||
|
||||
- System Settings is no longer one tabbed page. The inner tab bar is gone and
|
||||
each section is its own routed settings page reached through the settings
|
||||
rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
|
||||
(the former Integrations dumping ground, now split three-plus ways), plus
|
||||
Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
|
||||
Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
|
||||
these under Site & Facility, Integrations, Communication, Search & Identity,
|
||||
and Access & Security. Shared load/save/upload plumbing moved into a
|
||||
`useSystemSettings` composable so the pages stay thin. Old bookmarks keep
|
||||
working: `/settings/system` and every `/settings/system?tab=<key>` redirect
|
||||
to the matching new page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Audit log: hovering a user's SSO now shows their full name (best-effort,
|
||||
resolved from the employee directory in either mode).
|
||||
- Refreshed the internal status docs to match the code (project active
|
||||
state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
|
||||
corrected the get_asset_panels endpoint path in the hook docstring, and
|
||||
removed leftover debug console.log lines.
|
||||
- Measuring tools wired into the remaining cross-cutting surfaces an audit
|
||||
found them missing from: generic asset serialization (typedata + pluginid,
|
||||
which also fixes relationship-card links to tools), map subtype
|
||||
filtering/coloring and the MapEditor filter, dashboard totals, warranty
|
||||
asset links (via a new by-asset detail route), and the two ADR-010 hook
|
||||
declarations (presentation route token corrected; the calibration
|
||||
map-overlay endpoint now actually exists). The login avatar also resolves
|
||||
through the employee-photo helper, so self-hosted uploads show.
|
||||
- Measuring tools are now wired into the asset-identifier matrix and global
|
||||
search. The Settings identifier matrix gains a Measuring Tool column and the
|
||||
gauge-lab and maintenance reference inputs/rows on the measuring-tool form
|
||||
and detail pages honor those per-type toggles (a maintenance-reference field
|
||||
was added, matching the other asset types). Global search gains a Measuring
|
||||
Tools domain toggle and filter chip, routes measuring-tool hits to
|
||||
`/measuringtools/<id>` (previously the generic `/assets/<id>` fallback), and
|
||||
matches on `gaugelabreference` so a gage-tag lookup resolves the tool.
|
||||
- Site & Facility settings page renders booleans as toggles and the
|
||||
directory-mode settings as dropdowns, with labels and help text for every
|
||||
field (no more raw keys or type-true/false boxes).
|
||||
- System Settings tabs follow the URL: clicking a settings-rail link that
|
||||
only changes the ?tab= query (Branding, Floor Map) now switches the right
|
||||
panel, tab clicks update the URL, and browser back/forward restore tabs.
|
||||
- Following a relationship link between two assets of the same type now loads
|
||||
the destination page instead of stale content (router-view keyed on path;
|
||||
query-only URL changes still avoid a remount).
|
||||
- Asset relationships card no longer lists a symmetric peer twice. Relationship
|
||||
types gain `relationshiptypes.isdirectional` (migration
|
||||
`7d19_relationshiptype_directional`; seeded false for the connection-like
|
||||
types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet,
|
||||
USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/
|
||||
defaultprinter). The card now collapses every stored direction row of a
|
||||
symmetric type into one direction-blind "Connected" entry per peer (deleting
|
||||
it removes all collapsed rows), while directional types drop the
|
||||
Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer`
|
||||
phrasing. The type CRUD and the per-asset relationships endpoint carry
|
||||
`isdirectional`; the Relationship Types settings page gains a Directional
|
||||
toggle.
|
||||
|
||||
## [0.6.0] - 2026-07-11
|
||||
|
||||
### Added
|
||||
@@ -104,7 +441,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
|
||||
- Multi-stage Docker build that compiles the Vue frontend and ships
|
||||
`frontend/dist`, which Flask serves.
|
||||
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
|
||||
reconciled README, DEPLOY, CLAUDE, and ROADMAP.
|
||||
reconciled README, DEPLOY, status docs, and ROADMAP.
|
||||
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort
|
||||
Gitea Actions CI (backend tests, naming/style gate, frontend build).
|
||||
|
||||
@@ -137,5 +474,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
|
||||
integration that passed the key as a query parameter. See
|
||||
`docs/COLLECTOR-INTEGRATION.md`.
|
||||
|
||||
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...HEAD
|
||||
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...HEAD
|
||||
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0
|
||||
|
||||
27
CLAUDE.md
27
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,10 +42,12 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
|
||||
|
||||
### Active state
|
||||
|
||||
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
|
||||
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
|
||||
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
|
||||
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||
- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).
|
||||
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script import the whole legacy DB (X-Import-Mode preserves timestamps).
|
||||
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
|
||||
|
||||
### Deferred
|
||||
@@ -47,8 +55,9 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
|
||||
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
|
||||
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
|
||||
- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
|
||||
- Frontend hook contract for asset-detail, map markers, search results
|
||||
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet)
|
||||
- (DONE) Frontend plugin hook contract (ADR-010): get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation shipped; generic renderers for panels/overlays land incrementally.
|
||||
- (DONE) Per-plugin Alembic chains (ADR-008): every bundled plugin carries its own chain; no plugin uses db.create_all().
|
||||
- Legacy ASP data import against the renamed schema (unblocked; run via docs/IMPORT-API.md) + a production pilot deployment.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -126,4 +135,4 @@ Each plugin must have:
|
||||
- `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix
|
||||
- `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods
|
||||
- `migrations/rename_underscore_columns.sql` - one-time rename of snake_case columns to lowercase concatenated (per CONTRIBUTING.md)
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d16_directoryemployees`). Run `flask db upgrade` to apply.
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes`). Run `flask db upgrade` to apply.
|
||||
|
||||
@@ -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
|
||||
|
||||
62
README.md
62
README.md
@@ -7,16 +7,22 @@ A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Pyt
|
||||
ShopDB tracks and manages:
|
||||
- **Machines** - CNC equipment, CMMs, inspection systems, etc.
|
||||
- **PCs** - Shopfloor computers, engineering workstations
|
||||
- **Printers** - Network printers with Zabbix integration
|
||||
- **Applications** - Software deployed across the shop floor
|
||||
- **Printers** - Network printers with Zabbix supply integration
|
||||
- **Network devices** - Switches, routers, and the subnet browser
|
||||
- **Measuring tools** - Gage-lab instruments with calibration tracking
|
||||
- **Applications** - Software deployed across the shop floor, with per-PC install tracking
|
||||
- **Employees** - Directory, recognition and training notifications
|
||||
- **Warranties** - Coverage records with Dell warranty lookups
|
||||
- **USB devices** - CMMC check-in/out tracking
|
||||
- **Knowledge Base** - Documentation and troubleshooting guides
|
||||
- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.x with Flask
|
||||
- Python 3.12 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.6+ database
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
- JWT authentication
|
||||
- Plugin architecture for extensibility
|
||||
|
||||
@@ -59,13 +65,13 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
### Database
|
||||
|
||||
- **Table names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machines`, `pctypes`, `machinetypes`, `businessunits`
|
||||
- Examples: `assets`, `computers`, `printers`, `businessunits`
|
||||
- **Column names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
|
||||
- Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate`
|
||||
- **Foreign keys:** Referenced table name + `id`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid`
|
||||
- **Boolean columns:** Prefixed with `is` or `has`
|
||||
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `islicenced`
|
||||
- Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced`
|
||||
|
||||
### Code
|
||||
|
||||
@@ -77,9 +83,9 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
### API
|
||||
|
||||
- **Endpoints:** Lowercase, plural nouns
|
||||
- Examples: `/api/machines`, `/api/pctypes`, `/api/locations`
|
||||
- Examples: `/api/machines`, `/api/computers`, `/api/locations`
|
||||
- **Query parameters:** Lowercase, single word
|
||||
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
|
||||
- Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer`
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
@@ -92,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.12
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
|
||||
@@ -102,7 +108,7 @@ SQLite). Do not run dev or production against SQLite.
|
||||
|
||||
### Distribution
|
||||
|
||||
The application is distributed internally through the GE Aerospace Gitea. Clone
|
||||
The application is distributed through the internal GE Aerospace git server. Clone
|
||||
it from there; there is no public package or image registry.
|
||||
|
||||
### Fast path (Docker)
|
||||
@@ -158,12 +164,10 @@ npm run build # production build into frontend/dist (served by Flask)
|
||||
Complete first-run setup at `/setup`, or run `flask seed admin` for a headless
|
||||
admin account.
|
||||
|
||||
To import data from the legacy ShopDB MySQL database (one-time, see
|
||||
`migrations/DATA_MIGRATION_GUIDE.md`):
|
||||
|
||||
```bash
|
||||
python scripts/import_from_mysql.py
|
||||
```
|
||||
To import a site's legacy data, use the HTTP import surface: an admin API
|
||||
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration
|
||||
through documented endpoints (`X-Import-Mode` preserves original timestamps).
|
||||
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
|
||||
|
||||
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
|
||||
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
|
||||
@@ -192,30 +196,36 @@ The REST API follows standard conventions:
|
||||
| PUT | `/api/machines/:id` | Update machine |
|
||||
| DELETE | `/api/machines/:id` | Soft delete machine |
|
||||
|
||||
Each asset plugin exposes the same CRUD pattern on its own prefix
|
||||
(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`),
|
||||
and cross-cutting asset endpoints live under `/api/assets`.
|
||||
|
||||
Query parameters for list endpoints:
|
||||
- `page` - Page number (default: 1)
|
||||
- `per_page` - Items per page (default: 25)
|
||||
- `perpage` - Items per page
|
||||
- `sort` - Sort field
|
||||
- `order` - Sort direction (asc/desc)
|
||||
- `dir` - Sort direction (asc/desc)
|
||||
- `search` - Search term
|
||||
- `type` - Filter by asset type (computer, printer, machine, network_device)
|
||||
- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool)
|
||||
|
||||
## Plugin System
|
||||
|
||||
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
|
||||
|
||||
The image bundles ten plugins; only the ones a site installs are loaded:
|
||||
The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
- **machine** - CNC, CMM, and other shop-floor machines
|
||||
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
|
||||
- **machines** - CNC, CMM, and other shop-floor machines
|
||||
- **measuringtools** - Gage-lab instruments with calibration tracking
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices
|
||||
- **network** - Network devices and subnets
|
||||
- **notifications** - Shopfloor notifications and recognition feed
|
||||
- **printers** - Extended printer management with Zabbix integration
|
||||
- **slides** - TV/kiosk slideshows
|
||||
- **usb** - CMMC USB check-in/out tracking
|
||||
- **warranty** - Dell warranty lookups
|
||||
- **warranty** - Warranty records with Dell lookups
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -30,6 +30,80 @@ a real caller (the GE-Enforce fleet agent) to it.
|
||||
HTTP 500 `Collector API key not configured` and rejects every request. An
|
||||
unconfigured server never silently accepts unauthenticated data.
|
||||
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
|
||||
- In addition to the env keys, a managed API token scoped to `collector.ingest`
|
||||
is accepted as a collector credential on every collector endpoint. See
|
||||
"Managed collector tokens" below; env keys remain the fallback.
|
||||
|
||||
### Managed collector tokens (recommended)
|
||||
|
||||
Alongside the env keys, every collector endpoint (`/api/collector/<plugin>`,
|
||||
`/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`) also accepts a **managed API
|
||||
token** (PAT) scoped to the `collector.ingest` permission. The env keys stay
|
||||
supported as a bootstrap/legacy fallback - nothing breaks - but a managed token
|
||||
is the preferred credential because it can be minted, rotated, and revoked from
|
||||
the UI (Settings > API Tokens) and its use shows up in `lastusedat` and the
|
||||
audit log.
|
||||
|
||||
What makes a token a collector service token: it is scoped to ONLY
|
||||
`collector.ingest`. That scope authorizes the collector ingest API and NOTHING
|
||||
else. The existing scoped-token machinery contains it automatically - a scoped
|
||||
token passes `require_permission` only for its listed permissions and is denied
|
||||
on every role-gated (`require_role`) endpoint and on import mode, and
|
||||
`collector.ingest` gates no normal route. So a collector token that leaks cannot
|
||||
be used to read or write anything through the regular API; it can only submit
|
||||
collector payloads.
|
||||
|
||||
Both wire transports are accepted (send whichever is convenient; GE-Enforce
|
||||
sends `X-API-Key` today, so that stays ergonomic):
|
||||
|
||||
```
|
||||
POST /api/collector/computers
|
||||
X-API-Key: shopdb_pat_<40 hex>
|
||||
```
|
||||
or
|
||||
```
|
||||
POST /api/collector/computers
|
||||
Authorization: Bearer shopdb_pat_<40 hex>
|
||||
```
|
||||
|
||||
An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector
|
||||
token and is rejected (401) - only `collector.ingest` in the scope list counts.
|
||||
A revoked or expired token is rejected (401) on both transports.
|
||||
|
||||
#### How to mint one (admin flow)
|
||||
|
||||
The simplest contained flow: an **admin** mints the token, scoped to
|
||||
`collector.ingest`. Because the token is scoped, the admin-role bypass is
|
||||
suspended for it, so the token is contained to the collector API even though its
|
||||
owner is an admin - it cannot act with admin authority anywhere.
|
||||
|
||||
1. Settings > API Tokens > New Token.
|
||||
2. Click the **Collector service token** preset (pre-selects only
|
||||
`collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an
|
||||
expiry, Create.
|
||||
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
|
||||
same way as the env key: the `collectorApiKey` field in per-site
|
||||
`site-config.json` (see "Delivering the API key to clients" below). The
|
||||
client sends it in `X-API-Key` exactly as it sends an env key today - no
|
||||
client code change.
|
||||
|
||||
Service identity (documented, not built): if you prefer a non-admin owner,
|
||||
create a dedicated low-privilege user (e.g. `svc-collector`) whose role holds
|
||||
only `collector.ingest`, plus `apitokens.create` if that user is to mint its own
|
||||
token. The scope ceiling then caps any token it mints at `collector.ingest`.
|
||||
The admin-minted route above is simpler and equally contained, so it is the
|
||||
recommended default.
|
||||
|
||||
#### Rotation
|
||||
|
||||
Managed tokens rotate without a fleet re-image:
|
||||
|
||||
1. Mint a new collector token (steps above).
|
||||
2. Deploy it via `site-config.json` (`collectorApiKey`) - update the one per-site
|
||||
value.
|
||||
3. Confirm the new token is in use: watch its `lastusedat` climb in Settings >
|
||||
API Tokens (and the old token's `lastusedat` go stale).
|
||||
4. Revoke the old token once traffic has moved. Revocation is immediate.
|
||||
|
||||
### Generic endpoint contract: `POST /api/collector/<plugin>`
|
||||
|
||||
@@ -135,14 +209,42 @@ a column.
|
||||
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
|
||||
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
|
||||
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
|
||||
| `defaultprinter` | string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a `defaultprinter` relationship. Unresolved -> warning. |
|
||||
| `printers` | array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a `connectedto` relationship (the default is skipped here since it already links as `defaultprinter`). Unresolved entries -> warning. |
|
||||
|
||||
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
|
||||
If you change the payload, change it there and re-check this table.
|
||||
|
||||
### PC -> printer relationship sync
|
||||
|
||||
When a payload carries `defaultprinter` and/or `printers`, the collector syncs
|
||||
`AssetRelationship` rows so a PC page shows its printers and a printer page shows
|
||||
the PCs that use it (both render in the shared Relationships card).
|
||||
|
||||
- Resolution: each identifier is matched, first hit wins, against the printer's
|
||||
`windowsname`, `hostname`, `sharename`, its asset number/name, then any active
|
||||
printer communications IP. Case-insensitive except the IP (exact). An
|
||||
identifier that resolves to nothing adds a warning and is skipped; it never
|
||||
fails the whole push.
|
||||
- Link types: the default printer links with `defaultprinter` (directional, PC
|
||||
is the source); every other reported printer links with `connectedto`
|
||||
(symmetric). A printer that is both default and in `printers` links only as
|
||||
the default.
|
||||
- Idempotent: re-reporting the same set creates no duplicate rows (an existing
|
||||
matching row is reactivated if it was archived, otherwise left as is).
|
||||
- Stale-link archive: on every push, collector-created links to printers no
|
||||
longer reported are set inactive. Collector-created rows are tagged in
|
||||
`assetrelationships.label = 'collector:printers'`; only tagged rows are ever
|
||||
archived, so links you create by hand in the UI are never touched. A payload
|
||||
that omits BOTH printer keys leaves all existing printer links untouched
|
||||
(report an empty `printers: []` to clear the auto links instead).
|
||||
- Response: the collector response carries `printerlinkcount` and a
|
||||
`printerlinks` list of `{assetid, relationshiptype}` for the links kept.
|
||||
|
||||
### pc-type mapping (configurable per site)
|
||||
|
||||
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
|
||||
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
|
||||
`pctypemap_<pxetype>` settings (Settings > Collector PC Types).
|
||||
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
|
||||
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
|
||||
failure.
|
||||
@@ -256,6 +358,13 @@ collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
|
||||
field name below was checked against `get_collector_schema` in
|
||||
`plugins/computers/plugin.py`.
|
||||
|
||||
The `X-API-Key` value can be EITHER a `COLLECTOR_API_KEY[_COMPUTERS]` env key OR
|
||||
a managed token scoped to `collector.ingest` (a `shopdb_pat_...` secret; see
|
||||
"Managed collector tokens"). The script is identical for both - it just carries
|
||||
whatever `collectorApiKey` the site-config supplies - so switching a site from an
|
||||
env key to a managed token (and rotating it) is a config change, not a script
|
||||
change.
|
||||
|
||||
```powershell
|
||||
# Send-ShopdbCollectorReport.ps1
|
||||
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
|
||||
@@ -406,8 +515,32 @@ function Send-ShopdbCollectorReport {
|
||||
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
|
||||
}
|
||||
|
||||
# --- Installed printers (Win32_Printer). The Default flag marks the one
|
||||
# default printer. We report each printer's port name (an IP or a queue
|
||||
# host for network printers) and fall back to the share/printer name, which
|
||||
# the collector resolves flexibly against printer windowsname/hostname/IP. ---
|
||||
$defaultPrinter = ''
|
||||
$printerIds = @()
|
||||
try {
|
||||
$printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop
|
||||
foreach ($p in $printers) {
|
||||
if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote)
|
||||
# Prefer the port name (IP or queue host); fall back to ShareName,
|
||||
# then the printer Name.
|
||||
$identity = $p.PortName
|
||||
if (-not $identity) { $identity = $p.ShareName }
|
||||
if (-not $identity) { $identity = $p.Name }
|
||||
if (-not $identity) { continue }
|
||||
$printerIds += $identity
|
||||
if ($p.Default) { $defaultPrinter = $identity }
|
||||
}
|
||||
$printerIds = @($printerIds | Select-Object -Unique)
|
||||
} catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" }
|
||||
|
||||
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
|
||||
$payload = @{ hostname = $hostname }
|
||||
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
|
||||
if ($printerIds.Count) { $payload['printers'] = $printerIds }
|
||||
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
|
||||
if ($pcType) { $payload['pctype'] = $pcType }
|
||||
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
|
||||
|
||||
100
docs/CONFIG.md
100
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
|
||||
|
||||
@@ -160,12 +170,27 @@ them under `instance/branding/`.
|
||||
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
|
||||
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
|
||||
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
|
||||
| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
|
||||
| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
|
||||
| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
|
||||
| `qr_target_measuring_tool` | (empty) | Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`, `{locationcode}`, `{locationname}`. |
|
||||
| `label_default_style` | `card` | Default asset-label layout used when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). |
|
||||
| `label_default_codetype` | `qr` | Default asset-label code type used when a label first opens: `qr` (QR code) or `barcode` (CODE128). |
|
||||
| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. |
|
||||
| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. |
|
||||
| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. |
|
||||
| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. |
|
||||
| `label_default_encodes_measuring_tool` | `location` | What a measuring-tool label encodes by default. Values across these five: `assetpage`, `assetnumber`, `serialnumber`, `location` (measuring tools only), or `custom`. Overridable on the label page. |
|
||||
|
||||
The shared asset-label generator lives at `/print/asset-label/<assettype>/<id>` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_<type>` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page.
|
||||
|
||||
The batch generator at `/print/asset-label-batch/<assettype>` (reached from the "Print Labels" button on each asset list page) lays a multi-selection of one type onto ULINE label sheets: a 6-up 3 in x 3 in format or a dense 72-up mini-label format, with a start-cell offset for reusing partial sheets. It reuses the same code-type and `label_default_encodes_<type>` defaults as the single label.
|
||||
|
||||
### map
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. |
|
||||
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. |
|
||||
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
|
||||
| `map_width` | `3300` | Blueprint native width in pixels. |
|
||||
| `map_height` | `2550` | Blueprint native height in pixels. |
|
||||
@@ -200,7 +225,48 @@ them under `instance/branding/`.
|
||||
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
|
||||
| `smtp_from_address` | (empty) | From address for outgoing email. |
|
||||
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
|
||||
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
|
||||
| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). |
|
||||
|
||||
#### Email flows and delivery model
|
||||
|
||||
The mail service (`shopdb/utils/mailer.py`, stdlib `smtplib`/`ssl`/`email`
|
||||
only) reads the keys above settings-first via the cached settings map, with an
|
||||
environment-variable fallback (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
|
||||
`SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`,
|
||||
`SMTP_ALERT_RECIPIENTS`, `SMTP_ENABLED`) applied only when any `SMTP_*` env var
|
||||
is present. When `smtp_enabled` is false or `smtp_host` is blank, every send is
|
||||
a graceful no-op that logs a warning and returns without error, so an
|
||||
unconfigured site never crashes. The SMTP password is never logged.
|
||||
|
||||
Three flows use it:
|
||||
|
||||
- Welcome email. When an admin creates a user (POST `/api/users`), the account
|
||||
is flagged `mustchangepassword` and a best-effort welcome email is sent with
|
||||
the facility name (`facility_name`), the username, the temporary password,
|
||||
and the sign-in link (`site_base_url` + `/login`). Mail is best-effort: the
|
||||
user is created even if the send fails (the response carries a `warning`). On
|
||||
first login the API returns `mustchangepassword: true`; the frontend forces
|
||||
the user through `/change-password` (POST `/api/auth/change-password`) before
|
||||
the app. Changing the password clears the flag and resets lockout counters.
|
||||
Set `sendwelcome: false` or `mustchangepassword: false` in the create body to
|
||||
opt out.
|
||||
|
||||
- Test email. POST `/api/settings/test-email` (settings.edit) sends a probe to
|
||||
the supplied `to` (or `alert_recipients`). The Email / SMTP settings page
|
||||
"Send Test Email" button calls it and shows the result; a real SMTP error is
|
||||
surfaced with the password scrubbed out.
|
||||
|
||||
- Alerts and report delivery (on-demand). POST `/api/reports/email`
|
||||
(reports.export) takes `{subject, columns, rows, intro?, to?}` and mails the
|
||||
rows as an HTML table. Recipients default to `alert_recipients` when `to` is
|
||||
omitted, so the same endpoint serves both report delivery and alerts. Report
|
||||
pages (Warranty, Toner) carry an "Email report" button that posts the rows
|
||||
they already loaded.
|
||||
|
||||
There is NO scheduler in this app: sending is on-demand. To automate a
|
||||
recurring send (e.g. a nightly warranty digest), point an external cron job
|
||||
at `/api/reports/email` using an API token (PAT) scoped to `reports.export`.
|
||||
See `docs/IMPORT-API.md` for the token model.
|
||||
|
||||
### audit
|
||||
|
||||
@@ -220,6 +286,25 @@ them under `instance/branding/`.
|
||||
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. |
|
||||
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. |
|
||||
|
||||
**Personal API tokens.** Besides login JWTs and SAML, a user may create
|
||||
personal API tokens (PATs) for scripts and integrations, from Settings > API
|
||||
Tokens (or `POST /api/apitokens`). A PAT is sent like a JWT
|
||||
(`Authorization: Bearer shopdb_pat_...`), authenticates as its owning user
|
||||
across the whole API, and does not carry the hourly `JWT_ACCESS_TOKEN_EXPIRES`
|
||||
limit (it never expires unless an explicit expiry is set). Only the sha256 hash
|
||||
is stored; the secret is shown once at creation. This is the recommended
|
||||
credential for long-running imports (see `docs/IMPORT-API.md`). There is no env
|
||||
var to configure; PATs are managed entirely through the API/UI.
|
||||
|
||||
Creating or managing a PAT requires the `apitokens.create` permission (admins
|
||||
hold it by default; grant it to other roles from Settings > Users & Roles). By
|
||||
default a PAT is unscoped and acts with the full authority of its owner. A PAT
|
||||
may optionally carry a scopes list (a subset of the owner's permissions, capped
|
||||
at what the owner actually holds): a scoped token grants ONLY those permissions,
|
||||
intersected with the owner's live permissions at use time, and suspends the
|
||||
admin bypass, so it is denied on role-gated (admin-only) endpoints and on import
|
||||
mode. Use an unscoped token for admin-only work and imports.
|
||||
|
||||
### identifiers (dynamic)
|
||||
|
||||
One boolean key per asset identifier per asset type, keyed
|
||||
@@ -234,6 +319,15 @@ One boolean key per search domain, keyed `search_<type>_enabled` (default
|
||||
`true`). Toggles whether a domain appears in global search results. The set is
|
||||
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
|
||||
|
||||
## Custom fields
|
||||
|
||||
Site-defined extra attributes per asset type (Settings > Custom Fields, table
|
||||
`customfields`). Each field has a `searchable` flag (default off). When on, the
|
||||
field's stored values are matched by global search and a hit routes to the
|
||||
owning asset's detail page. The asset's `search_<type>_enabled` domain toggle
|
||||
still applies, so a custom-field hit on a computer only shows when the computer
|
||||
search domain is enabled. Inactive or non-searchable fields are never matched.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
|
||||
|
||||
## Current version
|
||||
|
||||
The plugin contract is at **0.6.0**, declared in `shopdb/__init__.py` as
|
||||
The plugin contract is at **0.10.0**, declared in `shopdb/__init__.py` as
|
||||
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
|
||||
bump is allowed to break the contract, and this project has used that latitude.
|
||||
|
||||
@@ -25,8 +25,11 @@ Recorded in the comment block in `shopdb/__init__.py`:
|
||||
| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) |
|
||||
| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction |
|
||||
| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) |
|
||||
| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) |
|
||||
| 0.9.0 | Exposed the dualpath pair-resolution helpers on `shopdb.api` for the machines plugin | additive surface (minor) |
|
||||
| 0.10.0 | Added the `get_permissions` hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins | additive optional hook (minor) |
|
||||
|
||||
The source comment block documents 0.3.0, 0.4.0, and 0.6.0. Earlier points
|
||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, and 0.10.0. Earlier points
|
||||
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
||||
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
||||
0.2 baseline.
|
||||
@@ -49,6 +52,8 @@ land with a new or amended ADR.
|
||||
| `get_navigation_items` | Sidebar menu entries |
|
||||
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
|
||||
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) |
|
||||
| `get_permissions` | Plugin RBAC permissions, merged into the catalog for roles and token scopes (added 0.10.0) |
|
||||
| Frontend-contribution hooks | `get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`, consumed by `/api/pluginui/*` (added 0.7.0, [ADR-010](adr/ADR-010-frontend-plugin-hooks.md)) |
|
||||
| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) |
|
||||
| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |
|
||||
| `get_provisioning_note` | Setup-wizard transparency note for extra tables |
|
||||
@@ -65,7 +70,7 @@ Known-unstable areas. Building on these means expecting rework.
|
||||
|
||||
| Area | Status | Reference |
|
||||
|------|--------|-----------|
|
||||
| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) proposes the path: data-only declarative hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`) rendered by generic core components, with build-time glob discovery deferred for real components. | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (PROPOSED) |
|
||||
| Frontend renderers (residual) | The four data-only hooks and their `/api/pluginui/*` consumers are settled (0.7.0). The generic core renderers are landing incrementally: the settings-cards rail/landing renderer ships with 0.7.0; the asset-panel, map-overlay, and search-presentation renderers are wired opt-in per the ADR adoption plan. Real component-backed panels (bespoke charts, custom overlays) remain deferred to Option C (build-time glob discovery). | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (ACCEPTED) |
|
||||
| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) |
|
||||
| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) |
|
||||
|
||||
|
||||
@@ -128,6 +128,13 @@ to reproduce the exact enabled set, then just run `flask plugin upgrade-all`.)
|
||||
|
||||
## 6. Create the IIS site + web.config
|
||||
|
||||
This describes the own-site method (the app gets its own IIS site + port). To
|
||||
mount the app at a subpath under an existing site instead (e.g.
|
||||
`https://<host>/ops/` sharing the classic site's binding and cert), see
|
||||
**docs/INSTALL-WINDOWS-IIS.md section 7b**: same web.config, but the site is a
|
||||
`New-WebApplication` under the parent, `MOUNT_PATH=/ops` is set (web.config or
|
||||
`.env`), and the frontend is built with `VITE_BASE_PATH=/ops/`.
|
||||
|
||||
1. In IIS Manager, add a new **Site** (separate from the classic ASP site):
|
||||
- Physical path: `APP_ROOT`
|
||||
- Binding: a free port or a dedicated hostname (e.g. `https` 443 with the
|
||||
|
||||
126
docs/GE-ENFORCE-CLIENT.md
Normal file
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.
|
||||
@@ -330,20 +330,27 @@ def create_tool():
|
||||
...
|
||||
```
|
||||
|
||||
The `measuringtools.*` permissions are seeded exactly the way warranty seeds its
|
||||
own, by adding them to `Permission.PERMISSIONS` in `shopdb/core/models/user.py`:
|
||||
The `measuringtools.*` permissions belong to the plugin, not to core. The plugin
|
||||
declares them from the `get_permissions` hook (contract 0.10.0) so core never edits
|
||||
its catalog to accommodate a plugin:
|
||||
|
||||
```python
|
||||
# Measuring tools
|
||||
('measuringtools.view', 'View measuring tools', 'measuringtools'),
|
||||
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
|
||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_permissions(self):
|
||||
return [
|
||||
('measuringtools.view', 'View measuring tools', 'measuringtools'),
|
||||
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
|
||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
||||
]
|
||||
```
|
||||
|
||||
`flask seed permissions` is idempotent, so re-running it just adds the four new
|
||||
rows. The `admin` role bypasses every permission check, so an admin can operate the
|
||||
plugin before anyone grants the granular permissions.
|
||||
Installing or enabling the plugin seeds these rows automatically, and
|
||||
`flask seed permissions` (which now seeds core plus every enabled plugin) is
|
||||
idempotent, so re-running it just adds any missing rows. The `admin` role bypasses
|
||||
every permission check, so an admin can operate the plugin before anyone grants the
|
||||
granular permissions. See `get_permissions` in `docs/PLUGIN-HOOKS.md` for the
|
||||
disabled-plugin edge case.
|
||||
|
||||
**Responses use the framework helpers.** `success_response`, `error_response` (with
|
||||
`ErrorCodes`), and `paginated_response` produce the standard envelope
|
||||
@@ -525,9 +532,8 @@ form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdm
|
||||
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
|
||||
reorganize the file; just add the block, mirroring `machinesApi`.
|
||||
|
||||
**Views mirror the master templates.** The frontend has master templates the
|
||||
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`
|
||||
for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
||||
**Views mirror the master templates.** The frontend has master templates
|
||||
(`PrintersList.vue` for lists, `PrinterDetail.vue` for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
||||
|
||||
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
|
||||
and a calibration-status filter; the status badge uses `utils/colorStyle` with
|
||||
@@ -666,7 +672,7 @@ When you build a plugin, confirm all of this before you call it done:
|
||||
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green).
|
||||
- [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and
|
||||
pagination helpers; audit logs on writes.
|
||||
- [ ] Permissions added to `Permission.PERMISSIONS`; `flask seed permissions` run.
|
||||
- [ ] Permissions declared from the `get_permissions` hook; install/enable (or `flask seed permissions`) seeds them.
|
||||
- [ ] `on_install` seeds the asset type and any reference data, idempotently.
|
||||
- [ ] Hooks: navigation, reports, models implemented; config schema and collector
|
||||
implemented or consciously skipped with a reason.
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.6.0'
|
||||
__contract_version__ = '0.11.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -216,6 +216,139 @@ Consumed by `GET /api/reports`, which merges plugin cards after the static core
|
||||
reports sorted into category groups by the frontend (disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_permissions() -> List`
|
||||
|
||||
Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A
|
||||
plugin declares the permission names its own routes enforce via
|
||||
`require_permission`, instead of core accumulating every plugin's permissions in
|
||||
one catalog (plugin-is-the-product).
|
||||
|
||||
Each entry is a `(name, description, category)` tuple, matching the core
|
||||
permission catalog shape (dicts with those keys are also accepted). Names follow
|
||||
the naming convention (lowercase dotted, e.g. `machines.edit`).
|
||||
|
||||
```python
|
||||
class MachinesPlugin(BasePlugin):
|
||||
def get_permissions(self):
|
||||
return [
|
||||
('machines.view', 'View machines', 'machines'),
|
||||
('machines.create', 'Create machines', 'machines'),
|
||||
('machines.edit', 'Edit machines', 'machines'),
|
||||
('machines.delete', 'Delete machines', 'machines'),
|
||||
]
|
||||
```
|
||||
|
||||
Consumed by the core helper `full_permission_catalog()` (core permissions plus
|
||||
every ENABLED plugin's `get_permissions()`), which backs three consumers:
|
||||
|
||||
- `flask seed permissions` seeds the full catalog.
|
||||
- The role-management grid (`GET /api/users/permissions`) lists it, grouped by
|
||||
category.
|
||||
- API-token scope validation (`ApiToken.unknown_scope_names`) accepts a plugin
|
||||
permission as a scope only while that plugin is enabled.
|
||||
|
||||
Plugin install and enable also seed the plugin's own permissions idempotently,
|
||||
so enabling a fresh plugin creates its `Permission` rows without a separate seed
|
||||
pass.
|
||||
|
||||
Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its
|
||||
permissions are no longer offered for new scope grants or new role assignments.
|
||||
The `Permission` ROWS already in the database are NOT deleted, so roles that
|
||||
already reference them keep working until an admin edits the role. A broken
|
||||
plugin is isolated in prod and re-raised in dev/test.
|
||||
|
||||
### `get_settings_cards() -> List[Dict]`
|
||||
|
||||
Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010).
|
||||
Each card is merged into the settings rail and landing overview without the
|
||||
plugin hand-editing the core `settingsNav.js` catalog. `icon` is a string key
|
||||
mapped to a Lucide component core-side, exactly like `get_navigation_items`.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_settings_cards(self):
|
||||
return [{
|
||||
'group': 'Measuring Tools', # rail group title (created if new)
|
||||
'to': '/settings/measuringtooltypes',
|
||||
'icon': 'ruler', # string key, mapped core-side
|
||||
'title': 'Measuring Tool Types',
|
||||
'description': 'Manage measuring-tool subtypes + map colors',
|
||||
'position': 22, # order within the group
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/settings-cards`, which merges enabled plugins'
|
||||
cards into the core catalog (disabled plugins are skipped; a broken plugin is
|
||||
isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_asset_panels() -> List[Dict]`
|
||||
|
||||
Returns asset-detail extension-panel definitions. Added in contract 0.7.0
|
||||
(ADR-010). A generic core `AssetPanel` component renders each panel on the
|
||||
matching detail pages, fetching the panel's `endpoint`. This replaces
|
||||
hand-composing a plugin panel component into each detail view.
|
||||
|
||||
```python
|
||||
class WarrantyPlugin(BasePlugin):
|
||||
def get_asset_panels(self):
|
||||
return [{
|
||||
'id': 'warranty',
|
||||
'title': 'Warranty',
|
||||
'assettypes': ['*'], # detail pages it appears on; ['*'] = all
|
||||
'endpoint': '/api/warranty/asset/{assetid}',
|
||||
'render': 'table', # 'keyvalue' | 'table' | 'badge'
|
||||
'position': 30,
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/asset-panels?assetid=<id>`, which returns the
|
||||
panels whose `assettypes` match that asset's type (disabled plugins skipped;
|
||||
broken plugin isolated in prod, re-raised in dev/test). A panel that needs
|
||||
bespoke UI (a chart) is out of scope for this data-only hook.
|
||||
|
||||
### `get_map_overlays() -> List[Dict]`
|
||||
|
||||
Returns shop-floor map overlay/decoration definitions. Added in contract 0.7.0
|
||||
(ADR-010). The map stays data-driven off asset types + positions; an overlay
|
||||
adds decoration data (a badge or ring) plus an optional legend entry, with no
|
||||
plugin-side map code.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_map_overlays(self):
|
||||
return [{
|
||||
'id': 'calibration-due',
|
||||
'label': 'Calibration due', # legend label
|
||||
'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
|
||||
'style': 'badge', # 'badge' | 'ring'
|
||||
'legend': True,
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
|
||||
plugin isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_asset_presentation() -> List[Dict]`
|
||||
|
||||
Returns asset-type presentation/routing definitions. Added in contract 0.7.0
|
||||
(ADR-010). Declares how a plugin-owned asset type renders in global-search rows
|
||||
and cross-links (which icon, which detail route), so core never hardcodes a
|
||||
plugin's route or icon.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_asset_presentation(self):
|
||||
return [{
|
||||
'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns
|
||||
'icon': 'ruler',
|
||||
'label': 'Measuring Tool',
|
||||
'route': '/measuringtools/{assetid}',
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/asset-presentation` (disabled plugins skipped;
|
||||
broken plugin isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_provisioning_note() -> Optional[Dict]`
|
||||
|
||||
Transparency note the setup wizard shows the moment a site checks this plugin
|
||||
@@ -336,7 +469,15 @@ What `shopdb.api` exposes:
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`,
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Helpers: `audit_log`, `resolve_asset_position`
|
||||
- Authorization: `require_permission`, `require_role`,
|
||||
`service_token_authorized`
|
||||
(`service_token_authorized(scope)` returns True when the request carries a
|
||||
managed service token scoped for `scope` whose owner holds that permission -
|
||||
for unattended plugin endpoints like the GE-Enforce fetch API)
|
||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||
`dualpath_single_machine_enabled`
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
|
||||
```python
|
||||
@@ -387,6 +528,55 @@ position = resolve_asset_position(asset)
|
||||
|
||||
See [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm.
|
||||
|
||||
### Dualpath single-machine collapse
|
||||
|
||||
A Dualpath relationship pair is one physical dual-bay machine recorded as two
|
||||
asset rows. When the site setting `dualpath_single_machine` is on (default), the
|
||||
machines list, dashboard/report counts, and the floor map show the pair as a
|
||||
single machine (the SECONDARY bay is hidden); the data model always keeps both
|
||||
rows and detail pages stay per-bay.
|
||||
|
||||
```python
|
||||
from shopdb.api import resolve_dualpath_pairs, dualpath_single_machine_enabled
|
||||
|
||||
collapse = resolve_dualpath_pairs()
|
||||
# collapse.secondaryassetids: set of the non-primary bay asset ids to hide
|
||||
# collapse.partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for every
|
||||
# pair member (primary and secondary), for banners
|
||||
|
||||
if dualpath_single_machine_enabled():
|
||||
# exclude the hidden bays and annotate the visible (primary) bay
|
||||
...
|
||||
```
|
||||
|
||||
PRIMARY is the pair member with the lower natural-sort assetnumber.
|
||||
`resolve_dualpath_pairs` ignores the toggle (so a detail-page sibling banner can
|
||||
show always); gate the collapse itself on `dualpath_single_machine_enabled()`.
|
||||
|
||||
### Import mode (legacy timestamp passthrough)
|
||||
|
||||
Bulk imports from the classic ASP shopdb need to preserve each row's original
|
||||
`createddate` / `modifieddate` instead of stamping "now". `apply_import_timestamps`
|
||||
does this, gated so it never affects normal traffic: it only acts when the
|
||||
caller is an admin AND sent the `X-Import-Mode: true` request header.
|
||||
|
||||
```python
|
||||
from shopdb.api import apply_import_timestamps
|
||||
|
||||
asset = Asset(assetnumber=data['assetnumber'], ...)
|
||||
db.session.add(asset)
|
||||
# In import mode, stamp legacy createddate/modifieddate from the payload.
|
||||
# No-op for normal callers, or when the payload omits the fields.
|
||||
apply_import_timestamps(asset, data)
|
||||
db.session.commit()
|
||||
```
|
||||
|
||||
`import_mode_active()` returns the same admin-plus-header predicate, for guarding
|
||||
other backdated behavior (for example accepting a historical `checkouttime`).
|
||||
`parse_import_datetime(value)` parses both ISO `2020-01-05T12:00:00` and legacy
|
||||
`YYYY-MM-DD HH:MM:SS` into naive UTC. See [docs/IMPORT-API.md](IMPORT-API.md) for
|
||||
the full migration operator manual.
|
||||
|
||||
## Removed hooks
|
||||
|
||||
The following hooks existed in early drafts and have been removed for v1:
|
||||
|
||||
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` |
|
||||
@@ -122,6 +122,11 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
|
||||
| `get_navigation_items` | Plugin shows up in the sidebar nav |
|
||||
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
|
||||
| `get_reports` | Plugin's report cards appear on the Reports hub |
|
||||
| `get_settings_cards` | Plugin's card joins the settings rail + landing (no `settingsNav.js` edit) |
|
||||
| `get_permissions` | Plugin's RBAC permissions join the catalog, seeding, role grid, and token scopes |
|
||||
| `get_asset_panels` | Plugin panel renders on matching asset-detail pages |
|
||||
| `get_map_overlays` | Plugin decorates shop-floor map markers + adds a legend entry |
|
||||
| `get_asset_presentation` | Plugin declares its asset type's search icon + detail route |
|
||||
| `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/<name>` |
|
||||
|
||||
Each hook has a default that does nothing. Override only what your plugin needs.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
shopdb-flask is at `__contract_version__ = '0.11.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
|
||||
## Phase status
|
||||
|
||||
@@ -12,24 +12,22 @@ shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document cap
|
||||
| 3 - Manifest-first loader, shopdb.api namespace, auto-register blueprints | DONE | `6f085a1` |
|
||||
| 4 - Plugin scaffolding (`flask plugin new`) | DONE | `8eb9362` |
|
||||
| 5 - Alembic baseline, per-site deploy, ADRs to docs/adr | DONE | `d4e3ac9` |
|
||||
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | IN PROGRESS | this phase |
|
||||
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | DONE | v0.5.0-v0.7.0 |
|
||||
|
||||
The last big milestone before 1.0 is the legacy-ASP data import plus a production pilot deployment; the framework work below is what remains after that.
|
||||
|
||||
## What's left before tagging 1.0.0
|
||||
|
||||
### Must-have
|
||||
|
||||
- **Asset model fully wired**. `Asset.mapx, Asset.mapy` columns, `AssetRelationship.inheritsposition`, `AssetRelationship.propagatesthroughid` columns. Models match the locked ADR-001 surface that `resolve_asset_position` already targets.
|
||||
- **Equipment data migration script** for facilities migrating from legacy ASP shopdb. One-shot script under `scripts/migration/`. See [migrating-asset-schema](../../.claude/skills/migrating-asset-schema.md) for the policy. Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates.
|
||||
- **Equipment data migration script** for facilities migrating from legacy ASP shopdb. One-shot script under `scripts/migration/`. Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates.
|
||||
- **Printers retirement**. Legacy `PrinterData` model, `printers_bp` legacy blueprint, and the frontend `PrinterForm.vue` references to `printer.printerdata.*` get removed in lockstep. Coordinated with the equipment migration.
|
||||
- **Frontend hook contract**. Vue side equivalents for the backend hook system: how plugins expose asset-detail components, map markers, search-result renderers. Requires its own design ADR.
|
||||
- **Per-plugin Alembic migrations**. The framework supports them via `shopdb/plugins/migrations.py`; bundled plugins still rely on `db.create_all()`. Move each bundled plugin onto its own version chain before sister sites adopt.
|
||||
- **External plugin UI packaging**. The Vue-side hook contract ships (ADR-010: get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation) and route gating is backend-driven (ADR-009), but plugin routes/views still live in core `frontend/src`. Let an external plugin ship its own Vue bundle so adopters can add UI without editing core.
|
||||
|
||||
### Nice-to-have
|
||||
|
||||
- **Bundle the Roboto font locally.** `frontend/src/assets/style.css:2` imports Roboto from Google Fonts (`fonts.googleapis.com`). Air-gapped facilities have no route to that host, so the font silently falls back to a system font. Vendor the woff2 files into `frontend/src/assets/` and `@font-face` them locally so every site renders identically offline.
|
||||
- **Full palette theming.** `brand_primary_color` is settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the one primary color.
|
||||
- **Frontend plugin contract.** The backend hook system has no Vue-side equivalent yet (routes/views still ship in core; nav is already backend-driven). See the must-have entry above; this is the design ADR that unblocks external plugins shipping their own UI.
|
||||
- `measuringtools` plugin built using the scaffold (validates the scaffold under realistic conditions).
|
||||
- **Full palette theming.** `brand_primary_color` and a few brand colors are settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the brand colors.
|
||||
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
|
||||
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).
|
||||
- Plugin contract surface diff tooling. Today version bumps are manual judgment; a CI check that diffs the contract surface against the previous tag would catch missed bumps. See ADR-002.
|
||||
@@ -63,3 +61,9 @@ When a roadmap item gets prioritized, document the why in a new ADR and link fro
|
||||
- [ADR-004](adr/ADR-004-deployment-topology.md) - Deployment topology (per-site)
|
||||
- [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) - Equipment vs measuringtools
|
||||
- [ADR-006](adr/ADR-006-collector-contract.md) - Collector contract pattern
|
||||
- [ADR-007](adr/ADR-007-product-versioning-and-releases.md) - Product versioning and releases
|
||||
- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - Plugin migration ownership (per-plugin chains)
|
||||
- [ADR-009](adr/ADR-009-frontend-plugin-gating.md) - Frontend plugin route gating
|
||||
- [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) - Frontend plugin hook contract
|
||||
- [ADR-011](adr/ADR-011-machines-rename.md) - Machines rename + modeltypes retyping
|
||||
- [ADR-012](adr/ADR-012-geenforce-manifest-ownership.md) - GE-Enforce manifest ownership
|
||||
|
||||
@@ -97,7 +97,7 @@ removes those bundled PNGs and ships a generic placeholder SVG instead.
|
||||
|
||||
If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still
|
||||
point at `/static/images/sitemap2025-*`, the map will 404 those images after the
|
||||
upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor
|
||||
upgrade. Re-upload your own floor plan in **Settings > Floor Map**. Uploaded floor
|
||||
plans are stored under `instance/` and survive upgrades, so a site that already
|
||||
uploaded its own plan is unaffected. Only instances still using the old bundled
|
||||
default need to act.
|
||||
|
||||
@@ -106,7 +106,7 @@ core discovers it, mirroring the backend model. Sketch:
|
||||
named extension points instead of editing core files: an `iconMap`
|
||||
registration for nav/asset icons, asset-detail panels, map-marker
|
||||
renderers, and search-result renderers (the "Frontend hook contract"
|
||||
already listed as deferred in the project CLAUDE.md). Core exposes a
|
||||
already listed as deferred in docs/ROADMAP.md). Core exposes a
|
||||
stable set of shared components (form controls, detail-page shells,
|
||||
table primitives) as the plugin frontend's only allowed core imports,
|
||||
the frontend analogue of the `shopdb.api` namespace.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ADR-010: Frontend plugin hook contract
|
||||
|
||||
- **Status:** PROPOSED
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-11
|
||||
- **Accepted:** 2026-07-11
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
@@ -112,7 +113,7 @@ not pursued.
|
||||
|
||||
## Decision
|
||||
|
||||
**PROPOSED:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
|
||||
**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
|
||||
the four presentation surfaces a generic core renderer can serve, and keep
|
||||
**file-convention glob discovery** (Option C) as the deferred mechanism for the
|
||||
residual cases where a real component is unavoidable. Do not pursue runtime
|
||||
|
||||
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).
|
||||
@@ -22,8 +22,9 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
|
||||
| [008](ADR-008-plugin-migration-ownership.md) | Plugin migration ownership (per-plugin chains) | ACCEPTED |
|
||||
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | PROPOSED |
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | ACCEPTED |
|
||||
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
|
||||
| [012](ADR-012-geenforce-manifest-ownership.md) | GE-Enforce manifest ownership in shopdb | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
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).
|
||||
2292
frontend/package-lock.json
generated
2292
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
@@ -25,6 +27,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"vite": "^6.4.1"
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import axios from 'axios'
|
||||
import { withBase, stripBase } from './../utils/basePath'
|
||||
|
||||
// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath
|
||||
// mount. Keeps the SPA, its API, and IIS all on the same mount path.
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: import.meta.env.BASE_URL + 'api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -28,9 +31,17 @@ api.interceptors.response.use(
|
||||
const hadToken = localStorage.getItem('token')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// Only redirect if user was previously logged in (session expired)
|
||||
// Only redirect if user was previously logged in (session expired).
|
||||
// Preserve the destination so login returns the user to this page.
|
||||
if (hadToken) {
|
||||
window.location.href = '/login'
|
||||
const loginPath = withBase('/login')
|
||||
// Router paths exclude the mount base; strip it or login's
|
||||
// router.push double-prefixes under a subpath mount.
|
||||
const here = stripBase(window.location.pathname) + window.location.search
|
||||
const target = here && here !== '/login'
|
||||
? loginPath + '?redirect=' + encodeURIComponent(here)
|
||||
: loginPath
|
||||
window.location.href = target
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
@@ -55,6 +66,9 @@ export const authApi = {
|
||||
return api.post('/auth/refresh', {}, {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||
})
|
||||
},
|
||||
changePassword(payload) {
|
||||
return api.post('/auth/change-password', payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +403,15 @@ export const modelsApi = {
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/models/${id}`)
|
||||
},
|
||||
uploadImage(id, file) {
|
||||
// multipart photo upload; backend sets imageurl to the served URL
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeImage(id) {
|
||||
return api.delete(`/models/${id}/image`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,20 +493,36 @@ export const applicationsApi = {
|
||||
},
|
||||
updateInstalledApp(machineId, appId, data) {
|
||||
return api.put(`/applications/machines/${machineId}/${appId}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
// Support Teams API (teams + nested contacts)
|
||||
export const supportteamsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/supportteams', { params })
|
||||
},
|
||||
// Support teams
|
||||
getSupportTeams() {
|
||||
return api.get('/applications/supportteams')
|
||||
get(id) {
|
||||
return api.get(`/supportteams/${id}`)
|
||||
},
|
||||
createSupportTeam(data) {
|
||||
return api.post('/applications/supportteams', data)
|
||||
create(data) {
|
||||
return api.post('/supportteams', data)
|
||||
},
|
||||
// App owners
|
||||
getAppOwners() {
|
||||
return api.get('/applications/appowners')
|
||||
update(id, data) {
|
||||
return api.put(`/supportteams/${id}`, data)
|
||||
},
|
||||
createAppOwner(data) {
|
||||
return api.post('/applications/appowners', data)
|
||||
remove(id) {
|
||||
return api.delete(`/supportteams/${id}`)
|
||||
},
|
||||
contacts: {
|
||||
add(teamId, data) {
|
||||
return api.post(`/supportteams/${teamId}/contacts`, data)
|
||||
},
|
||||
update(teamId, contactId, data) {
|
||||
return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data)
|
||||
},
|
||||
remove(teamId, contactId) {
|
||||
return api.delete(`/supportteams/${teamId}/contacts/${contactId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +723,11 @@ export const reportsApi = {
|
||||
},
|
||||
pcRelationships(params = {}) {
|
||||
return api.get('/reports/pc-relationships', { params })
|
||||
},
|
||||
// On-demand report delivery: email the given rows as an HTML table.
|
||||
// Recipients default to the site Alert Recipients when `to` is omitted.
|
||||
email(payload) {
|
||||
return api.post('/reports/email', payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +758,15 @@ export const employeesApi = {
|
||||
},
|
||||
importCsv(csv) {
|
||||
return api.post('/employees/directory/import', { csv })
|
||||
},
|
||||
// multipart photo upload; backend sets photofilename + returns photourl
|
||||
uploadPhoto(sso, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removePhoto(sso) {
|
||||
return api.delete(`/employees/${sso}/photo`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,6 +836,9 @@ export const settingsApi = {
|
||||
update(key, value) {
|
||||
return api.put(`/settings/${key}`, { value })
|
||||
},
|
||||
testEmail(to) {
|
||||
return api.post('/settings/test-email', { to })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/settings', data)
|
||||
},
|
||||
@@ -872,6 +928,24 @@ export const usersApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Personal API tokens: authenticate scripts/integrations as a user without
|
||||
// the hourly-expiring login JWT. The secret is returned ONCE, on create.
|
||||
export const apitokensApi = {
|
||||
// all=true (admin) lists everyone's tokens; otherwise just the caller's.
|
||||
list(params = {}) {
|
||||
return api.get('/apitokens', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/apitokens', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/apitokens/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/apitokens/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Network API (devices, subnets, and VLANs)
|
||||
export const networkApi = {
|
||||
// Network devices
|
||||
@@ -1052,3 +1126,32 @@ export const measuringtoolsApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
},
|
||||
get(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printedparts/items', data)
|
||||
},
|
||||
update(printeditemid, data) {
|
||||
return api.put(`/printedparts/items/${printeditemid}`, data)
|
||||
},
|
||||
remove(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
deleteImage(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,26 +624,26 @@ input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Dark mode form adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-control {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Dark mode form adjustments. Scoped to the explicit theme attribute (the
|
||||
theme store always stamps it at startup) - a bare prefers-color-scheme
|
||||
query here leaks dark widget styles into light mode on dark-OS machines. */
|
||||
[data-theme="dark"] .form-control {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
select.form-control option {
|
||||
background: var(--text);
|
||||
}
|
||||
[data-theme="dark"] select.form-control option {
|
||||
background: var(--bg-card-solid);
|
||||
}
|
||||
|
||||
/* Form grid */
|
||||
@@ -941,8 +941,17 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
td.actions {
|
||||
/* A td must stay display:table-cell - the .actions inline-flex above pulls
|
||||
the cell out of the table's row box, so its bottom border renders ~1px off
|
||||
from the other cells. Keep it a cell; space multiple buttons with a margin
|
||||
instead of the flex gap. */
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.actions .btn + .btn {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DETAIL PAGES (shared styles)
|
||||
@@ -1051,17 +1060,19 @@ td.actions {
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned
|
||||
grid) so cards distribute by height and the columns stay even no matter
|
||||
how many cards land on either side. display:contents flattens the two
|
||||
.content-column wrappers so their cards flow directly into the columns,
|
||||
which keeps the existing markup unchanged. */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 25px;
|
||||
column-count: 2;
|
||||
column-gap: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Section Cards */
|
||||
@@ -1070,6 +1081,10 @@ td.actions {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
/* multicol needs per-card spacing (column-gap is horizontal only) and
|
||||
must not split a card across the column break */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -1083,6 +1098,212 @@ td.actions {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Settings form controls - shared by the individual settings pages
|
||||
(branding, printing, email, integrations, identifiers, search, map...). */
|
||||
.setting-group {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.setting-group:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.setting-group h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.setting-description {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.setting-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.setting-row.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.setting-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.setting-row label span {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.setting-row input,
|
||||
.setting-row select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
.setting-row input[type="number"] {
|
||||
max-width: 120px;
|
||||
}
|
||||
.setting-row input:focus,
|
||||
.setting-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.input-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.toggle-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toggle-hint {
|
||||
display: block;
|
||||
margin-top: -0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--success);
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle-btn.active .toggle-slider {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
max-width: 400px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.inactive { background: var(--secondary); }
|
||||
.status-dot.warning { background: var(--warning); }
|
||||
.status-dot.pending { background: var(--primary); }
|
||||
.status-dot.success { background: var(--success); }
|
||||
.test-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.test-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
.test-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.view-logs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.view-logs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.identifier-matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.identifier-matrix th,
|
||||
.identifier-matrix td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.identifier-matrix th:first-child,
|
||||
.identifier-matrix td.identifier-name {
|
||||
text-align: left;
|
||||
}
|
||||
.identifier-matrix th {
|
||||
color: var(--text-light);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.identifier-matrix .identifier-name {
|
||||
color: var(--text);
|
||||
}
|
||||
.color-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.color-input-row input[type="color"] {
|
||||
width: 48px;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-input-row input[type="text"] {
|
||||
max-width: 160px;
|
||||
}
|
||||
.map-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.map-thumb {
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.map-thumb-dark { background: #222; }
|
||||
.settings-success {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Info List */
|
||||
.info-list {
|
||||
display: flex;
|
||||
@@ -1114,6 +1335,24 @@ td.actions {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Small email/Teams action buttons for support contacts */
|
||||
.contact-action {
|
||||
display: inline-block;
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--link);
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.contact-action:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Feature Tags */
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
@@ -1260,7 +1499,7 @@ td.actions {
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
column-count: 1;
|
||||
}
|
||||
|
||||
.audit-footer {
|
||||
@@ -1487,16 +1726,14 @@ td.actions {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.notification-item.type-incident {
|
||||
background: rgba(245, 54, 92, 0.1);
|
||||
}
|
||||
.notification-item.type-change {
|
||||
background: rgba(255, 136, 0, 0.1);
|
||||
}
|
||||
.notification-item.type-awareness {
|
||||
background: rgba(4, 185, 98, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-incident {
|
||||
background: rgba(245, 54, 92, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-change {
|
||||
background: rgba(255, 136, 0, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-awareness {
|
||||
background: rgba(4, 185, 98, 0.1);
|
||||
}
|
||||
|
||||
/* Light mode is now default, dark mode via prefers-color-scheme */
|
||||
@@ -1512,3 +1749,9 @@ td.actions {
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Clickable list rows: the whole row navigates to the item detail; interactive
|
||||
cells (the actions column, in-row links) stop propagation so they still work
|
||||
independently. */
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover td { background: var(--bg); }
|
||||
|
||||
@@ -18,30 +18,32 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Outgoing relationships (this asset controls/connects to...) -->
|
||||
<div v-if="outgoing.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Outgoing</h4>
|
||||
<!-- Symmetric connections: one direction-blind entry per peer -->
|
||||
<div v-if="connectedItems.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Connected</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in outgoing"
|
||||
:key="rel.relationshipid"
|
||||
v-for="item in connectedItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
|
||||
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
|
||||
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
|
||||
<div class="rel-line">
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
</div>
|
||||
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteRelationship(rel.relationshipid)"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
@@ -50,30 +52,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming relationships (...controls/connects to this asset) -->
|
||||
<div v-if="incoming.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Incoming</h4>
|
||||
<!-- Directional relationships: natural per-row phrasing, no jargon -->
|
||||
<div v-if="directionalItems.length > 0" class="relationship-group">
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in incoming"
|
||||
:key="rel.relationshipid"
|
||||
v-for="item in directionalItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
|
||||
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
|
||||
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
|
||||
<div class="rel-line">
|
||||
<template v-if="item.direction === 'outgoing'">
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-arrow">-></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"
|
||||
>
|
||||
×
|
||||
@@ -182,12 +196,12 @@ import { apiError } from '../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
assetId: {
|
||||
assetid: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// Alternative: lookup by machine/asset number
|
||||
machineNumber: {
|
||||
machinenumber: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
@@ -225,6 +239,71 @@ let searchTimeout = null
|
||||
|
||||
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
|
||||
|
||||
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
|
||||
// stored direction rows (both directions, plus any legacy duplicates) fold
|
||||
// into one displayed entry; its rowIds carries every collapsed relationshipid
|
||||
// so a delete removes them all.
|
||||
const connectedItems = computed(() => {
|
||||
const byPair = new Map()
|
||||
const rows = [
|
||||
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
|
||||
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
|
||||
]
|
||||
for (const { rel, peer } of rows) {
|
||||
if (rel.isdirectional !== false) continue
|
||||
const selfid = resolvedAssetId.value
|
||||
const peerid = peer?.assetid
|
||||
const lo = Math.min(selfid, peerid)
|
||||
const hi = Math.max(selfid, peerid)
|
||||
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
|
||||
const existing = byPair.get(key)
|
||||
if (existing) {
|
||||
existing.rowIds.push(rel.relationshipid)
|
||||
if (!existing.notes && rel.notes) existing.notes = rel.notes
|
||||
} else {
|
||||
byPair.set(key, {
|
||||
key,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer,
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byPair.values())
|
||||
})
|
||||
|
||||
// Directional types keep one entry per stored row with arrow phrasing.
|
||||
const directionalItems = computed(() => {
|
||||
const items = []
|
||||
for (const rel of outgoing.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `out-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.targetasset,
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
for (const rel of incoming.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `in-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.sourceasset,
|
||||
direction: 'incoming',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
|
||||
})
|
||||
@@ -236,14 +315,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetId, async () => {
|
||||
watch(() => props.assetid, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.machineNumber, async () => {
|
||||
watch(() => props.machinenumber, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
@@ -251,21 +330,20 @@ watch(() => props.machineNumber, async () => {
|
||||
})
|
||||
|
||||
async function resolveAssetId() {
|
||||
// If assetId is provided directly, use it
|
||||
if (props.assetId) {
|
||||
resolvedAssetId.value = props.assetId
|
||||
// If assetid is provided directly, use it
|
||||
if (props.assetid) {
|
||||
resolvedAssetId.value = props.assetid
|
||||
lookupFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to look up by machine number
|
||||
if (props.machineNumber) {
|
||||
if (props.machinenumber) {
|
||||
try {
|
||||
const response = await assetsApi.lookup(props.machineNumber)
|
||||
const response = await assetsApi.lookup(props.machinenumber)
|
||||
resolvedAssetId.value = response.data.data?.assetid
|
||||
lookupFailed.value = !resolvedAssetId.value
|
||||
} catch (error) {
|
||||
console.log('Asset lookup failed for:', props.machineNumber)
|
||||
resolvedAssetId.value = null
|
||||
lookupFailed.value = true
|
||||
loading.value = false
|
||||
@@ -356,11 +434,12 @@ async function saveRelationship() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRelationship(relationshipId) {
|
||||
// Delete every collapsed direction row behind a displayed entry.
|
||||
async function deleteItem(item) {
|
||||
if (!confirm('Remove this relationship?')) return
|
||||
|
||||
try {
|
||||
await assetsApi.deleteRelationship(relationshipId)
|
||||
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
|
||||
await loadRelationships()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
@@ -423,6 +502,11 @@ function getAssetRoute(asset) {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
/* Match .section-card spacing so relationships is its own card with a gap
|
||||
below (not visually merged with the Notes card) and does not split across
|
||||
a multicol break. */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -499,6 +583,24 @@ function getAssetRoute(asset) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rel-arrow {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-from {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
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>
|
||||
@@ -132,7 +132,9 @@ const assetTypeColorsMap = {
|
||||
'computer': '#2196F3', // Blue
|
||||
'printer': '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
'network_device': '#FF9800' // Orange (alternate key)
|
||||
'network_device': '#FF9800', // Orange (alternate key)
|
||||
'measuring_tool': '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
// Get asset type color with case-insensitive lookup
|
||||
@@ -229,6 +231,7 @@ function getSubtypeId(asset) {
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -384,6 +387,10 @@ function renderMarkers() {
|
||||
typeName = item.assettype || ''
|
||||
}
|
||||
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
|
||||
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
|
||||
if (item.dualpathpartner) {
|
||||
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
||||
}
|
||||
detailRoute = getAssetDetailRoute(item)
|
||||
} else {
|
||||
// Legacy machine mode
|
||||
|
||||
@@ -14,7 +14,7 @@ const state = reactive({
|
||||
|
||||
let inflight = null
|
||||
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
|
||||
|
||||
function applySetting(key, value) {
|
||||
const match = KEY_RE.exec(key)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,13 @@ const routes = [
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
// Forced/self-service password change (standalone, authenticated)
|
||||
{
|
||||
path: '/change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('../views/ChangePassword.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// First-run setup wizard (standalone, admin-only)
|
||||
{
|
||||
path: '/setup',
|
||||
@@ -70,6 +77,20 @@ const routes = [
|
||||
name: 'print-machine-badge',
|
||||
component: () => import('../views/print/MachineBadge.vue')
|
||||
},
|
||||
// Shared asset label/code generator for any asset type (public, like the
|
||||
// other /print/* routes). assettype = machine|computer|printer|
|
||||
// network_device|measuring_tool; id = the asset's plugin id.
|
||||
{
|
||||
path: '/print/asset-label/:assettype/:id',
|
||||
name: 'print-asset-label',
|
||||
component: () => import('../views/print/AssetLabel.vue')
|
||||
},
|
||||
// Batch label sheets (ULINE 6-up / mini 72-up) for one asset type.
|
||||
{
|
||||
path: '/print/asset-label-batch/:assettype',
|
||||
name: 'print-asset-label-batch',
|
||||
component: () => import('../views/print/AssetLabelBatch.vue')
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr',
|
||||
name: 'print-printer-qr-batch',
|
||||
@@ -96,7 +117,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
@@ -105,7 +126,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return next('/login')
|
||||
// Remember where they were headed so login can send them back there.
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
return next('/')
|
||||
@@ -114,6 +136,13 @@ router.beforeEach(async (to, from, next) => {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// Forced password change: an admin-set temporary password must be replaced
|
||||
// before the user reaches the rest of the app. Let them log out.
|
||||
if (authStore.isAuthenticated && authStore.mustChangePassword
|
||||
&& to.path !== '/change-password' && to.path !== '/login') {
|
||||
return next('/change-password')
|
||||
}
|
||||
|
||||
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
|
||||
// The enabled list is fetched once and cached; fail-open on error so a blip
|
||||
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).
|
||||
|
||||
@@ -89,6 +89,12 @@ export default [
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
@@ -155,10 +161,107 @@ export default [
|
||||
component: () => import('../../views/settings/EmployeeDirectory.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
component: () => import('../../views/settings/SystemSettings.vue'),
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/zabbix',
|
||||
name: 'zabbix-settings',
|
||||
component: () => import('../../views/settings/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dellwarranty',
|
||||
name: 'dell-warranty-settings',
|
||||
component: () => import('../../views/settings/DellWarrantySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypemapping',
|
||||
name: 'pctype-mapping-settings',
|
||||
component: () => import('../../views/settings/PCTypeMappingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/branding',
|
||||
name: 'branding-settings',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/audit',
|
||||
name: 'audit-settings',
|
||||
component: () => import('../../views/settings/AuditSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/authentication',
|
||||
name: 'authentication-settings',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/apitokens',
|
||||
name: 'apitokens-settings',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/floormap',
|
||||
name: 'floor-map-settings',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
|
||||
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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -19,6 +19,14 @@ export default [
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
// Resolve a tool from its core asset id (search rows / cross-links carry
|
||||
// assetid, not the extension id). Shares the detail component.
|
||||
path: 'measuringtools/by-asset/:assetid',
|
||||
name: 'measuringtool-by-asset',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/:id',
|
||||
name: 'measuringtool-detail',
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkDevicesList.vue'),
|
||||
component: () => import('../../views/network/NetworkHub.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
@@ -26,6 +26,17 @@ export default [
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
// Legacy path -> the Networks tab of the hub.
|
||||
path: 'networks',
|
||||
redirect: { path: '/network', query: { tab: 'networks' } }
|
||||
},
|
||||
{
|
||||
path: 'networks/:id',
|
||||
name: 'network-subnet-detail',
|
||||
component: () => import('../../views/network/SubnetDetail.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
// Network-specific settings
|
||||
{
|
||||
path: 'settings/vlans',
|
||||
|
||||
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/PrintedItemsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const ASSET_TYPE_LABELS = {
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'network device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ROUTES = {
|
||||
@@ -17,6 +18,7 @@ const ASSET_TYPE_ROUTES = {
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network',
|
||||
'measuring_tool': '/measuringtools',
|
||||
}
|
||||
|
||||
// Plugin-specific id field inside asset.typedata for each asset type.
|
||||
@@ -26,6 +28,7 @@ const ASSET_TYPE_ID_KEYS = {
|
||||
'printer': 'printerid',
|
||||
'network_device': 'networkdeviceid',
|
||||
'network device': 'networkdeviceid',
|
||||
'measuring_tool': 'measuringtoolid',
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -7,7 +7,9 @@ export const assetTypeColorsMap = {
|
||||
computer: '#2196F3', // Blue
|
||||
printer: '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
network_device: '#FF9800' // Orange (alternate key)
|
||||
network_device: '#FF9800', // Orange (alternate key)
|
||||
measuring_tool: '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#BDBDBD'
|
||||
@@ -37,6 +39,7 @@ export function getSubtypeId(asset) {
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The settings GET is public (jwt optional), so the kiosk dashboard and the
|
||||
// print views can read these without auth. Fetched once and cached per page.
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
|
||||
@@ -38,7 +39,7 @@ export async function getFacilityName() {
|
||||
|
||||
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
|
||||
export async function getSiteLogo() {
|
||||
return getSetting('site_logo', '/ge-aerospace-logo.svg')
|
||||
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))
|
||||
}
|
||||
|
||||
// Logo composited into the center of printer QR codes. Empty = no overlay.
|
||||
@@ -47,17 +48,18 @@ export async function getSiteLogo() {
|
||||
export async function getQrLogo() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['qr_logo']
|
||||
return (value === undefined || value === null) ? '/ge-monogram.svg' : value
|
||||
return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value)
|
||||
}
|
||||
|
||||
// Logo printed on machine inspection badges.
|
||||
export async function getBadgeLogo() {
|
||||
return getSetting('badge_logo', '/ge-aerospace-logo.svg')
|
||||
return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg'))
|
||||
}
|
||||
|
||||
// Browser-tab favicon. Empty = keep the shipped /favicon.svg.
|
||||
export async function getFavicon() {
|
||||
return getSetting('site_favicon', '')
|
||||
const value = await getSetting('site_favicon', '')
|
||||
return value ? withBase(value) : value
|
||||
}
|
||||
|
||||
// Brand primary color override. Empty = built-in palette from style.css.
|
||||
@@ -120,6 +122,16 @@ export async function getPrinterHostnameTemplate() {
|
||||
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
|
||||
}
|
||||
|
||||
// Email domain appended to a contact SSO to build email/Teams links
|
||||
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
|
||||
// means "disable contact action buttons", so read the raw setting rather than
|
||||
// getSetting (which swaps '' for the fallback).
|
||||
export async function getContactEmailDomain() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['contact_email_domain']
|
||||
return (value === undefined || value === null) ? 'geaerospace.com' : value
|
||||
}
|
||||
|
||||
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
|
||||
// shipped defaults. Do not touch the style.css palette here.
|
||||
export async function applyBranding() {
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</template>
|
||||
|
||||
<div class="nav-section">Displays</div>
|
||||
<a href="/shopfloor" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a href="/tv" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
@@ -39,13 +39,22 @@
|
||||
|
||||
<div class="user-menu">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
|
||||
:alt="authStore.displayName" @error="onAvatarError" />
|
||||
<div class="user-ident">
|
||||
<div class="username">{{ authStore.displayName }}</div>
|
||||
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
|
||||
<div class="user-identity">
|
||||
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
|
||||
:alt="authStore.displayName" @error="onAvatarError" />
|
||||
<div class="user-ident">
|
||||
<div class="username">{{ authStore.displayName }}</div>
|
||||
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<router-link to="/change-password" class="icon-btn" title="Change password">
|
||||
<KeyRound :size="16" /> Password
|
||||
</router-link>
|
||||
<button class="icon-btn" @click="handleLogout" title="Log out">
|
||||
<LogOut :size="16" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
||||
</template>
|
||||
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||
</div>
|
||||
@@ -75,32 +84,42 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
<!-- Keyed on path so same-component navigation (machine -> machine via
|
||||
a relationship link) remounts and reloads; query-only changes
|
||||
(e.g. /reports?report=x) do not remount, and the settings shell
|
||||
keys as one unit so its rail survives child navigation. -->
|
||||
<router-view :key="routeViewKey" />
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
const activeNotifications = ref([])
|
||||
const facilityName = ref('ShopDB')
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
|
||||
|
||||
function getTicketSearchUrl(ticketnumber) {
|
||||
@@ -128,6 +147,7 @@ const iconMap = {
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
@@ -229,8 +249,31 @@ function onAvatarError(event) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-menu { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); }
|
||||
.user-ident { display: flex; flex-direction: column; line-height: 1.1; }
|
||||
.user-sso { font-size: 0.72rem; color: var(--text-light); }
|
||||
/* Footer user block: identity row on top, compact icon actions below, all
|
||||
kept inside the fixed-width sidebar (no full-width buttons overflowing). */
|
||||
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
|
||||
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
|
||||
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
|
||||
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.user-actions { display: flex; gap: 0.5rem; }
|
||||
.icon-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
|
||||
</style>
|
||||
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<option value="computer">Computers</option>
|
||||
<option value="printer">Printers</option>
|
||||
<option value="network_device">Network Devices</option>
|
||||
<option value="measuring_tool">Measuring Tools</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +106,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
|
||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { assetsApi } from '../api'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
@@ -158,7 +159,8 @@ function getTypeIcon(assettype) {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="search-results">
|
||||
<div class="page-header">
|
||||
<h2>Search Results</h2>
|
||||
<span v-if="results.length" class="results-count">
|
||||
@@ -116,6 +116,7 @@ const typeLabels = {
|
||||
knowledgebase: 'KB',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
employee: 'Employee',
|
||||
notification: 'Notice',
|
||||
subnet: 'Subnet'
|
||||
@@ -127,6 +128,7 @@ const filterTypeMap = {
|
||||
computers: ['computer'],
|
||||
printers: ['printer'],
|
||||
network: ['network_device', 'subnet'],
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
notifications: ['notification'],
|
||||
@@ -139,6 +141,7 @@ const filterList = [
|
||||
{ key: 'computers', label: 'PCs' },
|
||||
{ key: 'printers', label: 'Printers' },
|
||||
{ key: 'network', label: 'Network' },
|
||||
{ key: 'measuringtools', label: 'Measuring Tools' },
|
||||
{ key: 'applications', label: 'Apps' },
|
||||
{ key: 'knowledgebase', label: 'KB' },
|
||||
{ key: 'notifications', label: 'Notices' },
|
||||
@@ -382,51 +385,33 @@ watch(results, () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-type.machine {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
/* Per-domain badge palette. Values live in CSS variables on the container so
|
||||
the dark theme overrides them in one place (below) instead of restating
|
||||
every selector. Each badge rule just references its pair. */
|
||||
.search-results {
|
||||
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
|
||||
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
||||
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
||||
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
||||
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
||||
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
||||
}
|
||||
|
||||
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
|
||||
.result-type.pc,
|
||||
.result-type.computer {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.result-type.application {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.result-type.knowledgebase {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.result-type.printer {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.result-type.network_device {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.result-type.employee {
|
||||
background: #e0f2f1;
|
||||
color: #00695c;
|
||||
}
|
||||
|
||||
.result-type.notification {
|
||||
background: #e8eaf6;
|
||||
color: #283593;
|
||||
}
|
||||
|
||||
.result-type.subnet {
|
||||
background: #fbe9e7;
|
||||
color: #bf360c;
|
||||
}
|
||||
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
||||
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
||||
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
||||
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
||||
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
@@ -456,10 +441,6 @@ watch(results, () => {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.result-location::before {
|
||||
content: '\1F4CD ';
|
||||
}
|
||||
|
||||
.result-ticket {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
@@ -482,50 +463,17 @@ watch(results, () => {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.result-type.machine {
|
||||
background: rgba(21, 101, 192, 0.2);
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.result-type.pc,
|
||||
.result-type.computer {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.result-type.application {
|
||||
background: rgba(230, 81, 0, 0.2);
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.result-type.knowledgebase {
|
||||
background: rgba(123, 31, 162, 0.2);
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.result-type.printer {
|
||||
background: rgba(194, 24, 91, 0.2);
|
||||
color: #f48fb1;
|
||||
}
|
||||
|
||||
.result-type.network_device {
|
||||
background: rgba(245, 127, 23, 0.2);
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.result-type.employee {
|
||||
background: rgba(0, 105, 92, 0.2);
|
||||
color: #80cbc4;
|
||||
}
|
||||
|
||||
.result-type.notification {
|
||||
background: rgba(40, 53, 147, 0.2);
|
||||
color: #9fa8da;
|
||||
}
|
||||
|
||||
.result-type.subnet {
|
||||
background: rgba(191, 54, 12, 0.2);
|
||||
color: #ffab91;
|
||||
.search-results {
|
||||
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
|
||||
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
|
||||
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
|
||||
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
||||
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
||||
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
||||
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
||||
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
||||
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
||||
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">
|
||||
@@ -99,7 +99,7 @@
|
||||
</div>
|
||||
|
||||
<!-- USB Checkout History -->
|
||||
<div class="section-card">
|
||||
<div v-if="usbEnabled" class="section-card">
|
||||
<h2 class="section-title">USB Checkout History</h2>
|
||||
<div v-if="historyLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="checkoutHistory.length === 0" class="empty">
|
||||
@@ -115,7 +115,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in checkoutHistory" :key="record.log_id">
|
||||
<tr v-for="record in displayedHistory" :key="record.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${record.device_id}`">
|
||||
{{ record.device_id }}
|
||||
@@ -126,6 +126,20 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
v-if="checkoutHistory.length > historyLimit && !showAllHistory"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllHistory = true"
|
||||
>
|
||||
Show {{ checkoutHistory.length - historyLimit }} more
|
||||
</button>
|
||||
<button
|
||||
v-if="showAllHistory && checkoutHistory.length > historyLimit"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllHistory = false"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -136,6 +150,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { employeesApi, usbApi, notificationsApi } from '@/api'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '../../composables/toast'
|
||||
const toast = useToast()
|
||||
|
||||
@@ -145,6 +160,7 @@ const employee = ref(null)
|
||||
const recognitions = ref([])
|
||||
const usbDevices = ref([])
|
||||
const checkoutHistory = ref([])
|
||||
const usbEnabled = ref(true)
|
||||
const loading = ref(true)
|
||||
const recognitionsLoading = ref(true)
|
||||
const usbLoading = ref(true)
|
||||
@@ -161,6 +177,16 @@ const displayedRecognitions = computed(() => {
|
||||
return recognitions.value.slice(0, recognitionsLimit)
|
||||
})
|
||||
|
||||
const historyLimit = 10
|
||||
const showAllHistory = ref(false)
|
||||
|
||||
const displayedHistory = computed(() => {
|
||||
if (showAllHistory.value) {
|
||||
return checkoutHistory.value
|
||||
}
|
||||
return checkoutHistory.value.slice(0, historyLimit)
|
||||
})
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (!employee.value) return ''
|
||||
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
|
||||
@@ -175,7 +201,13 @@ const initials = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEmployee()
|
||||
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()])
|
||||
// Skip the USB panels entirely when the usb plugin is disabled - its
|
||||
// /api/usb routes 404 otherwise and spam the console.
|
||||
await loadEnabledPlugins()
|
||||
usbEnabled.value = isPluginEnabled('usb')
|
||||
const tasks = [loadRecognitions()]
|
||||
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
|
||||
await Promise.all(tasks)
|
||||
})
|
||||
|
||||
async function loadEmployee() {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Badge
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
@@ -16,8 +19,21 @@
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
|
||||
<div v-if="machine.dualpathpartner" class="dualpath-banner">
|
||||
Dual-bay machine - sibling bay:
|
||||
<router-link
|
||||
v-if="machine.dualpathpartner.machineid"
|
||||
:to="`/machines/${machine.dualpathpartner.machineid}`"
|
||||
>{{ machine.dualpathpartner.assetnumber }}</router-link>
|
||||
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="machine.machine?.imageurl">
|
||||
<img :src="machine.machine.imageurl" :alt="machine.machine.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ machine.assetnumber }}</h1>
|
||||
@@ -56,6 +72,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -121,6 +138,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Configuration -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Configuration</h3>
|
||||
@@ -154,34 +200,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="machine.assetid" />
|
||||
@@ -190,7 +208,7 @@
|
||||
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (dualpath, controls, ...) -->
|
||||
<AssetRelationships v-if="machine.assetid" :assetId="machine.assetid" />
|
||||
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="machine.notes">
|
||||
@@ -264,6 +282,22 @@ function formatDate(dateStr) {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.dualpath-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--primary);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dualpath-banner a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.625rem;
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
|
||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '../../api'
|
||||
import ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
|
||||
@@ -463,11 +463,8 @@ onMounted(async () => {
|
||||
|
||||
// Load relationship types separately
|
||||
try {
|
||||
const relRes = await fetch('/api/assets/relationshiptypes')
|
||||
if (relRes.ok) {
|
||||
const relData = await relRes.json()
|
||||
relationshipTypes.value = relData.data || []
|
||||
}
|
||||
const relRes = await relationshipTypesApi.list()
|
||||
relationshipTypes.value = relRes.data.data || []
|
||||
} catch (e) {
|
||||
// Fallback - use hardcoded Controls type
|
||||
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
|
||||
@@ -523,7 +520,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load relationships')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machines</h2>
|
||||
<router-link to="/print/asset-label-batch/machine" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +36,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in machines" :key="item.assetid">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.machine?.machinetypename || '-' }}</td>
|
||||
@@ -47,7 +50,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -84,11 +87,11 @@ import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { machinesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const machines = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -120,19 +123,19 @@ async function loadMachines() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadMachines()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadMachines()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadMachines()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/measuring_tool/${tool?.measuringtool?.measuringtoolid}`" class="btn btn-secondary" v-if="tool" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
@@ -45,6 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -61,10 +65,14 @@
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.gaugelabreference">
|
||||
<div class="info-row" v-if="tool.gaugelabreference && isEnabled('gaugelabreference', 'measuring_tool')">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ tool.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.maintenancereference && isEnabled('maintenancereference', 'measuring_tool')">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ tool.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ tool.serialnumber }}</span>
|
||||
@@ -132,7 +140,7 @@
|
||||
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (partof, connectedto, ...) -->
|
||||
<AssetRelationships v-if="tool.assetid" :assetId="tool.assetid" />
|
||||
<AssetRelationships v-if="tool.assetid" :assetid="tool.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="tool.notes">
|
||||
@@ -164,9 +172,12 @@ import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const tool = ref(null)
|
||||
const { warranties } = useWarrantyBadge(() => tool.value?.assetid)
|
||||
@@ -178,7 +189,11 @@ function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleStri
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
// by-asset route resolves the tool from a core asset id (search/cross-link
|
||||
// rows carry assetid, not the extension id); detail route keys on the id.
|
||||
const response = route.params.assetid
|
||||
? await measuringtoolsApi.getByAsset(route.params.assetid)
|
||||
: await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool:', err)
|
||||
|
||||
@@ -27,13 +27,21 @@
|
||||
<label for="serialnumber">Serial Number (vendor)</label>
|
||||
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'measuring_tool')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input id="gaugelabreference" v-model="form.gaugelabreference" type="text" class="form-control" />
|
||||
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('maintenancereference', 'measuring_tool')">
|
||||
<div class="form-group">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input id="maintenancereference" v-model="form.maintenancereference" type="text" class="form-control" />
|
||||
<small class="form-help">Maintenance system asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="measuringtooltypeid">Type</label>
|
||||
@@ -126,11 +134,14 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { measuringtoolsApi, assetsApi, locationsApi, businessunitsApi } from '../../api'
|
||||
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Resolved dynamically from /api/assets/types (no hardcoded id) so custom
|
||||
@@ -147,6 +158,7 @@ const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: '',
|
||||
measuringtooltypeid: '',
|
||||
@@ -190,6 +202,7 @@ onMounted(async () => {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || '',
|
||||
measuringtooltypeid: ext.measuringtooltypeid || '',
|
||||
@@ -218,6 +231,7 @@ async function saveTool() {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || null,
|
||||
measuringtooltypeid: form.value.measuringtooltypeid || null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tools</h2>
|
||||
<router-link to="/print/asset-label-batch/measuring_tool" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in tools" :key="item.assetid">
|
||||
<tr v-for="item in tools" :key="item.assetid" class="clickable-row" @click="$router.push(`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
@@ -60,7 +61,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -95,14 +96,14 @@ import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const tools = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -141,23 +142,26 @@ async function loadTools() {
|
||||
}
|
||||
|
||||
function reload() {
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(reload, 300)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadTools()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -90,14 +90,14 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const notifications = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
|
||||
const selectedType = ref('')
|
||||
const currentFilter = ref('')
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(1)
|
||||
@@ -152,19 +152,19 @@ async function loadNotifications() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(searchQuery.value)
|
||||
loadNotifications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
@@ -13,6 +14,9 @@
|
||||
<template v-else-if="computer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="computer.computer?.imageurl">
|
||||
<img :src="computer.computer.imageurl" :alt="computer.computer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
@@ -45,6 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -114,6 +119,25 @@
|
||||
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
@@ -136,28 +160,9 @@
|
||||
<p v-else class="muted">No network addresses on record</p>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
@@ -187,15 +192,15 @@
|
||||
<router-link
|
||||
v-for="app in installedApps"
|
||||
:key="app.id"
|
||||
:to="`/applications/${app.application?.appid}`"
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="app-item"
|
||||
>
|
||||
<div class="app-info">
|
||||
<span class="app-name">{{ app.application?.appname }}</span>
|
||||
<span class="app-version" v-if="app.version">v{{ app.version }}</span>
|
||||
<span class="app-name">{{ app.appname }}</span>
|
||||
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
||||
</div>
|
||||
<div class="app-desc" v-if="app.application?.appdescription">
|
||||
{{ app.application.appdescription }}
|
||||
<div class="app-desc" v-if="app.appdescription">
|
||||
{{ app.appdescription }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -208,7 +213,7 @@
|
||||
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetId="computer.assetid" />
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="computer.notes">
|
||||
@@ -265,7 +270,6 @@ onMounted(async () => {
|
||||
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
||||
installedApps.value = appsResponse.data.data || []
|
||||
} catch (appError) {
|
||||
console.log('No installed apps data:', appError.message)
|
||||
}
|
||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,210 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Computers</h2>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in computers" :key="item.assetid">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.computer?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(item.accessmethods || []).length">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadComputers()
|
||||
})
|
||||
|
||||
async function loadComputers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadComputers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
margin: 0 4px 3px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||
offsets the row. lay tags out inline instead. */
|
||||
.features {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* the global .actions rule is inline-flex, which also breaks table-cell
|
||||
alignment when applied straight on a <td>; pin it back to a cell here. */
|
||||
td.actions {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Computers</h2>
|
||||
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.computer?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(item.accessmethods || []).length">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadComputers()
|
||||
})
|
||||
|
||||
async function loadComputers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadComputers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
margin: 0 4px 3px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||
offsets the row. lay tags out inline instead. */
|
||||
.features {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* the global .actions rule is inline-flex, which also breaks table-cell
|
||||
alignment when applied straight on a <td>; pin it back to a cell here. */
|
||||
td.actions {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
425
frontend/src/views/print/AssetLabel.vue
Normal file
425
frontend/src/views/print/AssetLabel.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print Asset Label</h3>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="!asset" class="error-msg">Asset not found</div>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Style
|
||||
<select v-model="style">
|
||||
<option value="card">Card (badge)</option>
|
||||
<option value="plain">Plain (code only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<div class="control-row">
|
||||
<label>Output
|
||||
<select v-model="outputMode">
|
||||
<option value="single">Single label (standalone)</option>
|
||||
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
|
||||
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
|
||||
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
|
||||
<span class="cell-picker-label">Sheet cell</span>
|
||||
<div class="cell-grid">
|
||||
<button
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
type="button"
|
||||
class="cell-btn"
|
||||
:class="{ active: cellNum === sheetCell }"
|
||||
@click="sheetCell = cellNum"
|
||||
>{{ cellNum }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn" @click="print">Print</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable area: standalone single label (unchanged default). -->
|
||||
<div v-if="asset && outputMode === 'single'" class="label-sheet">
|
||||
<div class="asset-label" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
|
||||
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
|
||||
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
|
||||
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
|
||||
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
|
||||
<div v-else-if="asset" class="sheets-container">
|
||||
<div class="print-sheet">
|
||||
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
|
||||
>
|
||||
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const loading = ref(true)
|
||||
const asset = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
const qrImage = ref('')
|
||||
// The resolved string the code encodes; empty when the chosen field has no
|
||||
// value (e.g. serial number on an asset with none) so the UI can explain it.
|
||||
const codeText = ref('')
|
||||
|
||||
const style = ref('card')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
|
||||
// label at sheetCell (1-6) of a ULINE 6-up sheet.
|
||||
const outputMode = ref('single')
|
||||
const sheetCell = ref(1)
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
|
||||
|
||||
const cardTitle = computed(() => {
|
||||
if (!asset.value) return ''
|
||||
const ext = extension.value || {}
|
||||
return ext.modelname
|
||||
|| ext.measuringtooltypename
|
||||
|| asset.value.assettypename
|
||||
|| (config ? config.label : 'Asset')
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
const ext = extension.value
|
||||
return (ext && ext.imageurl) ? ext.imageurl : null
|
||||
})
|
||||
|
||||
// Small identity table shown on the card.
|
||||
const identityFields = computed(() => {
|
||||
if (!asset.value) return []
|
||||
const rows = []
|
||||
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
|
||||
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
|
||||
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
|
||||
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
|
||||
return rows
|
||||
})
|
||||
|
||||
// Human caption printed under the code.
|
||||
const caption = computed(() => captionFor(asset.value, encodes.value))
|
||||
|
||||
// Human phrase for the current encode mode, used in the "nothing to encode"
|
||||
// message.
|
||||
const ENCODE_LABELS = {
|
||||
assetnumber: 'asset number', serialnumber: 'serial number',
|
||||
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
||||
}
|
||||
const encodeLabel = computed(() =>
|
||||
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
||||
|
||||
async function renderCode() {
|
||||
const text = await resolveText(assettype, asset.value, encodes.value)
|
||||
codeText.value = text
|
||||
if (!text) { qrImage.value = ''; return }
|
||||
if (codetype.value === 'qr') {
|
||||
qrImage.value = await renderQrDataUrl(text)
|
||||
} else {
|
||||
await nextTick()
|
||||
if (!barcodeEl.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, text, {
|
||||
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loading.value = false; return }
|
||||
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.get(route.params.id)
|
||||
asset.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading asset:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
renderCode()
|
||||
}
|
||||
})
|
||||
|
||||
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
|
||||
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
|
||||
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
|
||||
/* pages let each render its own size (only one branch renders at a time). */
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
@page uline { size: letter; margin: 0; }
|
||||
.print-sheet { page: uline; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
||||
|
||||
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
|
||||
.cell-picker { margin-bottom: 12px; }
|
||||
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 3rem);
|
||||
grid-auto-rows: 3rem;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cell-btn:hover { border-color: var(--primary); }
|
||||
.cell-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.print-btn:hover { background: var(--primary-dark); }
|
||||
|
||||
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
|
||||
|
||||
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
|
||||
|
||||
.asset-label {
|
||||
width: 2.13in;
|
||||
min-height: 3.38in;
|
||||
background: white;
|
||||
color: #000;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
padding: 0.15in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-label.plain { justify-content: center; min-height: 2in; }
|
||||
|
||||
.label-title {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-image {
|
||||
max-width: 1.6in;
|
||||
max-height: 1.2in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-fields { width: 100%; margin-bottom: 0.08in; }
|
||||
.label-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 8pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label { color: #555; }
|
||||
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
|
||||
|
||||
.code-area {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.code-qr { width: 1.5in; height: 1.5in; }
|
||||
.code-barcode { width: 1.8in; height: 0.9in; }
|
||||
.code-caption {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: 0.02in;
|
||||
}
|
||||
.code-missing {
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
padding: 0.3in 0.1in;
|
||||
}
|
||||
|
||||
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
|
||||
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
|
||||
.label .asset-label.in-cell {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
|
||||
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
|
||||
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
|
||||
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.label-sheet { padding: 0; }
|
||||
.asset-label { border: none; }
|
||||
.sheets-container { padding: 0; }
|
||||
.print-sheet { border: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
body, .asset-label, .code-qr, .code-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print {{ config ? config.label : 'Asset' }} Labels</h3>
|
||||
<p v-if="!config" class="error-msg">Unknown asset type.</p>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Format
|
||||
<select v-model="format">
|
||||
<option value="uline6">ULINE 6-up (3 in x 3 in, 6 per page)</option>
|
||||
<option value="mini72">Mini 72-up (12 per cell, 72 per page)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start at cell
|
||||
<select v-model="startCell">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingAssets" class="loading-msg">Loading assets...</div>
|
||||
<div v-else-if="assets.length === 0" class="loading-msg">No assets found.</div>
|
||||
<div v-else class="asset-grid">
|
||||
<div
|
||||
v-for="asset in assets"
|
||||
:key="asset.assetid"
|
||||
class="asset-item"
|
||||
:class="{ selected: isSelected(asset) }"
|
||||
@click="toggle(asset)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(asset)" @click.stop />
|
||||
<label>
|
||||
<strong>{{ asset.assetnumber || '-' }}</strong>
|
||||
<div class="asset-sub">{{ asset.name || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selected.length }}</span>
|
||||
(<span class="pages">{{ pageCount }}</span> page{{ pageCount === 1 ? '' : 's' }})
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selected.length === 0" @click="print">Print Labels</button>
|
||||
<button class="secondary-btn" @click="selectAll">Select All</button>
|
||||
<button class="secondary-btn" @click="clearSelection">Clear</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable sheets -->
|
||||
<div class="sheets-container">
|
||||
<!-- ULINE 6-up: one asset per 3in cell -->
|
||||
<template v-if="format === 'uline6'">
|
||||
<div v-for="(page, pageIdx) in uline6Pages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, page[cellNum - 1] ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="page[cellNum - 1]">
|
||||
<div class="model-name">{{ titleFor(page[cellNum - 1]) }}</div>
|
||||
<div class="qr-container">
|
||||
<img
|
||||
v-if="codetype === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrMap[page[cellNum - 1].assetid] || ''"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
:ref="el => setBarcodeRef(el, page[cellNum - 1].assetid, 'u')"
|
||||
class="barcode-svg"
|
||||
></svg>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ captionMap[page[cellNum - 1].assetid] }}</div>
|
||||
<div v-if="page[cellNum - 1].name" class="info-row">{{ page[cellNum - 1].name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mini 72-up: 6 cells x 12 mini labels -->
|
||||
<template v-else>
|
||||
<div v-for="(page, pageIdx) in mini72Pages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(item, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="item ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="item">
|
||||
<img v-if="codetype === 'qr'" class="mini-qr" :src="qrMap[item.assetid] || ''" />
|
||||
<svg v-else :ref="el => setBarcodeRef(el, item.assetid, 'm')" class="mini-barcode"></svg>
|
||||
<div class="serial-text">{{ captionMap[item.assetid] }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
resolveCodeText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const MINI_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const assets = ref([])
|
||||
const selected = ref([])
|
||||
const loadingAssets = ref(true)
|
||||
const format = ref('uline6')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
const startCell = ref('1')
|
||||
|
||||
// assetid -> encoded code text / caption / QR data URL. Barcodes render to refs.
|
||||
const codeMap = ref({})
|
||||
const captionMap = ref({})
|
||||
const qrMap = ref({})
|
||||
const barcodeRefs = ref({})
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
function isSelected(asset) {
|
||||
return selected.value.some(a => a.assetid === asset.assetid)
|
||||
}
|
||||
function toggle(asset) {
|
||||
const idx = selected.value.findIndex(a => a.assetid === asset.assetid)
|
||||
if (idx > -1) selected.value.splice(idx, 1)
|
||||
else selected.value.push(asset)
|
||||
}
|
||||
function selectAll() { selected.value = [...assets.value] }
|
||||
function clearSelection() { selected.value = [] }
|
||||
|
||||
function titleFor(asset) {
|
||||
return asset.name || (config ? config.label : 'Asset')
|
||||
}
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selected.value.length === 0) return 0
|
||||
const skipped = parseInt(startCell.value) - 1
|
||||
if (format.value === 'uline6') {
|
||||
return Math.ceil((selected.value.length + skipped) / CELLS_PER_PAGE)
|
||||
}
|
||||
const cells = Math.ceil(selected.value.length / MINI_PER_CELL)
|
||||
return Math.ceil((cells + skipped) / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
// ULINE 6-up: each page is 6 cells, each holding one asset (or null).
|
||||
const uline6Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = page === 0 && cell < skip
|
||||
cells.push((!blank && idx < selected.value.length) ? selected.value[idx++] : null)
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
// Mini 72-up: each page is 6 cells, each holding up to 12 mini labels.
|
||||
const mini72Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = (page === 0 && cell < skip) || idx >= selected.value.length
|
||||
const items = []
|
||||
if (!blank) {
|
||||
for (let mini = 0; mini < MINI_PER_CELL; mini++) {
|
||||
items.push(idx < selected.value.length ? selected.value[idx++] : null)
|
||||
}
|
||||
}
|
||||
cells.push({ hasContent: !blank, items })
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
function setBarcodeRef(el, assetid, prefix) {
|
||||
if (el) barcodeRefs.value[`${prefix}-${assetid}`] = el
|
||||
}
|
||||
|
||||
// Resolve the encode text + caption for every loaded asset once, then per
|
||||
// encode-mode change. Kept over the whole list (cheap) so selection is instant.
|
||||
async function buildCodeMap() {
|
||||
const codes = {}
|
||||
const captions = {}
|
||||
for (const asset of assets.value) {
|
||||
codes[asset.assetid] = await resolveCodeText(assettype, asset, encodes.value)
|
||||
captions[asset.assetid] = captionFor(asset, encodes.value)
|
||||
}
|
||||
codeMap.value = codes
|
||||
captionMap.value = captions
|
||||
}
|
||||
|
||||
async function buildQrMap() {
|
||||
if (codetype.value !== 'qr') return
|
||||
const images = {}
|
||||
for (const asset of selected.value) {
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (text) images[asset.assetid] = await renderQrDataUrl(text)
|
||||
}
|
||||
qrMap.value = images
|
||||
}
|
||||
|
||||
function renderBarcodes() {
|
||||
if (codetype.value !== 'barcode') return
|
||||
const prefix = format.value === 'uline6' ? 'u' : 'm'
|
||||
const height = format.value === 'uline6' ? 60 : 22
|
||||
const width = format.value === 'uline6' ? 2 : 1
|
||||
for (const asset of selected.value) {
|
||||
const el = barcodeRefs.value[`${prefix}-${asset.assetid}`]
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (!el || !text) continue
|
||||
try {
|
||||
JsBarcode(el, text, {
|
||||
format: 'CODE128', displayValue: false, width, height, margin: 0,
|
||||
background: 'transparent',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', asset.assetnumber, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCodes() {
|
||||
await buildCodeMap()
|
||||
await refreshRender()
|
||||
}
|
||||
|
||||
async function refreshRender() {
|
||||
await buildQrMap()
|
||||
await nextTick()
|
||||
renderBarcodes()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loadingAssets.value = false; return }
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.list({ perpage: 500 })
|
||||
assets.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading assets:', err)
|
||||
} finally {
|
||||
loadingAssets.value = false
|
||||
await refreshCodes()
|
||||
}
|
||||
})
|
||||
|
||||
watch(encodes, refreshCodes)
|
||||
watch([selected, format, codetype, startCell], refreshRender, { deep: true })
|
||||
|
||||
function print() { window.print() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.asset-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asset-item:hover { border-color: var(--primary); }
|
||||
.asset-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.asset-item input { margin-right: 10px; }
|
||||
.asset-item label { cursor: pointer; flex: 1; }
|
||||
.asset-item .asset-sub { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 12px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
.secondary-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.loading-msg, .error-msg { text-align: center; padding: 1.5rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
/* ULINE 6-up cells */
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
||||
.qr-container { text-align: center; }
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
.barcode-svg { width: 2.4in; height: 0.8in; }
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; color: #000; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; text-align: center; }
|
||||
|
||||
/* Mini 72-up cells */
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
.mini-qr { width: 0.48in; height: 0.48in; }
|
||||
.mini-barcode { max-width: 0.9in; height: 24px; }
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in label positions on a letter sheet */
|
||||
.pos-1, .cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2, .cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3, .cell-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4, .cell-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5, .cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6, .cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
@media print {
|
||||
body, .print-sheet, .label, .label-cell, .mini-label, .qr-img, .mini-qr, .barcode-svg, .mini-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import { getBadgeLogo } from '@/utils/siteSettings'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -37,7 +38,7 @@ const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
|
||||
const geLogo = ref('/ge-aerospace-logo.svg')
|
||||
const geLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
|
||||
const isInspection = computed(() => {
|
||||
if (!machine.value) return false
|
||||
|
||||
99
frontend/src/views/print/assetLabel.js
Normal file
99
frontend/src/views/print/assetLabel.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
|
||||
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
|
||||
// encode" resolution so both surfaces behave identically.
|
||||
import {
|
||||
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
|
||||
} from '../../api'
|
||||
import { fillUrlTemplate } from '@/utils/qrTarget'
|
||||
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
|
||||
|
||||
// Per asset-type: which api loads it, its detail/list routes, the extension key
|
||||
// nested in the merged payload, and its qr_target_<type> settings key.
|
||||
export const TYPE_CONFIG = {
|
||||
machine: {
|
||||
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
|
||||
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
|
||||
},
|
||||
computer: {
|
||||
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
|
||||
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
|
||||
},
|
||||
printer: {
|
||||
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
|
||||
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
|
||||
},
|
||||
network_device: {
|
||||
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
|
||||
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
|
||||
},
|
||||
measuring_tool: {
|
||||
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
|
||||
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
|
||||
},
|
||||
}
|
||||
|
||||
// Hardcoded fallback default per type when the site setting is unset. Machines
|
||||
// encode their machine number, measuring tools their inspection location.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
|
||||
|
||||
export function hasLocationType(assettype) {
|
||||
return assettype === 'measuring_tool'
|
||||
}
|
||||
|
||||
// The default encode mode for a type: the label_default_encodes_<type> site
|
||||
// setting when valid, else the hardcoded fallback.
|
||||
export async function resolveDefaultEncodes(assettype) {
|
||||
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
|
||||
if (hasLocationType(assettype)) valid.push('location')
|
||||
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
|
||||
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
|
||||
}
|
||||
|
||||
// 'location' degrades to the asset page when the asset has no location code.
|
||||
export function effectiveEncodes(encodes, asset) {
|
||||
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
|
||||
return encodes
|
||||
}
|
||||
|
||||
function tokensFor(asset) {
|
||||
return {
|
||||
assetid: asset.assetid || '',
|
||||
assetnumber: asset.assetnumber || '',
|
||||
serialnumber: asset.serialnumber || '',
|
||||
name: asset.name || '',
|
||||
pluginid: asset.pluginid || '',
|
||||
locationcode: asset.locationcode || '',
|
||||
locationname: asset.locationname || '',
|
||||
}
|
||||
}
|
||||
|
||||
// The string a label's code encodes for one asset. Empty when the chosen field
|
||||
// has no value (e.g. serial number on an asset with none).
|
||||
export async function resolveCodeText(assettype, asset, encodes) {
|
||||
const config = TYPE_CONFIG[assettype]
|
||||
if (!config || !asset) return ''
|
||||
const detailId = asset.pluginid
|
||||
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'assetnumber': return asset.assetnumber || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'custom': {
|
||||
const template = (await getSetting(config.targetKey, '')).trim()
|
||||
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
|
||||
}
|
||||
case 'assetpage':
|
||||
default: return assetPageUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Human caption printed under the code.
|
||||
export function captionFor(asset, encodes) {
|
||||
if (!asset) return ''
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'assetnumber':
|
||||
default: return asset.assetnumber || ''
|
||||
}
|
||||
}
|
||||
133
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
133
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="item">
|
||||
<div class="hero-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
||||
:alt="item.itemname" class="hero-image" />
|
||||
<div class="hero-content">
|
||||
<h2 class="hero-title">{{ item.itemname }}</h2>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }} on hand
|
||||
</span>
|
||||
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Details</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Item code</span>
|
||||
<span class="info-value">{{ item.itemcode }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Bin location</span>
|
||||
<span class="info-value">{{ item.binlocation || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Quantity on hand</span>
|
||||
<span class="info-value">{{ item.quantityonhand }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Low-stock threshold</span>
|
||||
<span class="info-value">{{ item.lowstockthreshold }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="item.printnotes">
|
||||
<span class="info-label">Print notes</span>
|
||||
<span class="info-value">{{ item.printnotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Who</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="transaction in item.recenttransactions"
|
||||
:key="transaction.transactionid">
|
||||
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
||||
<td>{{ transaction.transactiontype }}</td>
|
||||
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
||||
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
||||
</td>
|
||||
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
||||
<td>{{ transaction.reason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!item.recenttransactions?.length">
|
||||
<td colspan="5" class="empty-state">No transactions yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-footer">
|
||||
Created {{ formatDate(item.createddate) }} -
|
||||
Modified {{ formatDate(item.modifieddate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">Item not found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-actions { margin-top: 0.75rem; }
|
||||
.qty-out { color: var(--danger); }
|
||||
.qty-in { color: var(--success); }
|
||||
</style>
|
||||
170
frontend/src/views/printedparts/PrintedItemForm.vue
Normal file
170
frontend/src/views/printedparts/PrintedItemForm.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="save">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.itemname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bin location</label>
|
||||
<input v-model="form.binlocation" type="text" class="form-control"
|
||||
placeholder="e.g., Bin A3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input v-model="form.itemdescription" type="text" class="form-control"
|
||||
maxlength="500" placeholder="Brief description shown on the storefront" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Low-stock threshold</label>
|
||||
<input v-model.number="form.lowstockthreshold" type="number" min="0"
|
||||
class="form-control" />
|
||||
</div>
|
||||
<div v-if="isEdit" class="form-group">
|
||||
<label>Item code</label>
|
||||
<input :value="itemcode" type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Print notes</label>
|
||||
<textarea v-model="form.printnotes" class="form-control" rows="3"
|
||||
placeholder="Material, print time, slicer file path"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="form-group">
|
||||
<label>Photo</label>
|
||||
<div class="image-row">
|
||||
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
|
||||
<input type="file" accept="image/*" @change="onImagePicked" />
|
||||
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
|
||||
@click="removeImage">Remove photo</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const cancelTarget = computed(() =>
|
||||
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
|
||||
|
||||
const form = ref({
|
||||
itemname: '',
|
||||
itemdescription: '',
|
||||
lowstockthreshold: null,
|
||||
binlocation: '',
|
||||
printnotes: ''
|
||||
})
|
||||
const itemcode = ref('')
|
||||
const imageurl = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
const item = response.data.data
|
||||
for (const key of Object.keys(form.value)) {
|
||||
form.value[key] = item[key]
|
||||
}
|
||||
itemcode.value = item.itemcode
|
||||
imageurl.value = item.imageurl
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load the item'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = { ...form.value }
|
||||
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
|
||||
delete payload.lowstockthreshold
|
||||
}
|
||||
let printeditemid
|
||||
if (isEdit.value) {
|
||||
await printedpartsApi.update(route.params.id, payload)
|
||||
printeditemid = route.params.id
|
||||
} else {
|
||||
const response = await printedpartsApi.create(payload)
|
||||
printeditemid = response.data.data.printeditemid
|
||||
}
|
||||
router.push(`/printedparts/${printeditemid}`)
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onImagePicked(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const response = await printedpartsApi.uploadImage(route.params.id, file)
|
||||
imageurl.value = response.data.data.imageurl
|
||||
} catch (uploadError) {
|
||||
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage() {
|
||||
try {
|
||||
await printedpartsApi.deleteImage(route.params.id)
|
||||
imageurl.value = null
|
||||
} catch (deleteError) {
|
||||
error.value = 'Could not remove the image'
|
||||
console.error(deleteError)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.image-preview {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.form-hint { color: var(--text-light); }
|
||||
</style>
|
||||
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search code, name, description, bin..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Quantity</th>
|
||||
<th>Bin</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="clickable-row"
|
||||
@click="$router.push(`/printedparts/${item.printeditemid}`)"
|
||||
>
|
||||
<td class="thumb-cell">
|
||||
<img
|
||||
v-if="item.imageurl"
|
||||
:src="withBase(item.imageurl)"
|
||||
:alt="item.itemname"
|
||||
class="item-thumb"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.itemcode || '-' }}</td>
|
||||
<td>{{ item.itemname }}</td>
|
||||
<td>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.binlocation || '-' }}</td>
|
||||
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" class="empty-state">No printed parts found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@change="setPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const lowstockOnly = ref(false)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (lowstockOnly.value) params.lowstock = 'true'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printed parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-cell { width: 3rem; }
|
||||
.truncate-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@
|
||||
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print QR
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
@@ -16,6 +19,9 @@
|
||||
<template v-else-if="printer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="printer.printer?.imageurl">
|
||||
<img :src="printer.printer.imageurl" :alt="printer.printer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ displayTitle }}</h1>
|
||||
@@ -51,6 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -120,28 +127,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="printer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
||||
<AssetRelationships v-if="printer.assetid" :assetId="printer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location -->
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Map Location</span>
|
||||
@@ -164,20 +172,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="printer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
||||
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,6 +262,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h2>Printers</h2>
|
||||
<div class="header-actions">
|
||||
<router-link to="/print/printer-qr" class="btn btn-secondary" target="_blank">Batch Print QR</router-link>
|
||||
<router-link to="/print/asset-label-batch/printer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +44,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
|
||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid" class="clickable-row" @click="$router.push(`/printers/${printer.printer?.printerid || printer.assetid}`)">
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
|
||||
<td>{{ printer.businessunitname || '-' }}</td>
|
||||
@@ -54,7 +55,7 @@
|
||||
{{ printer.statusname || 'Active' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/printers/${printer.printer?.printerid || printer.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -89,13 +90,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const printers = ref([])
|
||||
const printerTypes = ref([])
|
||||
const typeFilter = ref('')
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -134,24 +135,24 @@ async function loadPrinters() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadPrinters()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -418,7 +419,7 @@ async function runReport(report) {
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
||||
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
|
||||
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,6 +101,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'assetnumber', label: 'Asset #' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'ipaddress', label: 'IP Address' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
@@ -119,6 +132,25 @@ const filteredPrinters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// One row per supply, honoring the active filter, for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
location: printer.location || '',
|
||||
ipaddress: printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
level: supply.level + '%',
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<h1>Warranty Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading" subject="Warranty Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,13 +55,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { warrantyApi } from '../../api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const counts = ref({})
|
||||
const buckets = ref({})
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'bucket', label: 'Status' },
|
||||
{ key: 'vendor', label: 'Vendor' },
|
||||
{ key: 'servicelevel', label: 'Service Level' },
|
||||
{ key: 'enddate', label: 'Ends' },
|
||||
{ key: 'assets', label: 'Covers' },
|
||||
]
|
||||
|
||||
// Flatten the buckets into one row per warranty for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push({
|
||||
bucket: b.label,
|
||||
vendor: w.vendor || '',
|
||||
servicelevel: w.servicelevel || '',
|
||||
enddate: w.enddate || '',
|
||||
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const bucketOrder = [
|
||||
{ key: 'expired', label: 'Expired', color: '#F44336' },
|
||||
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
|
||||
@@ -70,7 +98,7 @@ const bucketOrder = [
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
|
||||
function assetLink(a) {
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
|
||||
return (map[a.assettypename] || '/assets/') + a.assetid
|
||||
}
|
||||
|
||||
|
||||
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>API Tokens</h2>
|
||||
<button class="btn btn-primary" @click="openCreate()">+ New Token</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="tokens-intro">
|
||||
Personal access tokens let scripts and integrations authenticate as you
|
||||
without an hourly-expiring login session. Send the token as
|
||||
<code>Authorization: Bearer shopdb_pat_...</code>. Ideal for long-running
|
||||
imports that would otherwise die when the login JWT expires. A token may
|
||||
be restricted to a subset of your permissions; a restricted token cannot
|
||||
reach admin-only (role-gated) endpoints or import mode.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in myTokens" :key="token.tokenid">
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
|
||||
<td>{{ formatDate(token.createddate) }}</td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-secondary btn-sm"
|
||||
@click="openEdit(token)">Edit</button>
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="myTokens.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Admin: all tokens across every user -->
|
||||
<div v-if="isAdmin" class="card admin-tokens">
|
||||
<h3 class="section-subtitle">All Tokens (admin)</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in allTokens" :key="token.tokenid">
|
||||
<td>{{ token.username || '-' }}</td>
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="allTokens.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>New API Token</h3></div>
|
||||
<form @submit.prevent="createToken">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tokenname">Name *</label>
|
||||
<input id="tokenname" v-model="form.name" type="text" class="form-control"
|
||||
placeholder="e.g. legacy import runner" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tokenexpiry">Expiry (optional)</label>
|
||||
<input id="tokenexpiry" v-model="form.expiresat" type="date" class="form-control" />
|
||||
<small class="form-hint">Leave blank for a token that never expires.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Default is full access (the token acts as you). Restrict to grant
|
||||
only the checked permissions. A restricted token cannot use
|
||||
admin-only endpoints or import mode.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="form.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isCategoryPartiallySelected(category)"
|
||||
@change="toggleCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="form.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeCreate">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (scopes) -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="closeEdit">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>Edit token access</h3></div>
|
||||
<form @submit.prevent="saveEdit">
|
||||
<div class="modal-body">
|
||||
<p class="tokens-intro">Editing <strong>{{ editing.name }}</strong>.</p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="editForm.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Uncheck for full access (acts as the owner).
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="editForm.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isEditCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isEditCategoryPartiallySelected(category)"
|
||||
@change="toggleEditCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="editForm.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editError" class="error-message">{{ editError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEdit">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret reveal modal (shown once) -->
|
||||
<div v-if="newSecret" class="modal-overlay" @click.self="dismissSecret">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Copy your new token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p class="secret-warning">
|
||||
This is the only time the token is shown. Copy it now and store it
|
||||
somewhere safe. You will not be able to see it again.
|
||||
</p>
|
||||
<div class="secret-box">
|
||||
<code class="secret-value">{{ newSecret }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @click="dismissSecret">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revoke confirm -->
|
||||
<div v-if="toRevoke" class="modal-overlay" @click.self="toRevoke = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Revoke Token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Revoke <strong>{{ toRevoke.name }}</strong>? Any script using it will
|
||||
immediately lose access.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="toRevoke = null">Cancel</button>
|
||||
<button class="btn btn-danger" @click="revokeToken">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { apitokensApi, usersApi, authApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
|
||||
const myTokens = ref([])
|
||||
const allTokens = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Permission catalog grouped by category, plus the permissions the current
|
||||
// user actually holds (the create/edit grid never offers more than these).
|
||||
const permissionsGrouped = ref({})
|
||||
const myPermissions = ref([])
|
||||
|
||||
const showCreate = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', expiresat: '', restrict: false, scopes: [] })
|
||||
|
||||
const editing = ref(null)
|
||||
const editError = ref('')
|
||||
const editForm = ref({ restrict: false, scopes: [] })
|
||||
|
||||
const newSecret = ref('')
|
||||
const copied = ref(false)
|
||||
const toRevoke = ref(null)
|
||||
|
||||
// Grid limited to permissions the current user holds. Admins see everything.
|
||||
const availableGrouped = computed(() => {
|
||||
const held = new Set(myPermissions.value)
|
||||
const result = {}
|
||||
for (const [category, perms] of Object.entries(permissionsGrouped.value)) {
|
||||
const usable = isAdmin.value ? perms : perms.filter(p => held.has(p.name))
|
||||
if (usable.length) result[category] = usable
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tokensRes, permsRes, meRes] = await Promise.all([
|
||||
apitokensApi.list(),
|
||||
usersApi.permissions.list(),
|
||||
authApi.me()
|
||||
])
|
||||
myTokens.value = tokensRes.data.data || []
|
||||
permissionsGrouped.value = permsRes.data.data.grouped || {}
|
||||
myPermissions.value = meRes.data.data.permissions || []
|
||||
if (isAdmin.value) {
|
||||
const all = await apitokensApi.list({ all: true })
|
||||
allTokens.value = all.data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading API tokens:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||
}
|
||||
|
||||
function scopeSummary(token) {
|
||||
if (!token.scopes) return 'Full access'
|
||||
const count = token.scopes.length
|
||||
return count === 1 ? '1 permission' : `${count} permissions`
|
||||
}
|
||||
|
||||
function scopeBadgeClass(token) {
|
||||
return token.scopes ? 'badge-warning' : 'badge-success'
|
||||
}
|
||||
|
||||
// --- Create grid helpers ---
|
||||
function isCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => form.value.scopes.includes(p.name))
|
||||
}
|
||||
function isCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => form.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!form.value.scopes.includes(name)) form.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
form.value.scopes = form.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit grid helpers ---
|
||||
function isEditCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => editForm.value.scopes.includes(p.name))
|
||||
}
|
||||
function isEditCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => editForm.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleEditCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!editForm.value.scopes.includes(name)) editForm.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
editForm.value.scopes = editForm.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
|
||||
error.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
function closeCreate() { showCreate.value = false }
|
||||
|
||||
async function createToken() {
|
||||
error.value = ''
|
||||
if (form.value.restrict && form.value.scopes.length === 0) {
|
||||
error.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.value.name }
|
||||
if (form.value.expiresat) payload.expiresat = form.value.expiresat
|
||||
if (form.value.restrict) payload.scopes = form.value.scopes
|
||||
const response = await apitokensApi.create(payload)
|
||||
showCreate.value = false
|
||||
newSecret.value = response.data.data.secret
|
||||
copied.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to create token')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(token) {
|
||||
editing.value = token
|
||||
editError.value = ''
|
||||
editForm.value = {
|
||||
restrict: !!token.scopes,
|
||||
scopes: token.scopes ? [...token.scopes] : []
|
||||
}
|
||||
}
|
||||
|
||||
function closeEdit() { editing.value = null }
|
||||
|
||||
async function saveEdit() {
|
||||
editError.value = ''
|
||||
if (editForm.value.restrict && editForm.value.scopes.length === 0) {
|
||||
editError.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { scopes: editForm.value.restrict ? editForm.value.scopes : null }
|
||||
await apitokensApi.update(editing.value.tokenid, payload)
|
||||
editing.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
editError.value = apiError(err, 'Failed to update token')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newSecret.value)
|
||||
copied.value = true
|
||||
} catch {
|
||||
toast.error('Copy failed. Select the token and copy manually.')
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSecret() { newSecret.value = ''; copied.value = false }
|
||||
|
||||
function confirmRevoke(token) { toRevoke.value = token }
|
||||
|
||||
async function revokeToken() {
|
||||
try {
|
||||
await apitokensApi.remove(toRevoke.value.tokenid)
|
||||
toRevoke.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to revoke token')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tokens-intro {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tokens-intro code {
|
||||
background: var(--bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.admin-tokens {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-hint {
|
||||
display: block;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.secret-warning {
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.secret-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.secret-value {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.permission-category {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.category-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.category-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.perm-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user