Say what a plugin settings tab can actually do, and catch a declaration that cannot work
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

Finding F12 of the architectural review, checked against the code and found
worse than reported: the dynamic component import cannot be rewritten by the
bundler, so the built file ships the unresolved path and a plugin tab answers
404 in every production build, staged or not. It works only under the dev
server. Nothing shipping is affected, because the one plugin that declares a
tab also registers the same page as an ordinary route, and that is the one
sites reach.

- The ADR draft now carries a "Constraints as implemented" section: only the
  model catalog hub registers plugin tabs, registration happens when that hub
  mounts so a tab is not deep-linkable and does not survive a refresh, and the
  build finding above with the bundler's own warning. The two ways out are
  stated as the open decision rather than taken.
- The hook's docstring reads as instructions to a plugin author, and its
  example no longer names a path that does not exist at build time.
- A route declared for a hub that cannot render it is dropped with an error
  naming the plugin, the hub and the hubs that do work, and the same is logged
  at startup. Enabling the plugin still succeeds: a tab nobody can reach is no
  reason to take a working backend offline.
- Tests hold the two halves together: no bundled plugin may declare a hub the
  frontend does not implement, and the list in the server must equal the hubs
  that really register routes, so the two cannot drift apart again.
This commit is contained in:
cproudlock
2026-09-16 09:57:51 -04:00
parent 066a19052b
commit 1a6ac67cab
8 changed files with 380 additions and 14 deletions

View File

@@ -19,6 +19,41 @@ from dataclasses import dataclass, field
from flask import Flask, Blueprint
# Settings hubs whose Vue page actually draws plugin-contributed tabs: it asks
# /api/plugins/settings-routes, keeps the routes whose `hub` matches its own id,
# and registers them with router.addRoute(). Every other hub renders its own
# tabs and nothing else, so a route naming one would be returned by the API and
# never drawn - a tab the author declared correctly that simply does not exist
# for the user. Hubs are added here only when their .vue file gains that code;
# tests/test_core/test_plugin_settings_hubs.py fails if the two drift apart.
SETTINGS_HUBS_WITH_PLUGIN_TABS = ('model-catalog',)
def renderable_settings_routes(plugin_name: str, routes: Optional[List[Dict]],
logger) -> List[Dict]:
"""The subset of a plugin's declared settings routes that a hub can draw.
Anything naming a hub outside SETTINGS_HUBS_WITH_PLUGIN_TABS is dropped and
logged as an error naming the plugin, the hub and the supported set. The
declaration is not a crash - the plugin's API, models and pages are all
fine - so this refuses the dead tab rather than the plugin. The hard stop
lives in the test suite, where the author still has a chance to fix it.
"""
kept = []
for route in routes or []:
hub = route.get('hub')
if hub in SETTINGS_HUBS_WITH_PLUGIN_TABS:
kept.append(route)
continue
logger.error(
"Plugin %s declares settings route '%s' for hub '%s'. No settings "
"hub page registers plugin tabs for that hub, so the tab would "
"never render; dropping it. Hubs that support plugin tabs: %s",
plugin_name, route.get('name'), hub,
', '.join(SETTINGS_HUBS_WITH_PLUGIN_TABS))
return kept
@dataclass
class PluginMeta:
"""Plugin metadata container."""
@@ -147,11 +182,17 @@ class BasePlugin(ABC):
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.)
hub - settings hub to attach to; ONLY a hub named in
SETTINGS_HUBS_WITH_PLUGIN_TABS (top of this module) draws
plugin tabs. A route for any other hub is dropped by
/api/plugins/settings-routes with an error in the log.
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 (e.g. 'plugins/<name>/frontend/views/<Component>.vue')
component- path to the Vue component AS IT EXISTS IN THE BUILD, i.e.
relative to frontend/src, which for a plugin means
'.plugins-staged/<name>/views/<Component>.vue' - the copy
scripts/stage-frontend.mjs makes of plugins/<name>/frontend
Example:
return [{
@@ -159,9 +200,29 @@ class BasePlugin(ABC):
'path': 'operatingsystems',
'name': 'model-catalog-os',
'tab': 'Operating Systems',
'component': 'plugins/computers/frontend/views/OperatingSystemsList.vue'
'component': '.plugins-staged/computers/views/OperatingSystemsList.vue'
}]
Three constraints the shape of this hook does not show, so read them
before you rely on it (ADR-019, "Constraints as implemented"):
1. Declare a hub only if it is in SETTINGS_HUBS_WITH_PLUGIN_TABS. Today
that is 'model-catalog' alone. The other hubs have no code to draw
your tab, so the route is accepted and dropped.
2. Route the page yourself as well, in plugins/<name>/frontend/
routes.js, and treat THAT as the real route. The hub interpolates
`component` into a dynamic import with no static file extension,
which Vite will not rewrite, so the tab loads under `npm run dev`
and 404s in a production build. A page reachable only through the
tab is a page nobody can open at a site. Your frontend also has to
be staged into the build (SITE_PLUGINS decides that), so enabling a
plugin whose UI was never staged needs a frontend rebuild.
3. The tab is not a link you can hand out. The hub registers the route
when it mounts, so the URL resolves only for someone who has already
been through the parent hub in that page load. Do not link to it
from an email, a doc, or another page, and do not expect a refresh
on it to land anywhere.
Return [] (default) if the plugin adds no settings pages.
"""
return []