Files
shopdb-flask/shopdb/core/api/pluginui.py
cproudlock 2feb569686
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m17s
Rename Metrics to Monitors and move it second
"Metrics" promised numbers. The tab holds tracked services, tracked processes
and network interfaces - things being WATCHED and their current state, only
one of which is a measurement. Monitors is what the panels actually are, and
it matches the word the agent's own config already uses for them.

Moved to second, ahead of Software. What is being watched on this box is the
question asked right after "what is it", and well before its application
inventory - which is reference data somebody goes looking for deliberately.

Tab order is now Details, Monitors, Software, Activity.

Contract 0.29.0 keeps its number. It is unreleased, so the group vocabulary it
introduces is still being settled and nothing can yet depend on the old name.
Both the server and the client fall back to 'details' for a group they do not
recognise, so a site that deploys these halves out of step gets a misplaced
card rather than a missing one.

Scope: product
2026-08-31 15:21:46 -04:00

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', 'monitors', 'software', '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)