Files
shopdb-flask/docs/wiki-drafts/ADR-019-Plugin-Dynamic-Settings-Routes.md
Proudlock 3d041da233 Add wiki page drafts for Q3 2026 work
4 new pages:
- ADR-019: Plugin Dynamic Settings Routes
- Security Audit 2026-09-11 (4 critical + 6 high findings)
- Maintenance Footprint Baseline (936 hrs/year burden)
- Architectural Findings 2026-09-11 (22 findings)

7 existing pages need updates:
- PLUGIN-DEVELOPMENT.md, API-REFERENCE.md, DEPLOYMENT.md
- CONTRIBUTING.md, TESTING.md, SECURITY.md
- New: Automation-Skills.md

Ready to copy to GitHub wiki.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-11 17:04:39 -04:00

260 lines
10 KiB
Markdown

# 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
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
const route = useRoute()
const router = useRouter()
const pluginTabs = ref([])
onMounted(async () => {
// Fetch plugin-contributed settings routes
try {
const response = await axios.get('/api/plugins/settings-routes')
if (response.data && response.data.data) {
// Filter for this hub's routes
pluginTabs.value = response.data.data.routes.filter(r => r.hub === 'model-catalog')
// Dynamically register routes
pluginTabs.value.forEach(tab => {
if (!router.hasRoute(tab.name)) {
router.addRoute('model-catalog', {
path: tab.path,
name: tab.name,
component: () => import(`../../${tab.component}`)
})
}
})
}
} catch (error) {
console.error('Failed to load plugin settings routes:', error)
}
// Default redirect
if (route.name === 'model-catalog') {
router.replace({ name: 'model-catalog-vendors' })
}
})
</script>
<template>
<div class="tabs">
<!-- Core tabs -->
<router-link :to="{ name: 'model-catalog-vendors' }" class="tab">
Vendors
</router-link>
<router-link :to="{ name: 'model-catalog-types' }" class="tab">
Model Types
</router-link>
<router-link :to="{ name: 'model-catalog-models' }" class="tab">
Models
</router-link>
<!-- Plugin tabs loaded dynamically -->
<router-link
v-for="tab in pluginTabs"
:key="tab.name"
:to="{ name: tab.name }"
class="tab"
>
{{ tab.tab }}
</router-link>
</div>
<router-view />
</template>
```
**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