Files
shopdb-flask/docs/adr/ADR-010-frontend-plugin-hooks.md
cproudlock 4995456136 docs: take one site's name, hosts and paths off the public wiki
The publishability gate caught internal tooling names and developer paths but
nothing site-specific, so roughly sixty leaks reached the wiki: the site name in
ten documents, real fleet hostnames in the collector and GE-Enforce examples, an
internal database name through the whole import guide, imaging-share paths, and
a maintainer's username as the Deciders line of every ADR and inside a generated
curl example.

None of it is a security matter on an air-gapped fleet. It matters because these
pages are read by engineers at other plants, and a document that names one site
throughout reads as that site's notes rather than a product's documentation -
which is exactly what it then gets treated as.

Examples now use neutral hostnames, the site is "the reference site" where the
distinction carries meaning, and ADRs are decided by "ShopDB maintainers". The
gate carries all of these patterns, so the next one fails a build.

Two documents leave docs/ because they were never written for an outside reader.
PROJECT-REVIEW.md is an internal health memo pinned to a commit from July, whose
headline finding (an untracked playbook) has since been fixed - it is history,
and git holds it. PILOT-DEPLOY.md is one site's own cutover runbook, complete
with a "re-measure before publishing" placeholder; it moves next to the loader
it belongs to, in scripts/site_imports/wjf/.

ADR-015 is AMENDED rather than rewritten. Its enforcement section still said
report-only and its backlog still listed hardcodes that are now cleared, which
left the record contradicting itself. The amendment says what changed and why
the report-only period ended; the original text stays, because what the decision
looked like when it was taken is the part worth keeping.

Also corrects llms.txt's response envelope, which had errors at the top level
and pagination at meta.total. Both are nested one deeper, so anything written
against that description read undefined on every error it tried to handle.
2026-08-14 15:38:27 -04:00

319 lines
16 KiB
Markdown

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