# ADR-019: Plugin Dynamic Settings Routes **Status:** Accepted (2026-09-11) **Context:** Plugin UI extensions, lean builds **Supersedes:** Hardcoded plugin routes in core router ## 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 Added to `shopdb/core/api/plugins.py`: ```python @plugins_bp.route('/settings-routes', methods=['GET']) @jwt_required(optional=True) 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 in pm.get_enabled_plugin_instances(): if hasattr(plugin, 'get_settings_routes'): plugin_routes = plugin.get_settings_routes() if plugin_routes: routes.extend(plugin_routes) return success_response({'routes': routes}) ``` **Auth:** Deliberately anonymous (`jwt_required(optional=True)`) so unauthenticated pages (kiosks, TV displays) can load plugin routes. ### 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 ## 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. **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, though Vite's glob imports during the plugin staging step ensure the files are bundled. **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 filters out tabs the user can't access. The backend endpoint is anonymous, so permission checks would be client-side only (acceptable for UI gating; the API endpoint enforces permission anyway). **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