From dc9fe5834e06494a94ccdb8c1eb7f2fe553cb7fe Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 11 Jul 2026 14:16:59 -0400 Subject: [PATCH] Draft ADR-010 frontend hook contract; relabel Equipment to Machines ADR-010 (PROPOSED): four data-only frontend hooks following the get_reports precedent - get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation - batched as a future contract 0.7.0. Warranty proves asset panels first, measuringtools the rest; accept only after both run on the hooks. Display-label swap Equipment -> Machines across nav, list, form, detail, search, map editor, and settings copy (incl the API-failure fallback nav). Identifiers unchanged: plugin name, tables, /api/equipment, asset type, and routes all stay. Co-Authored-By: Claude Fable 5 --- docs/CONTRACT-STABILITY.md | 2 +- docs/adr/ADR-010-frontend-plugin-hooks.md | 319 ++++++++++++++++++ docs/adr/README.md | 1 + frontend/src/views/AppLayout.vue | 2 +- frontend/src/views/MapEditor.vue | 2 +- frontend/src/views/SearchResults.vue | 6 +- frontend/src/views/machines/MachineDetail.vue | 6 +- frontend/src/views/machines/MachineForm.vue | 6 +- frontend/src/views/machines/MachinesList.vue | 6 +- frontend/src/views/settings/settingsNav.js | 4 +- plugins/equipment/plugin.py | 4 +- 11 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 docs/adr/ADR-010-frontend-plugin-hooks.md diff --git a/docs/CONTRACT-STABILITY.md b/docs/CONTRACT-STABILITY.md index d624d98..111da12 100644 --- a/docs/CONTRACT-STABILITY.md +++ b/docs/CONTRACT-STABILITY.md @@ -65,7 +65,7 @@ Known-unstable areas. Building on these means expecting rework. | Area | Status | Reference | |------|--------|-----------| -| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. | project charter / this doc | +| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) proposes the path: data-only declarative hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`) rendered by generic core components, with build-time glob discovery deferred for real components. | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (PROPOSED) | | Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) | | Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) | diff --git a/docs/adr/ADR-010-frontend-plugin-hooks.md b/docs/adr/ADR-010-frontend-plugin-hooks.md new file mode 100644 index 0000000..a8e60f3 --- /dev/null +++ b/docs/adr/ADR-010-frontend-plugin-hooks.md @@ -0,0 +1,319 @@ +# ADR-010: Frontend plugin hook contract + +- **Status:** PROPOSED +- **Date:** 2026-07-11 +- **Deciders:** cproudlock +- **Supersedes:** none + +## Context + +The backend plugin contract is settled (ADR-002 surface, ADR-006 collector, +ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is +the frontend: how a plugin contributes UI without hand-editing core Vue files. +CONTRACT-STABILITY.md names this the single biggest churn item ("no server-side +hook for asset-detail panels, map markers, or search-result rendering; a plugin +that needs custom UI still hand-edits the Vue frontend"). + +The `measuringtools` plugin (bundled 2026-07-11, whose construction is narrated +in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly +**two hand edits to core frontend files**: + +1. `frontend/src/api/index.js` - an api-client block appended after + `warrantyApi` (PLUGIN-GUIDE.md section 9). The scaffolder now emits a + paste-in snippet, but it is still a hand edit to a shared, churn-heavy file. +2. `frontend/src/views/settings/settingsNav.js` - a card entry in the hardcoded + `settingsGroups` catalog (the "Measuring Tools" group, ~line 51), consumed by + `SettingsLayout` (left rail) and `SettingsIndex` (landing overview). + +Everything else integrated with **zero** core edits, because a mechanism already +existed for it: + +| Capability | Existing mechanism | +|---|---| +| Views / routes | Auto-discovery: `frontend/src/router/index.js` globs `router/routes/*.js` via `import.meta.glob`; a route starting `settings/` auto-nests under the settings shell. | +| Disabled-plugin gating | `meta: { plugin: '' }` per ADR-009; router guard redirects when the backend plugin is off. | +| Sidebar / dashboard / reports | Declarative backend hooks rendered by core: `get_navigation_items`, `get_dashboard_widgets`, `get_reports` on `BasePlugin` (shopdb/plugins/base.py), merged by `shopdb/core/api/dashboard.py` and `shopdb/core/api/reports.py`. | +| Map placement | Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code. | + +Capabilities a plugin **cannot have at all today** - no hook exists, and adding +one would mean forking a core view: + +3. **Asset-detail extension panels.** A warranty-coverage section on a PC or + printer detail page, a calibration-status card on a measuring tool. Today + `WarrantyPanel.vue` is composed in by hand-editing each detail view. +4. **Map marker / overlay contributions.** A calibration-due badge on the + shop-floor map (`frontend/src/views/map/MapView.vue`). The map draws type + colors but has no plugin decoration path. +5. **Search-result rendering / routing for plugin asset types.** Global search + returns assets, but core hardcodes how each type renders and where its detail + link points; a plugin asset type has no way to declare its icon or route. + +There is also an unsolved distribution wrinkle from ADR-003: external plugins +symlink into `plugins//` backend-only, but any frontend file they carry +must be physically copied into `frontend/src/`, because Vite compiles the tree +at build time and cannot reach outside it. + +## Options considered + +### A. Runtime dynamic component registration + +Plugins ship real Vue components that core loads and mounts at runtime into +named extension points (an `iconMap` registration, a panel registry, a marker +renderer registry). This is the richest model and the one ADR-009's "Future +direction" sketched. + +- Pro: a plugin can render anything; no core generic-renderer ceiling. +- Con: requires runtime module loading of plugin-authored code (dynamic import + of built chunks), a versioned shared-component surface that becomes an + accidental contract the moment it leaks, and it does nothing about the + build-time-only reach of Vite for external plugins. It is the most code for + the least near-term payoff. ADR-009 already priced this and deferred it. + +### B. Declarative data-only hooks rendered by core generic components + +Plugins return plain dicts from new `BasePlugin` hooks; a generic core component +renders them. This is the exact precedent already proven three times: +`get_navigation_items`, `get_dashboard_widgets`, and `get_reports` (0.6.0) all +return dicts that a core consumer endpoint merges and a core Vue component +renders. No plugin ships frontend code. + +- Pro: additive, minor-bump changes under ADR-002; identical access pattern to + the existing consumers (skip disabled, fail-loud in dev, isolate in prod); + works untouched for external symlink-only plugins, because the data crosses + the wire and core owns the renderer. +- Con: bounded to what a generic renderer can draw. A panel that needs a bespoke + chart or a custom-interaction map overlay does not fit. + +### C. Build-time file-convention discovery + +Extend the `import.meta.glob` precedent from `./routes/*.js` to a plugin-owned +frontend tree (`plugins/*/frontend/`), so a plugin's real components (full +list/detail/form views, its api-client module) live with the plugin and the +build picks them up by convention. This is ADR-009 "Future direction" steps 1-2. + +- Pro: true self-containment for genuine components; the natural home for the + api-client block (friction 1) and full views. +- Con: significant build-system work (per-plugin Vite entry discovery, + code-splitting, dev-server HMR across the tree) and it does not by itself + solve the external-plugin wrinkle - a symlinked out-of-tree `frontend/` is + still outside Vite's compiled root. Only pays off once out-of-tree plugins + with frontends are a real requirement. + +### Hybrid + +The evidence splits cleanly. The five friction points fall into two buckets: +surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5), +and surfaces where a real component is genuinely unavoidable (full views, and +the api-client module in friction 1). Option B fits the first bucket exactly and +is cheap and additive. Option C is the right long-term answer for the second but +is expensive and, per ADR-009, gated on external-plugin demand. Option A buys +nothing B does not, at the highest cost. The decision is B now, C deferred, A +not pursued. + +## Decision + +**PROPOSED:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for +the four presentation surfaces a generic core renderer can serve, and keep +**file-convention glob discovery** (Option C) as the deferred mechanism for the +residual cases where a real component is unavoidable. Do not pursue runtime +component registration (Option A). + +### New hooks (Option B, data-only, additive) + +Each is a new optional `BasePlugin` method returning a list of dicts, merged by +a core consumer endpoint using the same pattern as `get_reports` (inject +`plugin` name, skip disabled, re-raise in dev/test, log-and-isolate in prod), +and rendered by a generic core component. Icon values are string keys mapped to +Lucide components core-side, exactly like `get_navigation_items`. + +**`get_settings_cards`** - resolves friction point 2 (settingsNav.js hand edit). + +``` +{ + 'group': 'Measuring Tools', # rail group title (created if new) + 'to': '/settings/measuringtooltypes', + 'icon': 'ruler', # string key, mapped core-side + 'title': 'Measuring Tool Types', + 'description': 'Manage measuring-tool subtypes + map colors', + 'position': 22, # order within the group +} +``` + +Consumer: a new `GET /api/settings/cards` merges enabled plugins' cards into the +core `settingsGroups` catalog; `SettingsLayout` and `SettingsIndex` read the +merged catalog instead of the hardcoded JS array. The catalog's own core groups +stay in `settingsNav.js`; plugin groups are appended. + +**`get_asset_panels`** - resolves friction point 3 (asset-detail panels). + +``` +{ + 'id': 'calibration', + 'title': 'Calibration', + 'assettypes': ['measuring_tool'], # detail pages it appears on; ['*'] = all + 'endpoint': '/api/measuringtools/{assetid}/calibration-panel', + 'render': 'keyvalue', # 'keyvalue' | 'table' | 'badge' + 'position': 20, +} +``` + +Consumer: `GET /api/assets/{assetid}/panels` returns the panels whose +`assettypes` match that asset's type; a generic `AssetPanel` component on the +detail page fetches each `endpoint` and renders it in the declared style. This +covers the warranty-coverage and calibration-status cases. A panel that needs +bespoke UI (a chart) is out of scope for the data-only hook and falls to the +deferred component mechanism below - stated honestly, not hidden. + +**`get_map_overlays`** - resolves friction point 4 (map marker/overlay badges). + +``` +{ + 'id': 'calibration-due', + 'label': 'Calibration due', # legend label + 'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}] + 'style': 'badge', # 'badge' | 'ring' + 'legend': True, +} +``` + +Consumer: `MapView.vue` fetches enabled plugins' overlay endpoints and decorates +the already-placed markers; legend entries append to the existing legend. The +map stays data-driven; plugins add decoration data, not map code. + +**`get_asset_presentation`** - resolves friction point 5 (search rendering / +routing) and, as a bonus, removes the `AppLayout.vue` iconMap hand edit called +out in PLUGIN-GUIDE.md section 9. + +``` +{ + 'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns + 'icon': 'ruler', + 'label': 'Measuring Tool', + 'route': '/measuringtools/{assetid}', # detail-route pattern +} +``` + +Consumer: `GET /api/assets/presentation` returns the type-to-presentation map; +global-search result rows and any asset cross-link use it to pick the icon and +build the detail link, so core never hardcodes a plugin's route or icon. + +### Friction map + +| Friction point | Mechanism | Hook / change | +|---|---|---| +| 1. api-client block in api/index.js | C (deferred) | plugin-owned `frontend/` tree, glob-discovered; scaffolder snippet is the near-term mitigation | +| 2. settingsNav.js card entry | B (now) | `get_settings_cards` | +| 3. asset-detail panels | B (now) | `get_asset_panels` (bespoke panels -> C, deferred) | +| 4. map markers / overlays | B (now) | `get_map_overlays` (bespoke overlays -> C, deferred) | +| 5. search-result rendering / routing | B (now) | `get_asset_presentation` | +| views (full list/detail/form) | C (deferred) | already glob-discovered under core `routes/*.js`; long-term move to `plugins/*/frontend/` per ADR-009 | + +### Deferred: file-convention frontend tree (Option C) + +Extending `import.meta.glob` from `./routes/*.js` to `plugins/*/frontend/` (so +views and the api-client module live with the plugin) is ADR-009 "Future +direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is +build-system-heavy and the payoff only lands once out-of-tree plugins with their +own frontends are a real requirement. Until then, friction 1 stays mitigated by +the scaffolder snippet, and full views keep shipping in the core bundle and +gated by ADR-009 `meta.plugin`. + +This is also the honest limit on the external-plugin wrinkle. The four data-only +hooks need **zero** frontend files from a plugin, so an external symlink-only +(backend) plugin gets settings cards, detail panels, map overlays, and search +presentation with no copy-into-`frontend/src/` step at all. The wrinkle survives +only for the residual component-backed cases, which is exactly the deferred +Option C work; pushing external-repo frontend distribution to that later ADR +matches ADR-003's posture of deferring out-of-tree packaging until two sites run +their own plugins. + +### Contract-version impact (ADR-002) + +Each of the four hooks is a new optional `BasePlugin` method - an additive +change, so a **minor** bump per ADR-002, the same classification `get_reports` +took at 0.6.0. Landing all four together is a single minor bump (proposed +**0.7.0**); landing them incrementally is one minor bump each. Adding the core +consumer endpoints and the generic renderer components is core-internal and does +not itself move `__contract_version__`. The deferred Option C introduces a +separate, versioned **frontend** contract (ADR-009 step 4), tracked apart from +the backend `__contract_version__`; it is not part of this proposal's bumps. + +## Consequences + +### Positive + +- Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a + generic renderer can serve, using the already-proven declarative pattern - low + risk, low cost, additive-only. +- External symlink-only plugins get four presentation surfaces with no + copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle. +- Removes three of the two-plus hand edits the exemplar needed (settingsNav + card, plus the AppLayout iconMap edit from section 9), moving them to data. +- Docs-drift guard forces documentation: `tests/test_docs_contract.py` fails if + any new public `BasePlugin` hook is missing from `docs/PLUGIN-HOOKS.md`, so the + hooks cannot ship undocumented. + +### Negative + +- Four more hooks and four more core consumer endpoints to maintain, each with + the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline. +- The generic renderers set a ceiling: bespoke panels and overlays still have no + home until Option C lands, so the contract is honest-but-partial, not total. +- One extra request per surface on the pages that use it (a detail page fetches + its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch. + +### Neutral + +- Bundled plugins keep their current in-core views; migrating them to the hooks + is opt-in and incremental (see Adoption plan), not a flag-day rewrite. +- `meta.plugin` route gating (ADR-009) is unchanged and still gates the views; + these hooks add presentation, not routing. +- Contract tests (`tests/test_plugin_contract.py`) already assert every public + hook is exercised; the new hooks slot into that harness. + +## Adoption plan + +Prove the contract on bundled plugins before declaring it settled for sister +sites. + +1. **`get_asset_panels` first, via `warranty`.** Warranty is asset-general and + already composes `WarrantyPanel.vue` onto multiple detail pages by hand, with + a clean per-asset endpoint behind it. Migrating it to a declarative + `get_asset_panels` entry rendered by the generic `AssetPanel` is the + lowest-risk proof and immediately removes hand edits from every detail view + that shows warranty. This is the recommended first migration. +2. **`get_settings_cards`, `get_map_overlays`, `get_asset_presentation` via + `measuringtools`.** The guide exemplar already needs a settings card, a + calibration-due map badge, and search routing for its `measuring_tool` type, + so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes + the reference for the new hooks. +3. Only after both plugins run on the hooks in a real build: bump + `__contract_version__` to 0.7.0, document the hooks in + `docs/PLUGIN-HOOKS.md`, and mark this ADR ACCEPTED. + +## Open questions + +- Should `get_asset_panels` `render` styles stay a small closed set + (`keyvalue` / `table` / `badge`), or grow? A closed set keeps the renderer + generic; growth pressure is the signal that a case actually needs Option C. +- Should the four consumer endpoints collapse into one bundled + `GET /api/plugins/frontend-contributions` call to save round-trips, or stay + separate per surface for cache locality? Defer until the per-surface request + cost is measured. +- When Option C lands, does the api-client module move under + `plugins/*/frontend/` or get replaced entirely by a generated client from the + backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract + follow-up. + +## References + +- ADR-001 (asset model as the map/search data source) +- ADR-002 (bump classification for the new hooks) +- ADR-003 (external-plugin distribution posture) +- ADR-009 (route gating; "Future direction" is the deferred Option C) +- docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits) +- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers) +- shopdb/plugins/base.py (existing declarative hooks this pattern extends) +- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent) + + diff --git a/docs/adr/README.md b/docs/adr/README.md index e465235..f690596 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ Each ADR captures a single architectural decision: the context, the decision its | [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED | | [008](ADR-008-plugin-migration-ownership.md) | Plugin migration ownership (per-plugin chains) | ACCEPTED | | [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED | +| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | PROPOSED | ## Authoring diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index c8c2544..313d28c 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -136,7 +136,7 @@ const defaultNav = [ { name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 }, { name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 }, { name: 'Map', icon: 'map', route: '/map', position: 4 }, - { name: 'Equipment', icon: 'cog', route: '/machines', position: 10 }, + { name: 'Machines', icon: 'cog', route: '/machines', position: 10 }, { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 }, { name: 'Network', icon: 'network-wired', route: '/network', position: 18 }, { name: 'Printers', icon: 'printer', route: '/printers', position: 20 }, diff --git a/frontend/src/views/MapEditor.vue b/frontend/src/views/MapEditor.vue index cc9edf2..e4c452f 100644 --- a/frontend/src/views/MapEditor.vue +++ b/frontend/src/views/MapEditor.vue @@ -14,7 +14,7 @@

Assets