The page had grown to roughly ten cards in one flow, and three more surfaces were wanted on it: installed software, live metrics, and the service controls. Stacking those into the same column would have buried what the page is for. Tabs were mocked and rejected on 2026-08-28, on the grounds that the strip duplicated the sidebar and cost hero space. That objection is answered by where the strip sits: the hero and the triage band stay above it and never move, so "is this machine OK" is still answered without a click, and the strip partitions the detail underneath rather than navigating anywhere. WHY A PANEL DECLARES ITS OWN GROUP Most cards on a detail page are not the page's. They arrive through the ADR-010 get_asset_panels hook, so a page that sorted panels into tabs by knowing their ids would defeat the hook it is built on. Panels now carry an optional group instead, and the page only knows the five group names. Resolution happens server-side, in the asset-panels route, so that an absent or misspelled group becomes 'details' before the client ever sees it. The client renders known groups only; an unrecognised value that survived to it would be a panel that exists and is displayed nowhere. Contract 0.28.0 -> 0.29.0, additive - every existing plugin keeps working untouched, and the endpoints status panel is deliberately left ungrouped so the default path is exercised by a shipped plugin and not only by tests. A tab is offered only when something fills it. An empty tab reads as broken rather than as "this site does not collect that", so the strip is built from the groups that actually returned data, and a tab that disappears on refetch falls back to Details rather than leaving the page blank. AssetDetailTabs is core, not a PCDetail component: machines, printers, network devices and measuring tools all have the same shape, and the shared asset-detail shell they need starts here. Scope: product
127 lines
5.0 KiB
Python
127 lines
5.0 KiB
Python
"""Plugin frontend-contribution API endpoints (ADR-010).
|
|
|
|
Consumers for the four data-only presentation hooks on BasePlugin:
|
|
get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation.
|
|
|
|
Every endpoint copies the dashboard-widgets consumer semantics: skip disabled
|
|
plugins, tag each contributed dict with its originating plugin name, fail loud
|
|
in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional,
|
|
matching the other read-only core endpoints; they expose data, not mutations.
|
|
"""
|
|
|
|
from flask import Blueprint, request, current_app
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import Asset
|
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|
from shopdb.utils.authz import require_permission
|
|
|
|
pluginui_bp = Blueprint('pluginui', __name__)
|
|
|
|
# The tabs an asset detail page offers, and so the only values a panel's
|
|
# 'group' may take. Kept here rather than in the frontend because the server
|
|
# resolves the group before the client sees it - see asset_panels below.
|
|
#
|
|
# 'details' is first and is the fallback: it holds what an asset IS, which is
|
|
# what a panel declaring nothing almost always contributes.
|
|
ASSET_PANEL_GROUPS = ('details', 'software', 'metrics', 'actions', 'activity')
|
|
|
|
|
|
def _collect(hookname):
|
|
"""Aggregate a data-only hook across enabled plugins.
|
|
|
|
Same access pattern as dashboard.get_widgets: skip disabled plugins, inject
|
|
the plugin name on every returned dict, re-raise in dev/test, log-and-isolate
|
|
a broken plugin in prod. Returns the merged list (unsorted).
|
|
"""
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if not pm:
|
|
return []
|
|
|
|
merged = []
|
|
for name, plugin in pm.get_all_plugins().items():
|
|
if not pm.registry.is_enabled(name):
|
|
continue
|
|
try:
|
|
for entry in getattr(plugin, hookname)() or []:
|
|
entry['plugin'] = name
|
|
merged.append(entry)
|
|
except Exception:
|
|
# fail loud in dev/test, isolate broke plugin in prod
|
|
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
|
raise
|
|
current_app.logger.exception('Plugin %s %s failed', name, hookname)
|
|
return merged
|
|
|
|
|
|
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
def settings_cards():
|
|
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
|
|
|
The frontend merges these into the core settingsNav catalog and renders
|
|
them in the settings rail + landing overview.
|
|
"""
|
|
cards = _collect('get_settings_cards')
|
|
cards.sort(key=lambda card: card.get('position', 99))
|
|
return success_response(cards)
|
|
|
|
|
|
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
def asset_panels():
|
|
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
|
|
|
Query param assetid is required. A panel matches when its assettypes list
|
|
contains the asset's type key or the wildcard '*'.
|
|
|
|
Every panel comes back carrying a 'group', naming the detail-page tab it
|
|
belongs to. It is resolved HERE rather than in the client so a plugin that
|
|
predates tabs, or names a group that does not exist, still lands somewhere a
|
|
person can see instead of vanishing from a page that only renders known
|
|
groups. Unknown and absent both mean 'details'.
|
|
"""
|
|
assetid = request.args.get('assetid', type=int)
|
|
if not assetid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required')
|
|
|
|
asset = db.session.get(Asset, assetid)
|
|
if not asset:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
assettype = asset.assettype.assettype if asset.assettype else None
|
|
|
|
matched = []
|
|
for panel in _collect('get_asset_panels'):
|
|
assettypes = panel.get('assettypes') or []
|
|
if '*' in assettypes or (assettype and assettype in assettypes):
|
|
panel = dict(panel)
|
|
group = str(panel.get('group') or '').strip().lower()
|
|
panel['group'] = group if group in ASSET_PANEL_GROUPS else 'details'
|
|
matched.append(panel)
|
|
matched.sort(key=lambda panel: panel.get('position', 99))
|
|
return success_response(matched)
|
|
|
|
|
|
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
def map_overlays():
|
|
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
|
|
|
The map fetches each overlay endpoint and decorates already-placed markers.
|
|
"""
|
|
overlays = _collect('get_map_overlays')
|
|
overlays.sort(key=lambda overlay: overlay.get('position', 99))
|
|
return success_response(overlays)
|
|
|
|
|
|
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
def asset_presentation():
|
|
"""Merge enabled plugins' asset-type presentation entries (hook).
|
|
|
|
Search rows and cross-links use these to pick a type's icon and detail route.
|
|
"""
|
|
entries = _collect('get_asset_presentation')
|
|
return success_response(entries)
|