# ADR-019: Plugin Dynamic Settings Routes > **Read with the date on it.** This is a point-in-time audit, kept as the > record of what was found and what it turned out to be, not as current > guidance. One thing it says repeatedly is no longer true: it was written when > this line carried no ADRs, and the full ADR set now lives beside it in > [docs/adr](../adr/README.md). Where this page says a decision record is > missing or lives elsewhere, look there first. **Status:** Accepted (2026-09-11) **Context:** Plugin UI extensions, lean builds **Supersedes:** Hardcoded plugin routes in core router **Last checked against code:** 2026-09-18, the deployment branch at 35d4fcd41 This draft lives only in `docs/wiki-drafts/`. There is no `docs/adr/` directory on this branch, so this is the sole ADR-shaped record here and nothing enforces it except the tests named below. See ADR-Conformance-2026-09-18.md. ## Problem Plugins that extend core settings pages (like Operating Systems in Model Catalog) required hardcoded imports in `frontend/src/router/routes/core.js`: ```javascript { path: 'operatingsystems', name: 'model-catalog-os', component: () => import('../../.plugins-staged/computers/views/OperatingSystemsList.vue') } ``` **This broke lean builds** (`ci-lean` profile with only `machines,printers` plugins) because the import referenced a component that didn't exist in the staged plugins. **The deeper issue:** Core code shouldn't know about plugin UI structure. Every plugin that wants a settings tab requires a core code change, violating the plugin architecture principle that plugins are self-contained extensions. ## Decision Plugins declare their settings routes via a `get_settings_routes()` hook that returns route metadata. The frontend fetches these at runtime from `/api/plugins/settings-routes` and dynamically registers them via `router.addRoute()`. ### Backend: BasePlugin Hook Added to `shopdb/plugins/base.py`: ```python def get_settings_routes(self) -> List[Dict]: """Frontend routes this plugin adds to settings pages. Returns a list of route configurations for the plugin's settings pages. Each route is a dict with: hub - settings hub to attach to ('model-catalog', 'asset-config', etc.) path - route path relative to hub (e.g. 'operatingsystems') name - unique route name (e.g. 'model-catalog-os') tab - tab label shown in hub UI (e.g. 'Operating Systems') component - path to Vue component relative to frontend/src/ (e.g. '.plugins-staged/computers/views/OperatingSystemsList.vue') Return [] (default) if the plugin adds no settings pages. """ return [] ``` ### Example: ComputersPlugin ```python class ComputersPlugin(BasePlugin): def get_settings_routes(self) -> List[Dict]: """Register Operating Systems management page in Model Catalog.""" return [{ 'hub': 'model-catalog', 'path': 'operatingsystems', 'name': 'model-catalog-os', 'tab': 'Operating Systems', 'component': '.plugins-staged/computers/views/OperatingSystemsList.vue' }] ``` ### Backend: API Endpoint In `shopdb/core/api/plugins.py:46-95`, as it stands on the deployment branch at 35d4fcd41: ```python @plugins_bp.route('/settings-routes', methods=['GET']) @require_permission('settings.view') def get_plugin_settings_routes(): """Return settings routes contributed by all enabled plugins.""" pm = current_app.extensions.get('plugin_manager') if not pm: return success_response({'routes': []}) routes = [] for plugin_name, plugin in pm.get_all_plugins().items(): # Only include routes from enabled plugins if not pm.registry.is_enabled(plugin_name): continue if not hasattr(plugin, 'get_settings_routes'): continue # One broken plugin must not take every settings hub down with it. try: plugin_routes = plugin.get_settings_routes() except Exception: current_app.logger.exception( 'get_settings_routes failed for plugin %s; skipping it', plugin_name) continue routes.extend(renderable_settings_routes(plugin_name, plugin_routes, current_app.logger)) return success_response({'routes': routes}) ``` Earlier revisions of this draft showed a `@jwt_required(optional=True)` handler iterating `pm.get_enabled_plugin_instances()`. Neither is in the code: `grep -rn get_enabled_plugin_instances shopdb/ plugins/` returns nothing, so that block would not have run. It is replaced above rather than kept, because a copy-paste block that reopens the anonymous read is worse than no block. **Auth:** the endpoint is gated on `settings.view`. It was written anonymous, on the reasoning that unauthenticated pages (kiosks, TV displays) register plugin tabs before login. Nothing does: its only caller is `ModelCatalogHub.vue`, which sits behind `requiresAuth` and `requiresAdmin`. Open, it handed an anonymous caller the enabled plugin list, the settings hub layout and the internal Vue component paths, so it was closed. ### Frontend: Dynamic Route Registration Settings hub pages (e.g., `ModelCatalogHub.vue`) fetch plugin routes at mount and register them dynamically: ```vue ``` **Key behavior:** - Routes registered via `router.addRoute()` persist for the session - Components lazy-load when the tab is clicked (code-splitting preserved) - Tabs only appear if the plugin is enabled and staged ## Constraints as implemented What the sections above describe is the mechanism as designed. What ships is narrower, and a plugin author reading only the hook signature would not find this out until a tab failed to appear in front of a user. The three constraints below are the ones the contract does not express. **One hub draws plugin tabs: Model Catalog.** `router.addRoute` appears exactly once in the frontend, in `frontend/src/views/settings/ModelCatalogHub.vue`. `AssetConfigHub`, `SiteConfigHub`, `NetworkHub`, `SecurityHub`, `LocationsHub`, `IntegrationsHub` and `AssetTypeHub` render their own tabs and nothing else, so a route declaring any of their ids is accepted by the hook, returned by the API and drawn by nobody. Adding a hub to the supported set means writing the fetch-and-register code into that hub's `.vue` file, not editing a list. The supported set is named once, in `SETTINGS_HUBS_WITH_PLUGIN_TABS` (`shopdb/plugins/base.py`); `/api/plugins/settings-routes` drops a route outside it and logs an error naming the plugin and the hub, the plugin manager logs the same finding at startup, and `tests/test_core/test_plugin_settings_hubs.py` fails if a bundled plugin declares an unsupported hub or if the list and the frontend drift apart. **A plugin tab is not deep-linkable.** The hub registers its plugin routes in `onMounted`. Before the hub has mounted, the router has no such route, so navigating straight to a plugin tab URL - from a link, a bookmark, or a refresh while sitting on one - resolves against a router that does not know the path. Only arriving through the parent hub works. This is a real limit on what a plugin settings page can be: it cannot be somewhere you send a person. **The component does not load in a production build at all.** The `component` field is a server-supplied string interpolated into a dynamic import, ``() => import(`../../${tab.component}`)``. Vite refuses that template - it has no static file extension - and says so during every build: [plugin vite:dynamic-import-vars] src/views/settings/ModelCatalogHub.vue: invalid import "../../${tab.component}". A file extension must be included in the static part of the import. The build still succeeds, and the emitted chunk carries the expression verbatim (`import(`../../${e.component}`)`), rewritten to nothing. At runtime the browser resolves it against the chunk's own URL under `/assets/` and asks the server for a `.vue` file; `dist/` contains no `.vue` files, so the request 404s and the tab throws when it is clicked. Under `npm run dev` the same import works, because the dev server serves sources by path. So the mechanism as it stands is a development-only path: a tab that appears in production and fails on the click. Nothing visibly broke because the one plugin using it does not depend on it. `plugins/computers/frontend/routes.js` also registers the same page statically at `/settings/operatingsystems`, and that route - a normal static import, a real URL - is what production actually uses. The dynamic tab is a duplicate way in. Two ways out, and this ADR does not pick one: give the template a static extension (declare `component` without `.vue` and import ``${component}.vue``), which makes Vite glob every `.vue` under `frontend/src` at build time and makes the tab real for staged plugins while staying build-time bound; or accept that plugin settings pages are ordinary plugin routes and drop the hub-tab mechanism. Deciding that is the open question the architectural review raised. **What a plugin author must therefore do:** 1. Declare `hub: 'model-catalog'` or declare nothing. For a settings page that belongs anywhere else, own a full page under the plugin's own routes (`plugins//frontend/routes.js`), which is statically registered, deep- linkable and not subject to any of this. 2. Register the page in `plugins//frontend/routes.js` even when you do declare a tab, and treat that route as the real one. A tab whose component is reachable no other way is a page nobody can open in production. `tests/test_core/test_plugin_settings_hubs.py` enforces this. 3. Point `component` at the staged path - `.plugins-staged//views/X.vue`, relative to `frontend/src` - and keep the source at `plugins//frontend/views/X.vue` so staging finds it. 4. Expect the frontend to be rebuilt when the plugin is added to a site. Enabling the plugin alone does not put its component in the bundle. 5. Do not publish, link to or bookmark the tab's URL. Send people to the hub. ## Consequences ### Benefits **Lean builds work:** A build with `SITE_PLUGINS=machines,printers` doesn't stage the computers plugin, so its component doesn't exist. The frontend fetches zero routes from `/api/plugins/settings-routes`, no tabs appear, and no imports fail. **Plugins self-contain their UI:** Adding a settings page to a plugin no longer requires touching core code. The plugin declares the route; the framework discovers it. **Runtime flexibility:** Enabling/disabling a plugin (via `/api/plugins/{name}` PUT) changes what routes are returned. After an app restart, the tabs appear or disappear automatically - for a plugin whose frontend is in the running bundle. Enabling one that was never staged adds a tab whose component cannot be resolved (see "Constraints as implemented"). **Composability:** Multiple plugins can contribute to the same hub. Model Catalog could have tabs from `computers` (Operating Systems), `printers` (Printer Models), `machines` (Machine Types), etc., with no conflicts. ### Trade-offs **Route registration is async:** The tabs appear after mount, not at router initialization. In practice this is <100ms and visually seamless, but the router doesn't know about plugin routes at build time. **Component paths are strings:** The `component` field is a string path that gets interpolated into `import()`. This prevents static analysis tools from tracing the dependency, and as written Vite will not rewrite the import at all, so it resolves in the dev server and 404s in a build (see "Constraints as implemented"). **Composability is one hub wide:** "Multiple plugins contribute to the same hub" is true of Model Catalog and of no other hub, because no other hub page registers plugin routes. See "Constraints as implemented". **A plugin tab has no URL worth sharing:** routes register at hub mount, so the tab is not reachable by link, bookmark or refresh. **No SSR for plugin tabs:** Server-side rendering can't evaluate `router.addRoute()` at build time. ShopDB doesn't use SSR, so this is not a concern, but it would block SSR adoption without refactoring. **Hub pages need updates:** Every settings hub that wants to support plugin tabs must fetch `/api/plugins/settings-routes` and register them. The pattern is copy-paste (see ModelCatalogHub), but it's not automatic. ## Alternatives Considered ### 1. Conditional imports at build time Use Vite's conditional imports based on `SITE_PLUGINS`: ```javascript const routes = [ { path: 'vendors', component: () => import('../../views/settings/VendorsList.vue') }, { path: 'types', component: () => import('../../views/settings/ModelTypesList.vue') }, ] if (import.meta.env.VITE_SITE_PLUGINS.includes('computers')) { routes.push({ path: 'operatingsystems', component: () => import('../../.plugins-staged/computers/views/OperatingSystemsList.vue') }) } ``` **Rejected:** Still requires core code to know about every plugin's routes. Not composable - adding a new plugin means editing core. ### 2. Plugin manifests drive build-time codegen Each plugin has a `manifest.json` with routes. The build reads all manifests and generates a `plugin-routes.js` that imports components and exports merged routes. **Rejected:** Build-time coupling is tighter than runtime. Changing a plugin's routes requires rebuilding the entire frontend. Dynamic enable/disable doesn't work without a rebuild. ### 3. Frontend plugin discovery via import.meta.glob Use Vite's glob imports to discover all plugin components: ```javascript const pluginComponents = import.meta.glob('../.plugins-staged/*/views/*.vue') ``` **Rejected:** Discovery is too broad. Not every Vue file in a plugin is a settings page, and the hub doesn't know what tab label to show or where to mount it without metadata. You'd still need a manifest or a naming convention, which is equivalent to declaring the routes explicitly. ## Implementation **Commits:** - `fe09d2745e` - Plugin dynamic settings routes architecture (backend hook + frontend registration) - `d59d0c3194` - Add `/api/plugins/settings-routes` to OpenAPI spec **Files changed:** - `shopdb/plugins/base.py` - Added `get_settings_routes()` hook - `plugins/computers/plugin.py` - Implemented hook for Operating Systems tab - `shopdb/core/api/plugins.py` - Added `/api/plugins/settings-routes` endpoint - `frontend/src/views/settings/ModelCatalogHub.vue` - Fetch and register plugin routes - `frontend/src/router/routes/core.js` - Removed hardcoded computers plugin import - `docs/openapi.json` - Documented new endpoint **Tests:** Gate tests pass (`test_openapi_spec.py`, `test_public_read_surface.py`). ## Future Extensions **Other settings hubs:** AssetConfigHub, SecurityHub, NetworkHub, etc. can adopt the same pattern. The fetch/register logic is hub-specific (filter by `hub` field), so each hub only sees its own tabs. **Nested routes:** A plugin could return routes with `children: [...]` for nested settings pages. Router supports this via `addRoute(parentName, childRoute)`. **Permissions:** The `tab` could include a `permission` field (e.g., `"computers.manage_os"`), and the frontend would filter out tabs the user cannot reach. This would be UI gating only, and the page behind the tab must still enforce its own permission; the `settings.view` gate on `/api/plugins/settings-routes` says who may read the tab list, not who may use a given tab. **Icons:** Add an `icon` field to the route metadata (e.g., `"mdi-microsoft-windows"`), and the tab renders the icon next to the label. **Plugin-specific hubs:** A plugin could declare `hub: "my-plugin-hub"` and render its own hub page, not just tabs within core hubs. The router would need to discover and register hub pages too. ## References - ADR-001: Asset as platform contract (plugin architecture principles) - ADR-013: Plugin catalog and lean builds (problem context) - PR #XXX: Plugin dynamic settings routes (implementation) - Issue #XXX: Lean builds fail with hardcoded plugin imports