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>
260 lines
10 KiB
Markdown
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
|