Compare commits
100 Commits
v0.7.0
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4bfcd41c | ||
|
|
26b6b6b32f | ||
|
|
eab225e1e6 | ||
|
|
a8a6baf979 | ||
|
|
427eb0de8c | ||
|
|
df918ed38f | ||
|
|
fc0d48a6a7 | ||
|
|
b68e927ef6 | ||
|
|
6439d1ccd9 | ||
|
|
6ed3da1b64 | ||
|
|
d6a78a72ff | ||
|
|
6dfc8906c4 | ||
|
|
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 |
@@ -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
|
||||
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -10,6 +10,63 @@ ADR-007 and ADR-002.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Asset detail pages (machines, PCs, printers, network devices, measuring
|
||||
tools) now share one canonical card skeleton: Identity -> type-specific ->
|
||||
status -> Location & Organization -> domain -> Custom Fields -> Warranty ->
|
||||
Relationships -> Notes -> audit footer. The location card reads "Location &
|
||||
Organization" on every page. The network device page was rebuilt into the
|
||||
family (its "Asset Information" folded into Identity, "Record Info" converted
|
||||
to the standard audit footer). Printer Notes moved out of mid-page to
|
||||
second-to-last and the printer gained an audit footer. Template reordering
|
||||
only; no data or API changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- List pages keep the current page (and search term) in the URL query, so
|
||||
paging to page 9, opening an item, and hitting browser Back returns to page 9
|
||||
instead of resetting to page 1. Applies to all 18 list views via a shared
|
||||
useListQuery composable; page 1 with no search stays a bare path.
|
||||
- PC detail Installed Applications no longer 500s and silently disappears on
|
||||
real PCs (ComputerInstalledApp had no to_dict); the section renders app
|
||||
name, version, and description again.
|
||||
- Employee detail skips its USB panels when the usb plugin is disabled (no
|
||||
more 404 console noise).
|
||||
- Shopfloor kiosk header text is readable (light on the dark navy header).
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Collector-driven PC -> printer relationships. The computers collector schema
|
||||
gained optional `defaultprinter` (string) and `printers` (array of strings)
|
||||
fields carrying Win32_Printer identifiers. On ingest each identifier resolves
|
||||
to a printer asset (by windows name / share / hostname / asset number-name or
|
||||
a communications IP) and the PC is linked to it: the default via a
|
||||
`defaultprinter` relationship, the rest via `connectedto`. The links render in
|
||||
the shared Relationships card on both the PC and printer detail pages. The
|
||||
sync is idempotent and archives collector-created links to printers no longer
|
||||
reported (tagged `assetrelationships.label = 'collector:printers'`, so
|
||||
manually-created links are never touched); unresolved identifiers become
|
||||
response warnings, never failures. The collector response carries
|
||||
`printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md.
|
||||
- Searchable custom fields. Each custom-field definition gained a `searchable`
|
||||
flag (Settings > Custom Fields). When on, that field's stored values are
|
||||
matched by global search and a hit routes to the owning asset's detail page.
|
||||
The asset's `search_<type>_enabled` domain toggle still applies, and matches
|
||||
dedupe against built-in-field asset hits so an asset appears once. Inactive or
|
||||
non-searchable fields are never matched.
|
||||
- Single-label sheet-position printing. The single asset-label page
|
||||
(`/print/asset-label/:assettype/:id`) gained an Output control that toggles
|
||||
between the standalone label (unchanged default) and placing that one label at
|
||||
a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the
|
||||
other five cells blank. This prints a single label onto the correct physical
|
||||
spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the
|
||||
legacy shopdb behavior and complementing the batch page's start-cell offset.
|
||||
The ULINE 6-up cell layout and dimensions are replicated from
|
||||
`AssetLabelBatch.vue` (left untouched); encode resolution stays shared via
|
||||
`assetLabel.js`.
|
||||
|
||||
## [0.7.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
@@ -204,7 +261,7 @@ ADR-007 and ADR-002.
|
||||
card on the application detail page and a new `settings/supportteams`
|
||||
management page render them.
|
||||
|
||||
- Import mode: a complete, idempotent HTTP migration surface so a script or LLM
|
||||
- Import mode: a complete, idempotent HTTP migration surface so a migration script
|
||||
can import the classic ASP shopdb through the API alone (no direct DB writes).
|
||||
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
|
||||
`shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`,
|
||||
@@ -248,7 +305,7 @@ ADR-007 and ADR-002.
|
||||
|
||||
- Audit log: hovering a user's SSO now shows their full name (best-effort,
|
||||
resolved from the employee directory in either mode).
|
||||
- Refreshed the internal status docs to match the code (CLAUDE.md active
|
||||
- Refreshed the internal status docs to match the code (project active
|
||||
state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
|
||||
corrected the get_asset_panels endpoint path in the hook docstring, and
|
||||
removed leftover debug console.log lines.
|
||||
@@ -384,7 +441,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
|
||||
- Multi-stage Docker build that compiles the Vue frontend and ships
|
||||
`frontend/dist`, which Flask serves.
|
||||
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
|
||||
reconciled README, DEPLOY, CLAUDE, and ROADMAP.
|
||||
reconciled README, DEPLOY, status docs, and ROADMAP.
|
||||
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort
|
||||
Gitea Actions CI (backend tests, naming/style gate, frontend build).
|
||||
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -21,12 +21,13 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
|
||||
- ADR-009: Frontend plugin route gating - ACCEPTED
|
||||
- ADR-010: Frontend plugin hook contract - ACCEPTED
|
||||
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
|
||||
- ADR-012: GE-Enforce manifest ownership in shopdb - ACCEPTED
|
||||
|
||||
## Coding convention
|
||||
|
||||
`CONTRIBUTING.md` defines naming rules (DB tables, columns, Python, JS, Vue, API). Pre-commit hook at `scripts/check-naming-and-style.sh` enforces them. Read `CONTRIBUTING.md` before naming any new identifier.
|
||||
|
||||
## Current state (as of 2026-07-12)
|
||||
## Current state (as of 2026-07-13)
|
||||
|
||||
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely complete; the last big milestone is the legacy-data import + a production pilot.
|
||||
|
||||
@@ -41,11 +42,12 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
|
||||
|
||||
### Active state
|
||||
|
||||
- 808 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
|
||||
- `__contract_version__` at 0.10.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d23_user_mustchangepassword` (30 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty.
|
||||
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script/LLM import the whole legacy DB (X-Import-Mode preserves timestamps).
|
||||
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||
- `__contract_version__` at 0.13.0 (0.12.0 added the mailer, 0.13.0 the User model, to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).
|
||||
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script import the whole legacy DB (X-Import-Mode preserves timestamps).
|
||||
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
|
||||
|
||||
### Deferred
|
||||
@@ -133,4 +135,4 @@ Each plugin must have:
|
||||
- `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix
|
||||
- `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods
|
||||
- `migrations/rename_underscore_columns.sql` - one-time rename of snake_case columns to lowercase concatenated (per CONTRIBUTING.md)
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d16_directoryemployees`). Run `flask db upgrade` to apply.
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes`). Run `flask db upgrade` to apply.
|
||||
|
||||
@@ -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
|
||||
|
||||
57
README.md
57
README.md
@@ -7,14 +7,20 @@ A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Pyt
|
||||
ShopDB tracks and manages:
|
||||
- **Machines** - CNC equipment, CMMs, inspection systems, etc.
|
||||
- **PCs** - Shopfloor computers, engineering workstations
|
||||
- **Printers** - Network printers with Zabbix integration
|
||||
- **Applications** - Software deployed across the shop floor
|
||||
- **Printers** - Network printers with Zabbix supply integration
|
||||
- **Network devices** - Switches, routers, and the subnet browser
|
||||
- **Measuring tools** - Gage-lab instruments with calibration tracking
|
||||
- **Applications** - Software deployed across the shop floor, with per-PC install tracking
|
||||
- **Employees** - Directory, recognition and training notifications
|
||||
- **Warranties** - Coverage records with Dell warranty lookups
|
||||
- **USB devices** - CMMC check-in/out tracking
|
||||
- **Knowledge Base** - Documentation and troubleshooting guides
|
||||
- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.x with Flask
|
||||
- Python 3.12 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
- JWT authentication
|
||||
@@ -59,13 +65,13 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
### Database
|
||||
|
||||
- **Table names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machines`, `pctypes`, `machinetypes`, `businessunits`
|
||||
- Examples: `assets`, `computers`, `printers`, `businessunits`
|
||||
- **Column names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
|
||||
- Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate`
|
||||
- **Foreign keys:** Referenced table name + `id`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid`
|
||||
- **Boolean columns:** Prefixed with `is` or `has`
|
||||
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `islicenced`
|
||||
- Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced`
|
||||
|
||||
### Code
|
||||
|
||||
@@ -77,9 +83,9 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
### API
|
||||
|
||||
- **Endpoints:** Lowercase, plural nouns
|
||||
- Examples: `/api/machines`, `/api/pctypes`, `/api/locations`
|
||||
- Examples: `/api/machines`, `/api/computers`, `/api/locations`
|
||||
- **Query parameters:** Lowercase, single word
|
||||
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
|
||||
- Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer`
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
@@ -92,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.12
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
|
||||
@@ -102,7 +108,7 @@ SQLite). Do not run dev or production against SQLite.
|
||||
|
||||
### Distribution
|
||||
|
||||
The application is distributed internally through the GE Aerospace Gitea. Clone
|
||||
The application is distributed through the internal GE Aerospace git server. Clone
|
||||
it from there; there is no public package or image registry.
|
||||
|
||||
### Fast path (Docker)
|
||||
@@ -158,12 +164,10 @@ npm run build # production build into frontend/dist (served by Flask)
|
||||
Complete first-run setup at `/setup`, or run `flask seed admin` for a headless
|
||||
admin account.
|
||||
|
||||
To import data from the legacy ShopDB MySQL database (one-time, see
|
||||
`migrations/DATA_MIGRATION_GUIDE.md`):
|
||||
|
||||
```bash
|
||||
python scripts/import_from_mysql.py
|
||||
```
|
||||
To import a site's legacy data, use the HTTP import surface: an admin API
|
||||
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration
|
||||
through documented endpoints (`X-Import-Mode` preserves original timestamps).
|
||||
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
|
||||
|
||||
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
|
||||
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
|
||||
@@ -192,31 +196,36 @@ The REST API follows standard conventions:
|
||||
| PUT | `/api/machines/:id` | Update machine |
|
||||
| DELETE | `/api/machines/:id` | Soft delete machine |
|
||||
|
||||
Each asset plugin exposes the same CRUD pattern on its own prefix
|
||||
(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`),
|
||||
and cross-cutting asset endpoints live under `/api/assets`.
|
||||
|
||||
Query parameters for list endpoints:
|
||||
- `page` - Page number (default: 1)
|
||||
- `per_page` - Items per page (default: 25)
|
||||
- `perpage` - Items per page
|
||||
- `sort` - Sort field
|
||||
- `order` - Sort direction (asc/desc)
|
||||
- `dir` - Sort direction (asc/desc)
|
||||
- `search` - Search term
|
||||
- `type` - Filter by asset type (computer, printer, machine, network_device)
|
||||
- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool)
|
||||
|
||||
## Plugin System
|
||||
|
||||
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
|
||||
|
||||
The image bundles eleven plugins; only the ones a site installs are loaded:
|
||||
The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
|
||||
- **machines** - CNC, CMM, and other shop-floor machines
|
||||
- **measuringtools** - Gage-lab instruments with calibration tracking
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices
|
||||
- **network** - Network devices and subnets
|
||||
- **notifications** - Shopfloor notifications and recognition feed
|
||||
- **printers** - Extended printer management with Zabbix integration
|
||||
- **slides** - TV/kiosk slideshows
|
||||
- **usb** - CMMC USB check-in/out tracking
|
||||
- **warranty** - Dell warranty lookups
|
||||
- **warranty** - Warranty records with Dell lookups
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -209,10 +209,38 @@ a column.
|
||||
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
|
||||
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
|
||||
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
|
||||
| `defaultprinter` | string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a `defaultprinter` relationship. Unresolved -> warning. |
|
||||
| `printers` | array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a `connectedto` relationship (the default is skipped here since it already links as `defaultprinter`). Unresolved entries -> warning. |
|
||||
|
||||
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
|
||||
If you change the payload, change it there and re-check this table.
|
||||
|
||||
### PC -> printer relationship sync
|
||||
|
||||
When a payload carries `defaultprinter` and/or `printers`, the collector syncs
|
||||
`AssetRelationship` rows so a PC page shows its printers and a printer page shows
|
||||
the PCs that use it (both render in the shared Relationships card).
|
||||
|
||||
- Resolution: each identifier is matched, first hit wins, against the printer's
|
||||
`windowsname`, `hostname`, `sharename`, its asset number/name, then any active
|
||||
printer communications IP. Case-insensitive except the IP (exact). An
|
||||
identifier that resolves to nothing adds a warning and is skipped; it never
|
||||
fails the whole push.
|
||||
- Link types: the default printer links with `defaultprinter` (directional, PC
|
||||
is the source); every other reported printer links with `connectedto`
|
||||
(symmetric). A printer that is both default and in `printers` links only as
|
||||
the default.
|
||||
- Idempotent: re-reporting the same set creates no duplicate rows (an existing
|
||||
matching row is reactivated if it was archived, otherwise left as is).
|
||||
- Stale-link archive: on every push, collector-created links to printers no
|
||||
longer reported are set inactive. Collector-created rows are tagged in
|
||||
`assetrelationships.label = 'collector:printers'`; only tagged rows are ever
|
||||
archived, so links you create by hand in the UI are never touched. A payload
|
||||
that omits BOTH printer keys leaves all existing printer links untouched
|
||||
(report an empty `printers: []` to clear the auto links instead).
|
||||
- Response: the collector response carries `printerlinkcount` and a
|
||||
`printerlinks` list of `{assetid, relationshiptype}` for the links kept.
|
||||
|
||||
### pc-type mapping (configurable per site)
|
||||
|
||||
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
|
||||
@@ -487,8 +515,32 @@ function Send-ShopdbCollectorReport {
|
||||
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
|
||||
}
|
||||
|
||||
# --- Installed printers (Win32_Printer). The Default flag marks the one
|
||||
# default printer. We report each printer's port name (an IP or a queue
|
||||
# host for network printers) and fall back to the share/printer name, which
|
||||
# the collector resolves flexibly against printer windowsname/hostname/IP. ---
|
||||
$defaultPrinter = ''
|
||||
$printerIds = @()
|
||||
try {
|
||||
$printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop
|
||||
foreach ($p in $printers) {
|
||||
if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote)
|
||||
# Prefer the port name (IP or queue host); fall back to ShareName,
|
||||
# then the printer Name.
|
||||
$identity = $p.PortName
|
||||
if (-not $identity) { $identity = $p.ShareName }
|
||||
if (-not $identity) { $identity = $p.Name }
|
||||
if (-not $identity) { continue }
|
||||
$printerIds += $identity
|
||||
if ($p.Default) { $defaultPrinter = $identity }
|
||||
}
|
||||
$printerIds = @($printerIds | Select-Object -Unique)
|
||||
} catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" }
|
||||
|
||||
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
|
||||
$payload = @{ hostname = $hostname }
|
||||
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
|
||||
if ($printerIds.Count) { $payload['printers'] = $printerIds }
|
||||
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
|
||||
if ($pcType) { $payload['pctype'] = $pcType }
|
||||
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
|
||||
|
||||
@@ -319,6 +319,15 @@ One boolean key per search domain, keyed `search_<type>_enabled` (default
|
||||
`true`). Toggles whether a domain appears in global search results. The set is
|
||||
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
|
||||
|
||||
## Custom fields
|
||||
|
||||
Site-defined extra attributes per asset type (Settings > Custom Fields, table
|
||||
`customfields`). Each field has a `searchable` flag (default off). When on, the
|
||||
field's stored values are matched by global search and a hit routes to the
|
||||
owning asset's detail page. The asset's `search_<type>_enabled` domain toggle
|
||||
still applies, so a custom-field hit on a computer only shows when the computer
|
||||
search domain is enabled. Inactive or non-searchable fields are never matched.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
@@ -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.
|
||||
@@ -6,7 +6,7 @@ API. No direct writes to the `shopdb_flask` database are needed or wanted: every
|
||||
row is created through a documented endpoint so authorization, validation,
|
||||
auditing, and plugin hooks all run exactly as they do for a human operator.
|
||||
|
||||
An LLM or a plain Python script can run the whole migration from this document.
|
||||
A plain Python script can run the whole migration from this document.
|
||||
|
||||
Contents:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -532,9 +532,8 @@ form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdm
|
||||
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
|
||||
reorganize the file; just add the block, mirroring `machinesApi`.
|
||||
|
||||
**Views mirror the master templates.** The frontend has master templates the
|
||||
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`
|
||||
for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
||||
**Views mirror the master templates.** The frontend has master templates
|
||||
(`PrintersList.vue` for lists, `PrinterDetail.vue` for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
||||
|
||||
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
|
||||
and a calibration-status filter; the status badge uses `utils/colorStyle` with
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.10.0'
|
||||
__contract_version__ = '0.13.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -469,12 +469,21 @@ What `shopdb.api` exposes:
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`,
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Authorization: `require_permission`, `require_role`
|
||||
- Authorization: `require_permission`, `require_role`,
|
||||
`service_token_authorized`
|
||||
(`service_token_authorized(scope)` returns True when the request carries a
|
||||
managed service token scoped for `scope` whose owner holds that permission -
|
||||
for unattended plugin endpoints like the GE-Enforce fetch API)
|
||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||
`dualpath_single_machine_enabled`
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
||||
alert recipients' emails from selected user ids or role membership
|
||||
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
||||
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
||||
email is unconfigured; send_alert targets the site's alert_recipients
|
||||
|
||||
```python
|
||||
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
|
||||
|
||||
392
docs/PLUGIN-LAB-PRINTEDPARTS.md
Normal file
392
docs/PLUGIN-LAB-PRINTEDPARTS.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Plugin lab: build the printedparts plugin
|
||||
|
||||
A hand-held, build-along tutorial: construct the 3D-printed-parts storefront +
|
||||
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by
|
||||
stage, seeing each piece work before moving on. Written for someone building
|
||||
their first plugin. The finished implementation lives on the
|
||||
`feat/printedparts-plugin` branch with one commit per stage, tagged
|
||||
`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03
|
||||
lab-stage-04` shows exactly what a stage changes.
|
||||
|
||||
Know before you start
|
||||
- You are building a BUNDLED plugin inside this repo. Plugin frontend files
|
||||
live in core (`frontend/src/...`), and three core files get small edits:
|
||||
`frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and
|
||||
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for
|
||||
all bundled plugins - external-plugin UI packaging does not exist yet.
|
||||
- Three deliberate divergences from the scaffold, each a teaching point:
|
||||
(1) NO AssetType - these are quantity consumables, not ADR-001 assets
|
||||
(stage 1); (2) the migration is a REAL baseline that creates tables, not a
|
||||
stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's
|
||||
first UNauthenticated write - read the decision record in the proposal
|
||||
before stage 7.
|
||||
- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
|
||||
DB names lowercase concatenated (`quantityonhand`); run
|
||||
`bash scripts/check-naming-and-style.sh` + the tests at every stage; one
|
||||
git commit per stage.
|
||||
|
||||
Prerequisites: working dev environment (README quick start), skim
|
||||
`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this
|
||||
lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules.
|
||||
|
||||
---
|
||||
|
||||
## Stage 0 - orientation (no code)
|
||||
|
||||
Read the proposal. Tour the two reference plugins you will imitate:
|
||||
`plugins/usb/` (checkout ledger + badge contract) and
|
||||
`plugins/measuringtools/` (post-cutover migration baseline, hooks).
|
||||
See it work: run the app, log in.
|
||||
|
||||
## Stage 1 - scaffold, minus the AssetType
|
||||
|
||||
```
|
||||
flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout"
|
||||
```
|
||||
|
||||
Walk the generated tree. Then diverge:
|
||||
1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its
|
||||
`on_install` call - a printed part is a kind-with-a-count, not an asset.
|
||||
Replace it with settings seeding (see the tagged commit): three Setting
|
||||
rows, category `printedparts` - `printedparts_code_prefix` (3DP),
|
||||
`printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny).
|
||||
2. `manifest.json`: `"dependencies": ["employees"]` (badge names),
|
||||
`"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`,
|
||||
`"display_name": "3D Printed Parts"`.
|
||||
|
||||
See it work: `flask plugin list` shows printedparts [Available].
|
||||
Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec`
|
||||
|
||||
## Stage 2 - models + real migration baseline + tables live
|
||||
|
||||
1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem`
|
||||
(itemcode unique+indexed, itemname, itemdescription, imageurl,
|
||||
quantityonhand, lowstockthreshold, binlocation, printnotes) and
|
||||
`PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype
|
||||
take/restock/adjust, SIGNED quantitychange, employeesso, employeename,
|
||||
reason, transactiondate) - both on `BaseModel`. The ledger is the source
|
||||
of truth; quantityonhand is a cache moved in the same commit.
|
||||
2. Update `models/__init__.py` exports and `plugin.py` `get_models`.
|
||||
3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`):
|
||||
`'printedparts': ('printeditems', 'printeditemtransactions'),`
|
||||
4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from
|
||||
measuringtools (change PLUGIN_NAME), then hand-write
|
||||
`versions/0001_printedparts_baseline.py` with explicit `op.create_table`
|
||||
for both tables + the three transaction indexes.
|
||||
5. The scaffold's `api/routes.py` still imports the model you deleted - make
|
||||
the blueprint import cleanly (a placeholder route is fine for now).
|
||||
|
||||
See it work:
|
||||
```
|
||||
flask plugin install printedparts && flask plugin enable printedparts
|
||||
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
|
||||
mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
|
||||
flask plugin upgrade-all -- printedparts: ok (idempotent)
|
||||
```
|
||||
|
||||
Common errors (both hit for real while building this):
|
||||
- An empty `Migration error:` on install. Root cause: anything that makes
|
||||
`plugins.printedparts.models` fail to import - the alembic env imports the
|
||||
models package, which pulls in plugin.py and routes.py. Here it was the
|
||||
scaffold routes importing the deleted model; the ImportError gets caught
|
||||
and retried down a subprocess path with no stderr. Fix the import, not the
|
||||
migration.
|
||||
- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add
|
||||
`EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` -
|
||||
the guard makes every new plugin declare its expected head on purpose.
|
||||
|
||||
Commit + tag `lab-stage-02`.
|
||||
|
||||
## Stage 3 - read API + list page (the first visible win)
|
||||
|
||||
1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via
|
||||
`get_pagination_params`/`paginate_query`, search across
|
||||
code/name/description/bin, `?lowstock=true` filter) and
|
||||
`GET /items/<id>` returning the item + its 25 most recent transactions.
|
||||
2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box',
|
||||
'route': '/printedparts', 'position': 46}`.
|
||||
3. Frontend: paste the `printedpartsApi` client into
|
||||
`frontend/src/api/index.js` (list/get for now, paths under
|
||||
`/printedparts/items`); rename the scaffold views to
|
||||
`PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint
|
||||
`router/routes/printedparts.js`; build the list page from
|
||||
`PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an
|
||||
image thumb column and a red/green quantity badge vs the threshold.
|
||||
4. Seed two or three rows by hand (SQL or flask shell) purely to have
|
||||
something to look at. NOTE: hand-seeded stock has no ledger backing - the
|
||||
stage-9 reconcile report will flag exactly these rows, which is the check
|
||||
working.
|
||||
|
||||
See it work: navigate to `/printedparts` - your parts in a table, low-stock
|
||||
row red-badged. Everything before this moment was invisible; from here on
|
||||
every stage shows on screen.
|
||||
|
||||
Common error: nav icon missing. The sidebar maps icon NAMES to Lucide
|
||||
components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing.
|
||||
Add `'box': Box` to the map (and the import) or reuse an existing name.
|
||||
|
||||
Commit + tag `lab-stage-03`.
|
||||
|
||||
## Stage 4 - catalog mutations + item photos + detail/form pages
|
||||
|
||||
1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the
|
||||
id: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
|
||||
updates catalog fields but REFUSES `quantityonhand` (ledger-managed).
|
||||
`DELETE` soft-retires. All `@jwt_required()` (permissions come in
|
||||
stage 6).
|
||||
2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE
|
||||
`/items/<id>/image` + public `GET /image/<filename>`, storing
|
||||
`printeditem-<id>.<ext>` in `instance/printedpartsimages/`, wiping prior
|
||||
extensions on replace, prefix-guarded delete.
|
||||
3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info
|
||||
list, transactions table); `PrintedItemForm.vue` create/edit + photo
|
||||
upload on edit; extend the api client.
|
||||
|
||||
See it work: add a part with a photo in the UI; thumbnail on the list, hero
|
||||
on the detail; `PUT` with `quantityonhand` returns the ledger-managed error.
|
||||
|
||||
Commit + tag `lab-stage-04`.
|
||||
|
||||
## Stage 5 - the ledger: restock/adjust with badge attribution
|
||||
|
||||
1. `services/badges.py` - COPY the USB badge contract (do not import
|
||||
`plugins.usb`; cross-plugin imports fail the contract test):
|
||||
`^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees
|
||||
plugin `DirectoryEmployee` (lazy import, graceful fallback), and the
|
||||
`printedparts_unknown_badge` policy - deny raises a kiosk-displayable
|
||||
`BadgeError`, allow records the SSO with an empty name.
|
||||
2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant:
|
||||
append the transaction row and move the cached quantity in ONE commit.
|
||||
Every write path goes through it.
|
||||
3. `POST /items/<id>/restock` {quantity, badge} and `/adjust`
|
||||
{quantitychange, reason, badge}; adjust requires a reason and refuses to
|
||||
drive stock below zero.
|
||||
4. Detail page: Restock/Adjust modals (shared `Modal.vue`).
|
||||
5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge
|
||||
shape, reason-required + below-zero guards, the policy toggle, 401 for
|
||||
anonymous. See `tests/test_plugins/test_printedparts_ledger.py`.
|
||||
|
||||
See it work: restock from the detail page with your SSO - quantity moves AND
|
||||
a named transaction row appears.
|
||||
|
||||
Common error: in tests, mutating rows through a nested `app.app_context()`
|
||||
does not reliably stick in the sqlite test env - stock the item through the
|
||||
real restock endpoint instead (also more honest).
|
||||
|
||||
Commit + tag `lab-stage-05`.
|
||||
|
||||
## Stage 6 - RBAC
|
||||
|
||||
1. `get_permissions` on the plugin: view/create/edit/delete/restock, category
|
||||
`printedparts` (seeded automatically on install/enable and by
|
||||
`flask seed permissions`).
|
||||
2. Add `@require_permission('printedparts.<x>')` under `@jwt_required()` on
|
||||
every mutation: create/edit/delete/image = create/edit/delete; restock +
|
||||
adjust = restock.
|
||||
3. Test with the `member_headers` fixture (authenticated, role-less): 403
|
||||
where admin succeeds - authentication alone is not authorization.
|
||||
|
||||
See it work: the permissions appear in the role grid (Settings > Roles), and
|
||||
the member test passes.
|
||||
|
||||
Commit + tag `lab-stage-06`.
|
||||
|
||||
## Stage 7 - the kiosk (the deliberate open write)
|
||||
|
||||
Read the decision record in the proposal first. The take endpoint must stay:
|
||||
decrement-only, badge-attributed server-side, bounded, physically
|
||||
rate-limited. Put the justification in the plugin README.
|
||||
|
||||
1. Backend, both UNdecorated: `GET /kiosk/item/<itemcode>` (summary for a
|
||||
scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} -
|
||||
validate active item, 1 <= qty <= onhand, resolve the badge, then
|
||||
`_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown
|
||||
verbatim on the kiosk - write them for a person standing at a screen.
|
||||
2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace.
|
||||
3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside
|
||||
`/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout,
|
||||
`meta.plugin` so a disabled plugin dead-ends). Three steps - scan item,
|
||||
scan badge, keypad quantity - driven by ONE hidden always-focused input
|
||||
that consumes keyboard-wedge scans (scanners type the code + Enter) for
|
||||
whichever step is active; manual type-in fallbacks for damaged labels.
|
||||
Success screen auto-resets after a few seconds.
|
||||
4. Kiosk test: open access, over-take guard, unknown-badge 422, and
|
||||
cache==ledger afterward.
|
||||
|
||||
See it work: full walkthrough in a browser - type a code, badge in, keypad 2,
|
||||
TAKE - stock drops with your name in the ledger.
|
||||
|
||||
Common error (by design): the full suite fails with
|
||||
`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`.
|
||||
That sweep asserts EVERY mutating route rejects a role-less user - the
|
||||
framework's net against accidentally-open writes. Your kiosk take is open on
|
||||
purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment
|
||||
pointing at the decision record. The net stays; the exception is explicit
|
||||
and reviewable.
|
||||
|
||||
Commit + tag `lab-stage-07`.
|
||||
|
||||
## Stage 8 - 1in x 0.5in bin labels
|
||||
|
||||
1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public
|
||||
`/print/printedparts-labels` route beside `/print/usb-labels` (a plugin
|
||||
OWNS its label page - the USB precedent; parts are not in the asset-label
|
||||
TYPE_CONFIG because they are not assets).
|
||||
2. The label: CODE128 of the itemcode via JsBarcode
|
||||
(`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`)
|
||||
+ the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner
|
||||
tolerance; CODE128 of `3DP-0042` is comfortable.
|
||||
3. Roll stock = one label per page: a global (unscoped) print style with
|
||||
`@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on
|
||||
each `.bin-label`. Multi-select + per-item copies; `?item=<id>`
|
||||
preselects (the Detail page's Bin Label button).
|
||||
|
||||
See it work: print preview shows one 1x0.5 label per page; scan the printed
|
||||
barcode (or the on-screen one with a phone scanner app) into the kiosk -
|
||||
label -> scan -> badge -> take -> ledger is the demo moment.
|
||||
|
||||
Commit + tag `lab-stage-08`.
|
||||
|
||||
## Stage 9 - reports + the reconcile check
|
||||
|
||||
1. Three jwt-optional endpoints in the plugin blueprint, each honoring
|
||||
`?format=csv` (local CSV helper - `generate_csv` is not on the contract
|
||||
surface): `/reports/stock`, `/reports/consumption?days=N`,
|
||||
`/reports/by-person?days=N`.
|
||||
2. The stock report's `ledgerdelta` column = cached quantityonhand minus the
|
||||
ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a
|
||||
write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows
|
||||
show up here, proving the check works.
|
||||
3. `get_reports` on the plugin (endpoint-style entries, categories
|
||||
inventory/usage) - they merge into `GET /api/reports` and the /reports hub
|
||||
while the plugin is enabled.
|
||||
|
||||
Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries
|
||||
strings.
|
||||
|
||||
Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only
|
||||
renderers; needs a core component) and a Settings card (needs a settings page
|
||||
to link). Reports are the monitoring surface.
|
||||
|
||||
Commit + tag `lab-stage-09`.
|
||||
|
||||
## Stage 10 - closeout
|
||||
|
||||
1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and
|
||||
grantable permissions disappear; API routes only disappear after a
|
||||
RESTART (blueprints register at startup - the guide's section 12 gotcha).
|
||||
Re-enable.
|
||||
2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` +
|
||||
`flask plugin install/enable printedparts` + `upgrade-all` - green with
|
||||
zero manual SQL.
|
||||
3. Full suite: backend pytest, vitest, frontend build, naming hook.
|
||||
4. Walk `PLUGIN-GUIDE.md` section 12's End checklist.
|
||||
|
||||
Done means: a colleague can clone the repo, enable the plugin, print a bin
|
||||
label, and take a part at the kiosk with their badge - without asking you
|
||||
anything.
|
||||
|
||||
## Stage 11 (extension) - low-stock email alerts
|
||||
|
||||
Per-item thresholds already exist; alerting on them is a worked example of a
|
||||
CONTRACT ADDITION, because the mailer was not on the plugin surface:
|
||||
1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump
|
||||
`__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the
|
||||
docs-drift guard test fails until the doc's version example matches.
|
||||
Manifest pins `core_version >=0.12.0` since the plugin now needs it.
|
||||
2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the
|
||||
threshold (before > threshold >= after). Crossing, not being-below, is the
|
||||
natural debounce: one alert per depletion, restocking above rearms.
|
||||
Best-effort try/except AFTER the commit - mail failure must never fail
|
||||
the take.
|
||||
3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty
|
||||
falls back to the site's alert_recipients via `send_alert`. Seed the new
|
||||
setting in on_enable too (idempotent) so already-installed sites get it.
|
||||
4. Test with a monkeypatched sender: no alert above threshold, one on the
|
||||
crossing, no re-fire while below, rearm after restock (see
|
||||
`test_lowstock_alert_fires_on_crossing_only`).
|
||||
|
||||
## Stage 12 (extension) - the admin settings page
|
||||
|
||||
A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred
|
||||
it. The page is ordinary:
|
||||
1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four
|
||||
keys via `settingsApi.list({category: 'printedparts'})`, save each with
|
||||
`settingsApi.update(key, value)` (admin-gated server-side).
|
||||
2. Route in the PLUGIN's router file with path `settings/printedparts` +
|
||||
`requiresAuth, requiresAdmin, plugin` meta - the router shell
|
||||
automatically nests any `settings/...` path under the two-pane settings
|
||||
rail.
|
||||
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
|
||||
the card appears in the rail's catalog while the plugin is enabled.
|
||||
|
||||
## Stage 13 (extension) - alert recipients picked from shopdb users
|
||||
|
||||
Free-text emails rot; user accounts do not. Another contract addition:
|
||||
`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the
|
||||
docs-drift guard again).
|
||||
1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded
|
||||
beside the others.
|
||||
2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's
|
||||
account email, merge with the free-text list, dedupe order-preserving;
|
||||
empty result still falls back to the site alert_recipients.
|
||||
3. Settings page: checkbox picker over `usersApi.list()` (the page is
|
||||
admin-only, matching the endpoint), saving joined ids.
|
||||
4. Test: active user's email + free-text merge deduped, inactive user
|
||||
skipped (`test_alert_recipients_merge_users_and_freetext`).
|
||||
|
||||
## Stage 14 (extension) - retire/restore in the UI, dashless codes
|
||||
|
||||
Field feedback stage: the soft-delete endpoint existed with no button, and
|
||||
the site wanted `WJRP0042`, not `WJRP-0042`.
|
||||
1. Detail gains Retire (confirm dialog; item leaves the storefront and the
|
||||
kiosk 404s its code, history and label intact) and Restore; the list
|
||||
gains an Include-retired toggle (`?active=false`) with a Retired badge.
|
||||
Restore is its own POST gated by printedparts.delete - PUT deliberately
|
||||
cannot flip isactive.
|
||||
2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their
|
||||
codes - itemcode is an immutable label once printed on a bin.
|
||||
|
||||
## Stage 15 (extension) - print-file revisions + role-based alerts
|
||||
|
||||
Two more field requests, and the plugin's FIRST incremental migration:
|
||||
1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item)
|
||||
arrives as `0002_printeditemfiles` on top of the 0001 baseline - the
|
||||
ADR-008 payoff: the plugin evolves its own schema, `flask plugin
|
||||
upgrade-all` applies it, the core chain never hears about it. Update
|
||||
PLUGIN_TABLE_OWNERS and the guard test's expected head.
|
||||
Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on
|
||||
utf8mb4 dies with error 1071 because the per-plugin chain does not apply
|
||||
the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or
|
||||
less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's
|
||||
innodb_large_prefix globals reset on restart (documented dev caveat).
|
||||
2. Upload endpoint assigns revision = max+1, stores
|
||||
`printeditem-<id>-rev<n><ext>` in `instance/printedpartsfiles/`
|
||||
(extension allowlist, 100 MB cap), records uploader from the JWT.
|
||||
Download serves the ORIGINAL filename; delete (permission-gated) exists
|
||||
for wrong-file mistakes, otherwise history is append-only. Detail page
|
||||
gains the revision table with a "current" badge on the newest.
|
||||
3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User;
|
||||
Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every
|
||||
ACTIVE member of each selected role (role.users backref), deduped with
|
||||
the user picks and free-text; settings page gains a role picker.
|
||||
|
||||
---
|
||||
|
||||
## Where each pattern lives (cheat sheet)
|
||||
|
||||
| Need | Copy from |
|
||||
|---|---|
|
||||
| Standalone (non-asset) plugin shape | `plugins/knowledgebase/` |
|
||||
| Checkout/ledger + badge contract | `plugins/usb/` (`api/routes.py` badge regex, `api/selfhosted.py` name resolve) |
|
||||
| Real-baseline plugin migration | `plugins/measuringtools/migrations/` |
|
||||
| Blueprint style, pagination, authz | `plugins/measuringtools/api/routes.py` |
|
||||
| Image upload/serve/delete | `shopdb/core/api/models.py` |
|
||||
| Open kiosk endpoints precedent | `plugins/employees/api/routes.py`, `plugins/notifications/api/routes.py` |
|
||||
| Plugin-owned label print view | `frontend/src/views/print/USBLabelBatch.vue` |
|
||||
| Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` |
|
||||
| Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` |
|
||||
| List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` |
|
||||
| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` |
|
||||
| Permissions declaration | `plugins/usb/plugin.py::get_permissions` |
|
||||
| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` |
|
||||
@@ -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.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
@@ -24,6 +24,7 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | ACCEPTED |
|
||||
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
|
||||
| [012](ADR-012-geenforce-manifest-ownership.md) | GE-Enforce manifest ownership in shopdb | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
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
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
@@ -25,6 +27,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"vite": "^6.4.1"
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import axios from 'axios'
|
||||
import { withBase, stripBase } from './../utils/basePath'
|
||||
|
||||
// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath
|
||||
// mount. Keeps the SPA, its API, and IIS all on the same mount path.
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: import.meta.env.BASE_URL + 'api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -31,10 +34,13 @@ api.interceptors.response.use(
|
||||
// Only redirect if user was previously logged in (session expired).
|
||||
// Preserve the destination so login returns the user to this page.
|
||||
if (hadToken) {
|
||||
const here = window.location.pathname + window.location.search
|
||||
const loginPath = withBase('/login')
|
||||
// Router paths exclude the mount base; strip it or login's
|
||||
// router.push double-prefixes under a subpath mount.
|
||||
const here = stripBase(window.location.pathname) + window.location.search
|
||||
const target = here && here !== '/login'
|
||||
? '/login?redirect=' + encodeURIComponent(here)
|
||||
: '/login'
|
||||
? loginPath + '?redirect=' + encodeURIComponent(here)
|
||||
: loginPath
|
||||
window.location.href = target
|
||||
}
|
||||
}
|
||||
@@ -1120,3 +1126,61 @@ 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}`)
|
||||
},
|
||||
restore(printeditemid) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restore`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
deleteImage(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||
},
|
||||
restock(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
|
||||
},
|
||||
adjust(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||
},
|
||||
kioskItem(itemcode) {
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
},
|
||||
uploadFile(printeditemid, file, note) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (note) formData.append('note', note)
|
||||
return api.post(`/printedparts/items/${printeditemid}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
removeFile(fileid) {
|
||||
return api.delete(`/printedparts/files/${fileid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,26 +624,26 @@ input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Dark mode form adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-control {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Dark mode form adjustments. Scoped to the explicit theme attribute (the
|
||||
theme store always stamps it at startup) - a bare prefers-color-scheme
|
||||
query here leaks dark widget styles into light mode on dark-OS machines. */
|
||||
[data-theme="dark"] .form-control {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
select.form-control option {
|
||||
background: var(--text);
|
||||
}
|
||||
[data-theme="dark"] select.form-control option {
|
||||
background: var(--bg-card-solid);
|
||||
}
|
||||
|
||||
/* Form grid */
|
||||
@@ -941,8 +941,17 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
td.actions {
|
||||
/* A td must stay display:table-cell - the .actions inline-flex above pulls
|
||||
the cell out of the table's row box, so its bottom border renders ~1px off
|
||||
from the other cells. Keep it a cell; space multiple buttons with a margin
|
||||
instead of the flex gap. */
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.actions .btn + .btn {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DETAIL PAGES (shared styles)
|
||||
@@ -1051,17 +1060,19 @@ td.actions {
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned
|
||||
grid) so cards distribute by height and the columns stay even no matter
|
||||
how many cards land on either side. display:contents flattens the two
|
||||
.content-column wrappers so their cards flow directly into the columns,
|
||||
which keeps the existing markup unchanged. */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 25px;
|
||||
column-count: 2;
|
||||
column-gap: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Section Cards */
|
||||
@@ -1070,6 +1081,10 @@ td.actions {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
/* multicol needs per-card spacing (column-gap is horizontal only) and
|
||||
must not split a card across the column break */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -1484,7 +1499,7 @@ td.actions {
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
column-count: 1;
|
||||
}
|
||||
|
||||
.audit-footer {
|
||||
@@ -1711,16 +1726,14 @@ td.actions {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.notification-item.type-incident {
|
||||
background: rgba(245, 54, 92, 0.1);
|
||||
}
|
||||
.notification-item.type-change {
|
||||
background: rgba(255, 136, 0, 0.1);
|
||||
}
|
||||
.notification-item.type-awareness {
|
||||
background: rgba(4, 185, 98, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-incident {
|
||||
background: rgba(245, 54, 92, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-change {
|
||||
background: rgba(255, 136, 0, 0.1);
|
||||
}
|
||||
[data-theme="dark"] .notification-item.type-awareness {
|
||||
background: rgba(4, 185, 98, 0.1);
|
||||
}
|
||||
|
||||
/* Light mode is now default, dark mode via prefers-color-scheme */
|
||||
@@ -1736,3 +1749,9 @@ td.actions {
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Clickable list rows: the whole row navigates to the item detail; interactive
|
||||
cells (the actions column, in-row links) stop propagation so they still work
|
||||
independently. */
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover td { background: var(--bg); }
|
||||
|
||||
@@ -196,12 +196,12 @@ import { apiError } from '../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
assetId: {
|
||||
assetid: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// Alternative: lookup by machine/asset number
|
||||
machineNumber: {
|
||||
machinenumber: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
@@ -315,14 +315,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetId, async () => {
|
||||
watch(() => props.assetid, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.machineNumber, async () => {
|
||||
watch(() => props.machinenumber, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
@@ -330,17 +330,17 @@ watch(() => props.machineNumber, async () => {
|
||||
})
|
||||
|
||||
async function resolveAssetId() {
|
||||
// If assetId is provided directly, use it
|
||||
if (props.assetId) {
|
||||
resolvedAssetId.value = props.assetId
|
||||
// If assetid is provided directly, use it
|
||||
if (props.assetid) {
|
||||
resolvedAssetId.value = props.assetid
|
||||
lookupFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to look up by machine number
|
||||
if (props.machineNumber) {
|
||||
if (props.machinenumber) {
|
||||
try {
|
||||
const response = await assetsApi.lookup(props.machineNumber)
|
||||
const response = await assetsApi.lookup(props.machinenumber)
|
||||
resolvedAssetId.value = response.data.data?.assetid
|
||||
lookupFailed.value = !resolvedAssetId.value
|
||||
} catch (error) {
|
||||
@@ -502,6 +502,11 @@ function getAssetRoute(asset) {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
/* Match .section-card spacing so relationships is its own card with a gap
|
||||
below (not visually merged with the Notes card) and does not split across
|
||||
a multicol break. */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
|
||||
38
frontend/src/components/TouchKeypad.vue
Normal file
38
frontend/src/components/TouchKeypad.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="touch-keypad">
|
||||
<button v-for="digit in digits" :key="digit" type="button"
|
||||
class="keypad-button" @click="$emit('digit', digit)">
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button type="button" class="keypad-button keypad-muted"
|
||||
@click="$emit('clear')">C</button>
|
||||
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
|
||||
<button type="button" class="keypad-button keypad-muted"
|
||||
@click="$emit('backspace')"><</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
defineEmits(['digit', 'clear', 'backspace'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.touch-keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.keypad-button {
|
||||
font-size: 1.8rem;
|
||||
padding: 1rem 0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.keypad-button:active { background: var(--primary); color: #fff; }
|
||||
.keypad-muted { color: var(--text-light); }
|
||||
</style>
|
||||
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() {
|
||||
|
||||
@@ -65,6 +65,14 @@ const routes = [
|
||||
name: 'shopfloor',
|
||||
component: () => import('../views/ShopfloorDashboard.vue')
|
||||
},
|
||||
{
|
||||
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
|
||||
// Open on purpose - see the decision record in the printedparts proposal.
|
||||
path: '/parts-kiosk',
|
||||
name: 'parts-kiosk',
|
||||
component: () => import('../views/printedparts/PartsKiosk.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: '/tv',
|
||||
name: 'tv',
|
||||
@@ -109,6 +117,12 @@ const routes = [
|
||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: '/print/printedparts-labels',
|
||||
name: 'print-printedparts-labels',
|
||||
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
@@ -117,7 +131,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
|
||||
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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
41
frontend/src/router/routes/printedparts.js
Normal file
41
frontend/src/router/routes/printedparts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Printedparts plugin routes.
|
||||
*
|
||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
||||
* edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009
|
||||
* guard redirects to the dashboard when the printedparts backend plugin is
|
||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
||||
* create or edit.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printedparts',
|
||||
name: 'printedparts',
|
||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printedparts',
|
||||
name: 'settings-printedparts',
|
||||
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
</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>
|
||||
<a v-if="isPluginEnabled('printedparts')" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
@@ -101,12 +103,14 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
KeyRound, LogOut
|
||||
Box, KeyRound, LogOut
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { isPluginEnabled } from '../composables/enabledPlugins'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -118,7 +122,7 @@ const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
const activeNotifications = ref([])
|
||||
const facilityName = ref('ShopDB')
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
|
||||
|
||||
function getTicketSearchUrl(ticketnumber) {
|
||||
@@ -146,6 +150,7 @@ const iconMap = {
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { authApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -48,7 +49,7 @@ const authStore = useAuthStore()
|
||||
|
||||
const forced = computed(() => authStore.mustChangePassword)
|
||||
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { setupApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { withBase, stripBase } from '../utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -62,12 +63,14 @@ const authStore = useAuthStore()
|
||||
function postLoginTarget() {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
return redirect
|
||||
// Tolerate a redirect that still carries the mount base (old bookmarks,
|
||||
// pre-fix interceptor URLs): router paths must be base-free.
|
||||
return stripBase(redirect)
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const mode = ref('login')
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="search-results">
|
||||
<div class="page-header">
|
||||
<h2>Search Results</h2>
|
||||
<span v-if="results.length" class="results-count">
|
||||
@@ -385,56 +385,33 @@ watch(results, () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-type.machine {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
/* Per-domain badge palette. Values live in CSS variables on the container so
|
||||
the dark theme overrides them in one place (below) instead of restating
|
||||
every selector. Each badge rule just references its pair. */
|
||||
.search-results {
|
||||
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
|
||||
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
||||
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
||||
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
||||
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
||||
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
||||
}
|
||||
|
||||
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
|
||||
.result-type.pc,
|
||||
.result-type.computer {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.result-type.application {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.result-type.knowledgebase {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.result-type.printer {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.result-type.network_device {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.result-type.measuring_tool {
|
||||
background: #e0f7fa;
|
||||
color: #00838f;
|
||||
}
|
||||
|
||||
.result-type.employee {
|
||||
background: #e0f2f1;
|
||||
color: #00695c;
|
||||
}
|
||||
|
||||
.result-type.notification {
|
||||
background: #e8eaf6;
|
||||
color: #283593;
|
||||
}
|
||||
|
||||
.result-type.subnet {
|
||||
background: #fbe9e7;
|
||||
color: #bf360c;
|
||||
}
|
||||
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
||||
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
||||
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
||||
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
||||
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
@@ -464,10 +441,6 @@ watch(results, () => {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.result-location::before {
|
||||
content: '\1F4CD ';
|
||||
}
|
||||
|
||||
.result-ticket {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
@@ -490,55 +463,17 @@ watch(results, () => {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.result-type.machine {
|
||||
background: rgba(21, 101, 192, 0.2);
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.result-type.pc,
|
||||
.result-type.computer {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.result-type.application {
|
||||
background: rgba(230, 81, 0, 0.2);
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.result-type.knowledgebase {
|
||||
background: rgba(123, 31, 162, 0.2);
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.result-type.printer {
|
||||
background: rgba(194, 24, 91, 0.2);
|
||||
color: #f48fb1;
|
||||
}
|
||||
|
||||
.result-type.network_device {
|
||||
background: rgba(245, 127, 23, 0.2);
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.result-type.measuring_tool {
|
||||
background: rgba(0, 131, 143, 0.2);
|
||||
color: #80deea;
|
||||
}
|
||||
|
||||
.result-type.employee {
|
||||
background: rgba(0, 105, 92, 0.2);
|
||||
color: #80cbc4;
|
||||
}
|
||||
|
||||
.result-type.notification {
|
||||
background: rgba(40, 53, 147, 0.2);
|
||||
color: #9fa8da;
|
||||
}
|
||||
|
||||
.result-type.subnet {
|
||||
background: rgba(191, 54, 12, 0.2);
|
||||
color: #ffab91;
|
||||
.search-results {
|
||||
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
|
||||
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
|
||||
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
|
||||
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
||||
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
||||
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
||||
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
||||
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
||||
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
||||
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -93,6 +93,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">{{ kb.keywords }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
|
||||
@@ -1,213 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Applications</h2>
|
||||
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search applications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="filter" class="form-control" @change="loadApplications">
|
||||
<option value="installable">Installable Applications</option>
|
||||
<option value="all">All Applications</option>
|
||||
<option value="hidden">Hidden Applications</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">Files</th>
|
||||
<th style="width: 50px;">Docs</th>
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="app in applications" :key="app.appid">
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
|
||||
⬇
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -137,6 +138,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Configuration -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Configuration</h3>
|
||||
@@ -170,34 +200,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="machine.assetid" />
|
||||
@@ -206,7 +208,7 @@
|
||||
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (dualpath, controls, ...) -->
|
||||
<AssetRelationships v-if="machine.assetid" :assetId="machine.assetid" />
|
||||
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="machine.notes">
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in machines" :key="item.assetid">
|
||||
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
</td>
|
||||
@@ -50,7 +50,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -87,11 +87,11 @@ import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { machinesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const machines = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -123,19 +123,19 @@ async function loadMachines() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadMachines()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadMachines()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadMachines()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -139,7 +140,7 @@
|
||||
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (partof, connectedto, ...) -->
|
||||
<AssetRelationships v-if="tool.assetid" :assetId="tool.assetid" />
|
||||
<AssetRelationships v-if="tool.assetid" :assetid="tool.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="tool.notes">
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in tools" :key="item.assetid">
|
||||
<tr v-for="item in tools" :key="item.assetid" class="clickable-row" @click="$router.push(`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
@@ -61,7 +61,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -96,14 +96,14 @@ import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const tools = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -142,23 +142,26 @@ async function loadTools() {
|
||||
}
|
||||
|
||||
function reload() {
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(reload, 300)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadTools()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,56 +59,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<!-- Network Info -->
|
||||
<!-- Identity -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Firmware Version</span>
|
||||
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Port Count</span>
|
||||
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Rack Unit</span>
|
||||
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">PoE Capable</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Managed Device</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="device.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="device.assetid" :items="warranties" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="device.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<div class="notes-content">{{ device.notes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<!-- Asset Info -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Asset Information</h3>
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
@@ -138,36 +94,73 @@
|
||||
<span class="info-label">Device Type</span>
|
||||
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Information (type-specific) -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="info-value">
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
{{ device.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Firmware Version</span>
|
||||
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Port Count</span>
|
||||
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Rack Unit</span>
|
||||
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">PoE Capable</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Managed Device</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">{{ device.locationname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ device.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="device.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="device.assetid" :items="warranties" />
|
||||
|
||||
<!-- Relationships -->
|
||||
<AssetRelationships
|
||||
v-if="device.assetid"
|
||||
:assetId="device.assetid"
|
||||
:assetid="device.assetid"
|
||||
/>
|
||||
|
||||
<!-- Audit Info -->
|
||||
<div class="section-card audit-card">
|
||||
<h3 class="section-title">Record Info</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="device.datecreated">
|
||||
<span class="info-label">Created</span>
|
||||
<span class="info-value">{{ formatDate(device.datecreated) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="device.datemodified">
|
||||
<span class="info-label">Last Modified</span>
|
||||
<span class="info-value">{{ formatDate(device.datemodified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="device.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<div class="notes-content">{{ device.notes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +177,12 @@
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(device.createddate) }}<template v-if="device.createdby"> by {{ device.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(device.modifieddate) }}<template v-if="device.modifiedby"> by {{ device.modifiedby }}</template></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
|
||||
@@ -1,347 +1,347 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Network Devices</h2>
|
||||
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Type Tabs -->
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
:class="{ active: selectedType === null }"
|
||||
@click="selectType(null)"
|
||||
>
|
||||
All ({{ totalCount }})
|
||||
</button>
|
||||
<button
|
||||
v-for="t in deviceTypes"
|
||||
:key="t.networkdevicetypeid"
|
||||
:class="{ active: selectedType === t.networkdevicetypeid }"
|
||||
@click="selectType(t.networkdevicetypeid)"
|
||||
>
|
||||
{{ t.networkdevicetype }} ({{ t.count || 0 }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search by hostname, asset #, serial..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Vendors</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="locationFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Locations</option>
|
||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Vendor</th>
|
||||
<th>Features</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid">
|
||||
<td>{{ device.assetnumber }}</td>
|
||||
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ device.serialnumber || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
|
||||
<td class="features">
|
||||
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
|
||||
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
|
||||
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
|
||||
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
{{ device.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ device.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/network/${device.networkdevice?.networkdeviceid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="9" style="text-align: center; color: var(--text-light);">
|
||||
No network devices found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { networkApi, vendorsApi, locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const devices = ref([])
|
||||
const deviceTypes = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const selectedType = ref(null)
|
||||
const vendorFilter = ref('')
|
||||
const locationFilter = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
const totalCount = ref(0)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDeviceTypes(),
|
||||
loadVendors(),
|
||||
loadLocations()
|
||||
])
|
||||
await loadDevices()
|
||||
})
|
||||
|
||||
async function loadDeviceTypes() {
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 100 })
|
||||
deviceTypes.value = response.data.data || []
|
||||
// Get counts for each type
|
||||
await updateTypeCounts()
|
||||
} catch (error) {
|
||||
console.error('Error loading device types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTypeCounts() {
|
||||
// Get summary for type counts
|
||||
try {
|
||||
const response = await networkApi.dashboardSummary()
|
||||
const byType = response.data.data?.bytype || response.data.data?.by_type || []
|
||||
totalCount.value = response.data.data?.total || 0
|
||||
|
||||
// Map counts to types
|
||||
deviceTypes.value = deviceTypes.value.map(t => {
|
||||
const found = byType.find(bt => bt.type === t.networkdevicetype)
|
||||
return { ...t, count: found?.count || 0 }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading type counts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading vendors:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (selectedType.value) params.typeid = selectedType.value
|
||||
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
||||
if (locationFilter.value) params.locationid = locationFilter.value
|
||||
|
||||
const response = await networkApi.list(params)
|
||||
devices.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading network devices:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(typeId) {
|
||||
selectedType.value = typeId
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
if (p >= 1 && p <= totalPages.value) {
|
||||
page.value = p
|
||||
loadDevices()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-tabs button:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.type-tabs button.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filters select.form-control {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.poe {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.feature-tag.ports {
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.poe {
|
||||
background: #1e3a29;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Network Devices</h2>
|
||||
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Type Tabs -->
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
:class="{ active: selectedType === null }"
|
||||
@click="selectType(null)"
|
||||
>
|
||||
All ({{ totalCount }})
|
||||
</button>
|
||||
<button
|
||||
v-for="t in deviceTypes"
|
||||
:key="t.networkdevicetypeid"
|
||||
:class="{ active: selectedType === t.networkdevicetypeid }"
|
||||
@click="selectType(t.networkdevicetypeid)"
|
||||
>
|
||||
{{ t.networkdevicetype }} ({{ t.count || 0 }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search by hostname, asset #, serial..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Vendors</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="locationFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Locations</option>
|
||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Vendor</th>
|
||||
<th>Features</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid" class="clickable-row" @click="$router.push(`/network/${device.networkdevice?.networkdeviceid || device.assetid}`)">
|
||||
<td>{{ device.assetnumber }}</td>
|
||||
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ device.serialnumber || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
|
||||
<td class="features">
|
||||
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
|
||||
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
|
||||
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
|
||||
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
{{ device.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ device.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/network/${device.networkdevice?.networkdeviceid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="9" style="text-align: center; color: var(--text-light);">
|
||||
No network devices found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { networkApi, vendorsApi, locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const devices = ref([])
|
||||
const deviceTypes = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
|
||||
const selectedType = ref(null)
|
||||
const vendorFilter = ref('')
|
||||
const locationFilter = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
const totalCount = ref(0)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDeviceTypes(),
|
||||
loadVendors(),
|
||||
loadLocations()
|
||||
])
|
||||
await loadDevices()
|
||||
})
|
||||
|
||||
async function loadDeviceTypes() {
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 100 })
|
||||
deviceTypes.value = response.data.data || []
|
||||
// Get counts for each type
|
||||
await updateTypeCounts()
|
||||
} catch (error) {
|
||||
console.error('Error loading device types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTypeCounts() {
|
||||
// Get summary for type counts
|
||||
try {
|
||||
const response = await networkApi.dashboardSummary()
|
||||
const byType = response.data.data?.bytype || response.data.data?.by_type || []
|
||||
totalCount.value = response.data.data?.total || 0
|
||||
|
||||
// Map counts to types
|
||||
deviceTypes.value = deviceTypes.value.map(t => {
|
||||
const found = byType.find(bt => bt.type === t.networkdevicetype)
|
||||
return { ...t, count: found?.count || 0 }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading type counts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading vendors:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (selectedType.value) params.typeid = selectedType.value
|
||||
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
||||
if (locationFilter.value) params.locationid = locationFilter.value
|
||||
|
||||
const response = await networkApi.list(params)
|
||||
devices.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading network devices:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(typeId) {
|
||||
selectedType.value = typeId
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadDevices()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
if (p >= 1 && p <= totalPages.value) {
|
||||
setPage(p)
|
||||
loadDevices()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair' || s === 'maintenance') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-tabs button:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.type-tabs button.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filters select.form-control {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.poe {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.feature-tag.ports {
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.poe {
|
||||
background: #1e3a29;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -118,6 +119,25 @@
|
||||
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
@@ -140,28 +160,9 @@
|
||||
<p v-else class="muted">No network addresses on record</p>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
@@ -191,15 +192,15 @@
|
||||
<router-link
|
||||
v-for="app in installedApps"
|
||||
:key="app.id"
|
||||
:to="`/applications/${app.application?.appid}`"
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="app-item"
|
||||
>
|
||||
<div class="app-info">
|
||||
<span class="app-name">{{ app.application?.appname }}</span>
|
||||
<span class="app-version" v-if="app.version">v{{ app.version }}</span>
|
||||
<span class="app-name">{{ app.appname }}</span>
|
||||
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
||||
</div>
|
||||
<div class="app-desc" v-if="app.application?.appdescription">
|
||||
{{ app.application.appdescription }}
|
||||
<div class="app-desc" v-if="app.appdescription">
|
||||
{{ app.appdescription }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -212,7 +213,7 @@
|
||||
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetId="computer.assetid" />
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="computer.notes">
|
||||
|
||||
@@ -1,211 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Computers</h2>
|
||||
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in computers" :key="item.assetid">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.computer?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(item.accessmethods || []).length">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadComputers()
|
||||
})
|
||||
|
||||
async function loadComputers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadComputers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
margin: 0 4px 3px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||
offsets the row. lay tags out inline instead. */
|
||||
.features {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* the global .actions rule is inline-flex, which also breaks table-cell
|
||||
alignment when applied straight on a <td>; pin it back to a cell here. */
|
||||
td.actions {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Computers</h2>
|
||||
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.computer?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(item.accessmethods || []).length">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadComputers()
|
||||
})
|
||||
|
||||
async function loadComputers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadComputers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
margin: 0 4px 3px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||
offsets the row. lay tags out inline instead. */
|
||||
.features {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* the global .actions rule is inline-flex, which also breaks table-cell
|
||||
alignment when applied straight on a <td>; pin it back to a cell here. */
|
||||
td.actions {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,13 +33,36 @@
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<div class="control-row">
|
||||
<label>Output
|
||||
<select v-model="outputMode">
|
||||
<option value="single">Single label (standalone)</option>
|
||||
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
|
||||
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
|
||||
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
|
||||
<span class="cell-picker-label">Sheet cell</span>
|
||||
<div class="cell-grid">
|
||||
<button
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
type="button"
|
||||
class="cell-btn"
|
||||
:class="{ active: cellNum === sheetCell }"
|
||||
@click="sheetCell = cellNum"
|
||||
>{{ cellNum }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn" @click="print">Print</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable area -->
|
||||
<div v-if="asset" class="label-sheet">
|
||||
<!-- Printable area: standalone single label (unchanged default). -->
|
||||
<div v-if="asset && outputMode === 'single'" class="label-sheet">
|
||||
<div class="asset-label" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
@@ -61,6 +84,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
|
||||
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
|
||||
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
|
||||
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
|
||||
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
|
||||
<div v-else-if="asset" class="sheets-container">
|
||||
<div class="print-sheet">
|
||||
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
|
||||
>
|
||||
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -90,6 +151,10 @@ const codeText = ref('')
|
||||
const style = ref('card')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
|
||||
// label at sheetCell (1-6) of a ULINE 6-up sheet.
|
||||
const outputMode = ref('single')
|
||||
const sheetCell = ref(1)
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
@@ -168,7 +233,9 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([style, codetype, encodes], renderCode)
|
||||
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
|
||||
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
|
||||
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
@@ -176,7 +243,11 @@ function print() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
|
||||
/* pages let each render its own size (only one branch renders at a time). */
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
@page uline { size: letter; margin: 0; }
|
||||
.print-sheet { page: uline; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
@@ -193,6 +264,33 @@ function print() {
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
||||
|
||||
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
|
||||
.cell-picker { margin-bottom: 12px; }
|
||||
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 3rem);
|
||||
grid-auto-rows: 3rem;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cell-btn:hover { border-color: var(--primary); }
|
||||
.cell-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
@@ -264,10 +362,61 @@ function print() {
|
||||
padding: 0.3in 0.1in;
|
||||
}
|
||||
|
||||
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
|
||||
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
|
||||
.label .asset-label.in-cell {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
|
||||
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
|
||||
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
|
||||
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.label-sheet { padding: 0; }
|
||||
.asset-label { border: none; }
|
||||
.sheets-container { padding: 0; }
|
||||
.print-sheet { border: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
body, .asset-label, .code-qr, .code-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
|
||||
@@ -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
|
||||
|
||||
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
|
||||
<p>
|
||||
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
|
||||
the item code, scannable at the parts kiosk.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading parts...</div>
|
||||
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
|
||||
<div v-else class="parts-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="part-item"
|
||||
:class="{ selected: isSelected(item) }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(item)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ item.itemcode }}</code></strong>
|
||||
<div class="alias">{{ item.itemname }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedItems.length }}</span> labels
|
||||
<label class="copies-label">Copies each:
|
||||
<input v-model.number="copies" type="number" min="1" max="10" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedItems.length === 0"
|
||||
@click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="labels-container">
|
||||
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
|
||||
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
|
||||
<div class="bin-code">{{ label.itemcode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { printedpartsApi } from '../../api'
|
||||
|
||||
const items = ref([])
|
||||
const selectedItems = ref([])
|
||||
const copies = ref(1)
|
||||
const loading = ref(true)
|
||||
const barcodeElements = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.list({ perpage: 500 })
|
||||
items.value = response.data.data || []
|
||||
// ?item=<id> preselects one part (the Detail-page print button)
|
||||
const preselect = new URLSearchParams(window.location.search).get('item')
|
||||
if (preselect) {
|
||||
const match = items.value.find(
|
||||
candidate => String(candidate.printeditemid) === preselect)
|
||||
if (match) selectedItems.value = [match]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const printLabels = computed(() => {
|
||||
const labels = []
|
||||
for (const item of selectedItems.value) {
|
||||
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
|
||||
labels.push(item)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
function isSelected(item) {
|
||||
return selectedItems.value.some(
|
||||
candidate => candidate.printeditemid === item.printeditemid)
|
||||
}
|
||||
|
||||
function toggleItem(item) {
|
||||
if (isSelected(item)) {
|
||||
selectedItems.value = selectedItems.value.filter(
|
||||
candidate => candidate.printeditemid !== item.printeditemid)
|
||||
} else {
|
||||
selectedItems.value = [...selectedItems.value, item]
|
||||
}
|
||||
}
|
||||
|
||||
function setBarcodeElement(element, index) {
|
||||
if (element) barcodeElements.value[index] = element
|
||||
}
|
||||
|
||||
watch(printLabels, async labels => {
|
||||
await nextTick()
|
||||
labels.forEach((label, index) => {
|
||||
const element = barcodeElements.value[index]
|
||||
if (element) {
|
||||
// CODE128 of the short item code fits 1x0.5in with comfortable
|
||||
// scanner tolerance; a QR at this size would be marginal.
|
||||
JsBarcode(element, label.itemcode, {
|
||||
format: 'CODE128',
|
||||
displayValue: false,
|
||||
width: 1.4,
|
||||
height: 26,
|
||||
margin: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.controls {
|
||||
max-width: 46rem;
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.parts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.part-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.part-item.selected { border-color: var(--primary); }
|
||||
.alias { color: var(--text-light); font-size: 0.85rem; }
|
||||
.selected-count { margin: 0.75rem 0; }
|
||||
.copies-label { margin-left: 1.25rem; }
|
||||
.copies-label input { width: 4rem; padding: 0.25rem; }
|
||||
.print-btn, .clear-btn, .select-all-btn {
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.loading-msg { color: var(--text-light); padding: 1rem; }
|
||||
|
||||
/* screen preview of the labels */
|
||||
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
|
||||
.bin-label {
|
||||
width: 1in;
|
||||
height: 0.5in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
outline: 1px dashed #bbb;
|
||||
}
|
||||
.bin-barcode { width: 0.92in; height: 0.3in; }
|
||||
.bin-code {
|
||||
font-size: 6.5pt;
|
||||
font-family: monospace;
|
||||
color: #000;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 1in x 0.5in roll stock: one label per page */
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.labels-container { display: block; padding: 0; gap: 0; }
|
||||
.bin-label {
|
||||
outline: none;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page { size: 1in 0.5in; margin: 0; }
|
||||
body { margin: 0; }
|
||||
}
|
||||
</style>
|
||||
242
frontend/src/views/printedparts/PartsKiosk.vue
Normal file
242
frontend/src/views/printedparts/PartsKiosk.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="parts-kiosk" @click="focusWedge">
|
||||
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
|
||||
always-focused input; whichever step is active consumes the scan -->
|
||||
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
|
||||
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
|
||||
|
||||
<header class="kiosk-header">
|
||||
<h1>3D Printed Parts</h1>
|
||||
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
|
||||
Start over
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
<!-- step 1: scan the bin -->
|
||||
<section v-if="step === 'item'" class="kiosk-step">
|
||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||
<p class="kiosk-hint">
|
||||
No scanner?
|
||||
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
|
||||
</p>
|
||||
<div v-if="manualEntry" class="manual-row">
|
||||
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
|
||||
@keydown.enter="lookupItem(manualCode)" />
|
||||
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 2: badge -->
|
||||
<section v-else-if="step === 'badge'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">Scan your badge</p>
|
||||
<div class="manual-row">
|
||||
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
|
||||
@keydown.enter="acceptBadge(manualBadge)" />
|
||||
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 3: quantity -->
|
||||
<section v-else-if="step === 'quantity'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">How many are you taking?</p>
|
||||
<div class="quantity-display">{{ quantity || '0' }}</div>
|
||||
<TouchKeypad @digit="quantity += $event"
|
||||
@clear="quantity = ''"
|
||||
@backspace="quantity = quantity.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
|
||||
@click="submitTake">
|
||||
{{ submitting ? 'Working...' : 'TAKE' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- done -->
|
||||
<section v-else-if="step === 'done'" class="kiosk-step">
|
||||
<p class="kiosk-success">Done - {{ doneMessage }}</p>
|
||||
<p class="kiosk-hint">Starting over in a few seconds...</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
import TouchKeypad from '../../components/TouchKeypad.vue'
|
||||
|
||||
const step = ref('item')
|
||||
const item = ref(null)
|
||||
const badge = ref('')
|
||||
const quantity = ref('')
|
||||
const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const submitting = ref(false)
|
||||
const manualEntry = ref(false)
|
||||
const manualCode = ref('')
|
||||
const manualBadge = ref('')
|
||||
const wedgeInput = ref(null)
|
||||
const wedgeBuffer = ref('')
|
||||
let resetTimer = null
|
||||
|
||||
onMounted(focusWedge)
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusWedge() {
|
||||
wedgeInput.value?.focus()
|
||||
}
|
||||
|
||||
function onWedgeEnter() {
|
||||
const scanned = wedgeBuffer.value.trim()
|
||||
wedgeBuffer.value = ''
|
||||
if (!scanned) return
|
||||
if (step.value === 'item') lookupItem(scanned)
|
||||
else if (step.value === 'badge') acceptBadge(scanned)
|
||||
}
|
||||
|
||||
async function lookupItem(itemcode) {
|
||||
error.value = ''
|
||||
if (!itemcode) return
|
||||
try {
|
||||
const response = await printedpartsApi.kioskItem(itemcode.trim())
|
||||
item.value = response.data.data
|
||||
step.value = 'badge'
|
||||
manualEntry.value = false
|
||||
manualCode.value = ''
|
||||
} catch (lookupError) {
|
||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||
'No part matches that barcode'
|
||||
}
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
function acceptBadge(value) {
|
||||
error.value = ''
|
||||
const scanned = (value || '').trim()
|
||||
if (!scanned) return
|
||||
badge.value = scanned
|
||||
manualBadge.value = ''
|
||||
step.value = 'quantity'
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
async function submitTake() {
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await printedpartsApi.kioskTake({
|
||||
itemcode: item.value.itemcode,
|
||||
badge: badge.value,
|
||||
quantity: parseInt(quantity.value, 10)
|
||||
})
|
||||
doneMessage.value = response.data.message
|
||||
step.value = 'done'
|
||||
resetTimer = setTimeout(reset, 4000)
|
||||
} catch (takeError) {
|
||||
error.value = takeError.response?.data?.data?.error?.message ||
|
||||
'Could not complete - see the parts team'
|
||||
if (takeError.response?.status === 422) {
|
||||
// badge problem: go back a step so the next scan retries cleanly
|
||||
step.value = 'badge'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(resetTimer)
|
||||
step.value = 'item'
|
||||
item.value = null
|
||||
badge.value = ''
|
||||
quantity.value = ''
|
||||
error.value = ''
|
||||
doneMessage.value = ''
|
||||
focusWedge()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parts-kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.kiosk-header {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wedge-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
}
|
||||
.kiosk-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
|
||||
.kiosk-hint { color: var(--text-light); }
|
||||
.kiosk-error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
|
||||
.item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 1rem 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.item-photo {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.quantity-display {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
border-bottom: 3px solid var(--primary);
|
||||
}
|
||||
.take-button {
|
||||
font-size: 1.5rem;
|
||||
padding: 0.9rem 3.5rem;
|
||||
}
|
||||
.manual-row { display: flex; gap: 0.6rem; }
|
||||
</style>
|
||||
349
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
349
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<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>
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
|
||||
Restock
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
|
||||
Adjust
|
||||
</button>
|
||||
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
|
||||
class="btn btn-secondary btn-sm">Bin Label</router-link>
|
||||
<button v-if="item.isactive" class="btn btn-danger btn-sm"
|
||||
@click="retireItem">Retire</button>
|
||||
<button v-else class="btn btn-primary btn-sm"
|
||||
@click="restoreItem">Restore</button>
|
||||
</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">Print files</h3>
|
||||
<div class="file-upload-row">
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
|
||||
<input v-model="fileNote" type="text" class="form-control"
|
||||
placeholder="What changed? (optional)" />
|
||||
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
|
||||
@click="uploadRevision">
|
||||
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fileError" class="error-message">{{ fileError }}</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rev</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>By</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="revision in files" :key="revision.fileid"
|
||||
:class="{ 'current-revision': revision === files[0] }">
|
||||
<td>{{ revision.revision }}</td>
|
||||
<td>
|
||||
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)">
|
||||
{{ revision.filename }}
|
||||
</a>
|
||||
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
||||
</td>
|
||||
<td>{{ formatSize(revision.filesize) }}</td>
|
||||
<td :title="revision.uploadeddate">{{ revision.uploadedby }}</td>
|
||||
<td>{{ revision.uploadnote || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
@click="removeRevision(revision)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="files.length === 0">
|
||||
<td colspan="6" class="empty-state">No print file uploaded yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Who</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="transaction in item.recenttransactions"
|
||||
:key="transaction.transactionid">
|
||||
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
||||
<td>{{ transaction.transactiontype }}</td>
|
||||
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
||||
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
||||
</td>
|
||||
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
||||
<td>{{ transaction.reason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!item.recenttransactions?.length">
|
||||
<td colspan="5" class="empty-state">No transactions yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-footer">
|
||||
Created {{ formatDate(item.createddate) }} -
|
||||
Modified {{ formatDate(item.modifieddate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">Item not found</div>
|
||||
|
||||
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
|
||||
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
|
||||
<div class="form-group">
|
||||
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
|
||||
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
|
||||
</div>
|
||||
<div v-if="ledgerMode === 'adjust'" class="form-group">
|
||||
<label>Reason *</label>
|
||||
<input v-model="ledgerReason" type="text" class="form-control"
|
||||
placeholder="e.g., damaged parts scrapped, recount" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Your badge / SSO *</label>
|
||||
<input v-model="ledgerBadge" type="text" class="form-control"
|
||||
placeholder="Scan badge or type SSO" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
|
||||
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
await loadFiles()
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const files = ref([])
|
||||
const fileInput = ref(null)
|
||||
const fileNote = ref('')
|
||||
const fileUploading = ref(false)
|
||||
const fileError = ref('')
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const response = await printedpartsApi.listFiles(route.params.id)
|
||||
files.value = response.data.data || []
|
||||
} catch (filesError) {
|
||||
console.error('Error loading files:', filesError)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRevision() {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (!file) { fileError.value = 'Choose a file first'; return }
|
||||
fileUploading.value = true
|
||||
fileError.value = ''
|
||||
try {
|
||||
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
|
||||
fileNote.value = ''
|
||||
fileInput.value.value = ''
|
||||
await loadFiles()
|
||||
} catch (uploadError) {
|
||||
fileError.value =
|
||||
uploadError.response?.data?.data?.error?.message || 'Upload failed'
|
||||
} finally {
|
||||
fileUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRevision(revision) {
|
||||
if (!window.confirm(
|
||||
`Delete revision ${revision.revision} (${revision.filename})?`)) return
|
||||
try {
|
||||
await printedpartsApi.removeFile(revision.fileid)
|
||||
await loadFiles()
|
||||
} catch (removeError) {
|
||||
fileError.value = 'Delete failed'
|
||||
console.error(removeError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const ledgerOpen = ref(false)
|
||||
const ledgerMode = ref('restock')
|
||||
const ledgerQuantity = ref(null)
|
||||
const ledgerReason = ref('')
|
||||
const ledgerBadge = ref('')
|
||||
const ledgerSaving = ref(false)
|
||||
const ledgerError = ref('')
|
||||
|
||||
function openLedger(mode) {
|
||||
ledgerMode.value = mode
|
||||
ledgerQuantity.value = null
|
||||
ledgerReason.value = ''
|
||||
ledgerBadge.value = ''
|
||||
ledgerError.value = ''
|
||||
ledgerOpen.value = true
|
||||
}
|
||||
|
||||
async function submitLedger() {
|
||||
ledgerSaving.value = true
|
||||
ledgerError.value = ''
|
||||
try {
|
||||
if (ledgerMode.value === 'restock') {
|
||||
await printedpartsApi.restock(item.value.printeditemid, {
|
||||
quantity: ledgerQuantity.value, badge: ledgerBadge.value
|
||||
})
|
||||
} else {
|
||||
await printedpartsApi.adjust(item.value.printeditemid, {
|
||||
quantitychange: ledgerQuantity.value,
|
||||
reason: ledgerReason.value,
|
||||
badge: ledgerBadge.value
|
||||
})
|
||||
}
|
||||
ledgerOpen.value = false
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (submitError) {
|
||||
ledgerError.value =
|
||||
submitError.response?.data?.data?.error?.message ||
|
||||
submitError.response?.data?.error?.message || 'Submit failed'
|
||||
} finally {
|
||||
ledgerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retireItem() {
|
||||
if (!window.confirm(
|
||||
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
|
||||
+ 'history and the bin label stay, and it can be restored later.')) return
|
||||
try {
|
||||
await printedpartsApi.remove(item.value.printeditemid)
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (retireError) {
|
||||
console.error('Retire failed:', retireError)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreItem() {
|
||||
try {
|
||||
const response = await printedpartsApi.restore(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (restoreError) {
|
||||
console.error('Restore failed:', restoreError)
|
||||
}
|
||||
}
|
||||
|
||||
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); }
|
||||
.file-upload-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.current-revision td { font-weight: 600; }
|
||||
.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>
|
||||
156
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
156
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<div class="header-actions">
|
||||
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
|
||||
Print Labels
|
||||
</router-link>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search code, name, description, bin..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
|
||||
Include retired
|
||||
</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 }}
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</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 includeRetired = 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'
|
||||
if (includeRetired.value) params.active = 'false'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printed parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-cell { width: 3rem; }
|
||||
.truncate-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -57,6 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
@@ -126,28 +127,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="printer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
||||
<AssetRelationships v-if="printer.assetid" :assetId="printer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location -->
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Map Location</span>
|
||||
@@ -170,20 +172,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="printer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
||||
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,6 +262,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid">
|
||||
<tr v-for="printer in printers" :key="printer.printer?.printerid || printer.assetid" class="clickable-row" @click="$router.push(`/printers/${printer.printer?.printerid || printer.assetid}`)">
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : (printer.printer?.hostname || '-') }}</td>
|
||||
<td>{{ printer.businessunitname || '-' }}</td>
|
||||
@@ -55,7 +55,7 @@
|
||||
{{ printer.statusname || 'Active' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/printers/${printer.printer?.printerid || printer.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@@ -90,13 +90,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const printers = ref([])
|
||||
const printerTypes = ref([])
|
||||
const typeFilter = ref('')
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -135,24 +135,24 @@ async function loadPrinters() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadPrinters()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadPrinters()
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -103,11 +103,12 @@ import { businessunitsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const { page, setPage } = useListQuery({ onChange: loadData })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -136,11 +137,11 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
function goToPage(p) { setPage(p); loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadData()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<th>Type</th>
|
||||
<th>On Detail</th>
|
||||
<th>On Form</th>
|
||||
<th>Search</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
@@ -47,6 +48,7 @@
|
||||
</td>
|
||||
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.showonform ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.searchable ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.sortorder }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
|
||||
@@ -57,7 +59,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleFields.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -92,6 +94,10 @@
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.searchable" /> Searchable</label>
|
||||
<span class="hint">Include this field's values in global search.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Sort Order</label>
|
||||
@@ -131,7 +137,7 @@ const error = ref('')
|
||||
const form = ref(blankForm())
|
||||
|
||||
function blankForm() {
|
||||
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true }
|
||||
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, searchable: false, sortorder: 0, isactive: true }
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
@@ -174,6 +180,7 @@ function openModal(item = null) {
|
||||
options: (item.options || []).join('\n'),
|
||||
showondetail: item.showondetail !== false,
|
||||
showonform: item.showonform !== false,
|
||||
searchable: item.searchable === true,
|
||||
sortorder: item.sortorder || 0,
|
||||
isactive: item.isactive !== false,
|
||||
}
|
||||
|
||||
@@ -213,14 +213,14 @@ import { locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const locations = ref([])
|
||||
const locationTypes = ref([])
|
||||
const allLocations = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadLocations })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -286,19 +286,19 @@ async function loadLocations() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadLocations()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadLocations()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadLocations()
|
||||
}
|
||||
|
||||
|
||||
@@ -147,11 +147,12 @@ import { modeltypesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const modelTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const { page, setPage } = useListQuery({ onChange: loadTypes })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -192,13 +193,13 @@ async function loadTypes() {
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadTypes()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadTypes()
|
||||
}
|
||||
|
||||
|
||||
@@ -209,15 +209,15 @@ import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const models = ref([])
|
||||
const vendors = ref([])
|
||||
const modelTypes = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadModels })
|
||||
const vendorFilter = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -290,19 +290,19 @@ async function loadModelTypes() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadModels()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadModels()
|
||||
}
|
||||
|
||||
|
||||
@@ -121,11 +121,12 @@ import { operatingsystemsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const { page, setPage } = useListQuery({ onChange: loadData })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -154,11 +155,11 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p) { page.value = p; loadData() }
|
||||
function goToPage(p) { setPage(p); loadData() }
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadData()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Collector PC Types</h2>
|
||||
<h2>Collector PC Types (retired)</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
When the collector ingests a PC, its imaging pc-type (from
|
||||
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
|
||||
Adjust the mapping per site.
|
||||
This page has been retired. Imaging pc-type handling now lives in
|
||||
GE-Enforce, where each imaging PC type is a manifest scope with its own
|
||||
Computer Type. Manage it from the GE-Enforce section instead.
|
||||
</p>
|
||||
|
||||
<div class="table-container" v-if="pcTypeMappings.length">
|
||||
<table class="identifier-matrix">
|
||||
<thead>
|
||||
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
|
||||
<td class="identifier-name">{{ row.pxetype }}</td>
|
||||
<td>
|
||||
<select
|
||||
:value="row.computertype"
|
||||
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="setting-description">No collector pc-type mappings are configured yet.</p>
|
||||
<p class="setting-description">
|
||||
The collector still applies the built-in pc-type to Computer Type
|
||||
defaults, so existing enrollment keeps working; there is nothing to
|
||||
configure here anymore.
|
||||
</p>
|
||||
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success, pcTypeMappings, computerTypes,
|
||||
loadSettings, loadComputerTypes, changePcTypeMapping,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadComputerTypes()
|
||||
})
|
||||
// Deprecated page. Kept only so the /settings/pctypemapping route and old
|
||||
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
|
||||
// collector pc-type mapping UI. See ADR-012.
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in plugins" :key="p.name">
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.displayname || p.name }}</td>
|
||||
<td class="mono">{{ p.version }}</td>
|
||||
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
||||
<td>
|
||||
|
||||
177
frontend/src/views/settings/PrintedPartsSettings.vue
Normal file
177
frontend/src/views/settings/PrintedPartsSettings.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Item code prefix</label>
|
||||
<input v-model="values.printedparts_code_prefix" type="text"
|
||||
class="form-control" maxlength="8" />
|
||||
<p class="field-hint">
|
||||
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
|
||||
Changing it does not rename existing items.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Default low-stock threshold</label>
|
||||
<input v-model.number="values.printedparts_default_threshold"
|
||||
type="number" min="0" class="form-control" />
|
||||
<p class="field-hint">Seed value for new items; each item can override.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
<option value="deny">Deny - refuse badges with no directory match</option>
|
||||
<option value="allow">Allow - record the SSO with no name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-stock alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-stock alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printedparts_alert_email" type="text"
|
||||
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email). Alerts fire once when an item
|
||||
crosses its threshold; restocking above re-arms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
'printedparts_alert_roleids'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
printedparts_alert_roleids: ''
|
||||
})
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printedparts' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
values.value.printedparts_default_threshold =
|
||||
parseInt(values.value.printedparts_default_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printedparts_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
Pick a section from the left, or choose one below.
|
||||
</p>
|
||||
|
||||
<section v-for="group in groups" :key="group.title" class="landing-section">
|
||||
<section v-for="group in visibleGroups" :key="group.title" class="landing-section">
|
||||
<h2 class="section-heading">{{ group.title }}</h2>
|
||||
<div class="landing-grid">
|
||||
<router-link
|
||||
@@ -25,10 +25,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare section heading never renders.
|
||||
const visibleGroups = computed(() => groups.value.filter(g => g.cards.length))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,17 +10,27 @@
|
||||
|
||||
<nav class="rail-nav">
|
||||
<template v-for="group in visibleGroups" :key="group.title">
|
||||
<div class="rail-group-heading">{{ group.title }}</div>
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="rail-link"
|
||||
:class="{ active: isActive(card.to) }"
|
||||
<button
|
||||
type="button"
|
||||
class="rail-group-heading"
|
||||
:class="{ collapsed: !isExpanded(group.title) }"
|
||||
@click="toggleGroup(group.title)"
|
||||
>
|
||||
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
|
||||
<span class="rail-label">{{ card.title }}</span>
|
||||
</router-link>
|
||||
<span class="rail-chevron" aria-hidden="true"></span>
|
||||
{{ group.title }}
|
||||
</button>
|
||||
<template v-if="isExpanded(group.title)">
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="rail-link"
|
||||
:class="{ active: isActive(card.to) }"
|
||||
>
|
||||
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
|
||||
<span class="rail-label">{{ card.title }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
|
||||
</nav>
|
||||
@@ -48,7 +58,9 @@ const search = ref('')
|
||||
// Filter the rail by title/description; drop groups that end up empty.
|
||||
const visibleGroups = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return groups.value
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare heading never renders.
|
||||
if (!term) return groups.value.filter(g => g.cards.length)
|
||||
return groups.value
|
||||
.map(g => ({
|
||||
title: g.title,
|
||||
@@ -65,6 +77,25 @@ function isActive(to) {
|
||||
const base = to.split('?')[0]
|
||||
return route.path === base
|
||||
}
|
||||
|
||||
// Collapsible groups so the 13-section rail fits without scrolling: only the
|
||||
// group containing the current page is open; the user can toggle others.
|
||||
// Searching forces every matching group open.
|
||||
const groupOfActive = computed(() => {
|
||||
for (const g of groups.value) {
|
||||
if (g.cards.some(c => isActive(c.to))) return g.title
|
||||
}
|
||||
return groups.value[0]?.title
|
||||
})
|
||||
const manualToggles = ref({})
|
||||
function toggleGroup(title) {
|
||||
manualToggles.value = { ...manualToggles.value, [title]: !isExpanded(title) }
|
||||
}
|
||||
function isExpanded(title) {
|
||||
if (search.value.trim()) return true
|
||||
if (title in manualToggles.value) return manualToggles.value[title]
|
||||
return title === groupOfActive.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -82,11 +113,11 @@ function isActive(to) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.rail-search {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
@@ -99,26 +130,52 @@ function isActive(to) {
|
||||
}
|
||||
|
||||
.rail-group-heading {
|
||||
margin: 1rem 0 0.3rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
margin: 0.35rem 0 0.15rem 0;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.rail-group-heading:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.rail-group-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* CSS-drawn caret (ASCII-only source): a right-pointing triangle that
|
||||
rotates down when the group is expanded. */
|
||||
.rail-chevron {
|
||||
width: 0;
|
||||
height: 0;
|
||||
flex: 0 0 auto;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid currentColor;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.rail-group-heading:not(.collapsed) .rail-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rail-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.86rem;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.rail-link:hover {
|
||||
|
||||
@@ -141,11 +141,12 @@ import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const statuses = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const { page, setPage } = useListQuery({ onChange: loadStatuses })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -186,13 +187,13 @@ async function loadStatuses() {
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadStatuses()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadStatuses()
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,7 @@ import { networkApi, locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -295,10 +296,9 @@ const subnets = ref([])
|
||||
const vlans = ref([])
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadSubnets })
|
||||
const vlanFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -383,19 +383,19 @@ async function loadSubnets() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadSubnets()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadSubnets()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadSubnets()
|
||||
}
|
||||
|
||||
|
||||
@@ -189,13 +189,13 @@ import { networkApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const vlans = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVLANs })
|
||||
const typeFilter = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -243,19 +243,19 @@ async function loadVLANs() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadVLANs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadVLANs()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadVLANs()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,15 @@ export const settingsGroups = [
|
||||
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
|
||||
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
|
||||
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
||||
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
|
||||
// devices all run an OS), so it lives in General Reference, not under PCs.
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PCs',
|
||||
cards: [
|
||||
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
|
||||
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||
],
|
||||
},
|
||||
@@ -50,9 +52,16 @@ export const settingsGroups = [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" group is contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog),
|
||||
// not hardcoded here.
|
||||
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||
// This empty placeholder fixes the group's position among the asset groups
|
||||
// (right after Machines); the merger fills it in place instead of appending a
|
||||
// new group at the end. When the plugin is disabled the group stays empty and
|
||||
// the rail/landing drop it (empty groups never render).
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [],
|
||||
},
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
@@ -83,7 +92,6 @@ export const settingsGroups = [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
{ to: '/settings/pctypemapping', icon: Laptop, title: 'Collector PC Types', description: 'Map collector enrollment imaging pc-type (shopfloor) to a Computer Type' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -149,12 +149,12 @@ import EmployeeSearch from '../../components/EmployeeSearch.vue'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const devices = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
const showAvailableOnly = ref(false)
|
||||
@@ -195,19 +195,19 @@ async function loadDevices() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadDevices()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
|
||||
10
frontend/src/views/vendors/VendorsList.vue
vendored
10
frontend/src/views/vendors/VendorsList.vue
vendored
@@ -188,12 +188,12 @@ import { vendorsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
const toast = useToast()
|
||||
|
||||
const vendors = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -243,19 +243,19 @@ async function loadVendors() {
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
setSearch(search.value)
|
||||
loadVendors()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
setPage(p)
|
||||
loadVendors()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
setPage(1)
|
||||
loadVendors()
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
|
||||
<td>
|
||||
<span v-if="!w.assets.length" class="muted">-</span>
|
||||
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
|
||||
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip" :title="a.name || a.assetnumber">{{ a.assetnumber }}</router-link>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
|
||||
@@ -82,16 +82,21 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Vendor *</label>
|
||||
<input v-model="form.vendor" type="text" class="form-control" maxlength="100" required />
|
||||
<select v-model="form.vendor" class="form-control" required>
|
||||
<option value="" disabled>Select a vendor...</option>
|
||||
<option v-for="name in vendorOptions" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
<small class="muted">Who backs the warranty. Manage the list under Vendors.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<label>Lookup source</label>
|
||||
<select v-model="form.provider" class="form-control">
|
||||
<option value="manual">Manual</option>
|
||||
<option value="dell">Dell</option>
|
||||
<option value="lenovo">Lenovo</option>
|
||||
<option value="hp">HP</option>
|
||||
<option value="manual">Manual entry</option>
|
||||
<option value="dell">Dell (auto-refresh)</option>
|
||||
<option value="lenovo">Lenovo (auto-refresh)</option>
|
||||
<option value="hp">HP (auto-refresh)</option>
|
||||
</select>
|
||||
<small class="muted">Where coverage data comes from. Non-manual sources can be refreshed from the maker's warranty API.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -155,7 +160,7 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { warrantyApi, assetsApi } from '../../api'
|
||||
import { warrantyApi, assetsApi, vendorsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
@@ -196,6 +201,15 @@ const form = ref(blankForm())
|
||||
const selectedAssets = ref([])
|
||||
const assetQuery = ref('')
|
||||
const assetResults = ref([])
|
||||
// Vendor dropdown options, pulled from the site vendor catalog.
|
||||
const vendorNames = ref([])
|
||||
// Keep an existing warranty's vendor selectable even if it is not (or no longer)
|
||||
// in the catalog, so editing never silently blanks it.
|
||||
const vendorOptions = computed(() => {
|
||||
const current = (form.value.vendor || '').trim()
|
||||
if (current && !vendorNames.value.includes(current)) return [current, ...vendorNames.value]
|
||||
return vendorNames.value
|
||||
})
|
||||
|
||||
function blankForm() {
|
||||
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
|
||||
@@ -215,6 +229,7 @@ function assetLink(a) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
loadVendors()
|
||||
// Deep-link from an asset detail page: open the add modal pre-linked to it.
|
||||
const addfor = route.query.addfor
|
||||
if (addfor) {
|
||||
@@ -229,6 +244,18 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadVendors() {
|
||||
// Best-effort: the combobox still accepts free text if this fails.
|
||||
try {
|
||||
const response = await vendorsApi.list({ per_page: 1000 })
|
||||
const rows = response.data.data
|
||||
const list = Array.isArray(rows) ? rows : (rows.items || [])
|
||||
vendorNames.value = list.map(v => v.vendor || v.name).filter(Boolean).sort()
|
||||
} catch (err) {
|
||||
vendorNames.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -350,6 +377,9 @@ async function refresh(w) {
|
||||
.muted { color: var(--text-light); }
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
|
||||
.filters { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
/* Keep the "Status" label + its select on one line so it aligns with the
|
||||
single-line search box next to it. */
|
||||
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.filters .form-control { max-width: 320px; }
|
||||
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -3,6 +3,10 @@ import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
// Mount path of the built app. Default '/' (own IIS site / dev). Set
|
||||
// VITE_BASE_PATH='/ops/' (trailing slash) to build for a subpath mount under
|
||||
// Default Web Site, e.g. `VITE_BASE_PATH=/ops/ npm run build`.
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
19
frontend/vitest.config.js
Normal file
19
frontend/vitest.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
// Standalone vitest config so the unit runner stays independent of vite.config.js.
|
||||
// jsdom gives component specs a DOM; the '@' alias mirrors the app build.
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.spec.js'],
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user