Tab the PC detail page, and let a panel say which tab it belongs to
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m24s

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
This commit is contained in:
cproudlock
2026-08-31 14:24:16 -04:00
parent ae7b30a056
commit 2122c3b45c
10 changed files with 318 additions and 22 deletions

View File

@@ -47,6 +47,7 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) | | 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) |
| 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) | | 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) |
| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) | | 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) |
| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `software` / `metrics` / `actions` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) |
| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) | | 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,

View File

@@ -12,7 +12,7 @@ never by editing this file.
| series | value | governed by | | series | value | governed by |
|---|---|---| |---|---|---|
| product `__version__` | `0.12.0` | ADR-007 | | product `__version__` | `0.12.0` | ADR-007 |
| plugin contract `__contract_version__` | `0.28.0` | ADR-002 | | plugin contract `__contract_version__` | `0.29.0` | ADR-002 |
They move independently. A contract bump is not a release. They move independently. A contract bump is not a release.
@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size ## Size
- test functions defined: **1575** (parametrised cases collect higher) - test functions defined: **1579** (parametrised cases collect higher)
- documented API paths: **346** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) - documented API paths: **346** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -0,0 +1,99 @@
<template>
<!-- The tab strip for an asset detail page. Presentational only: it owns the
strip and the keyboard model, never the content, so every detail page
gets the same behaviour without the strip knowing what an asset is.
It sits BELOW the hero and triage band on purpose. Those answer "is this
thing OK", which must not be behind a click - the strip partitions the
detail underneath and is not navigation. That distinction is what makes
tabs worth having here; a strip that duplicated the sidebar was tried and
rejected. -->
<div class="adt" role="tablist" :aria-label="label">
<button
v-for="(tab, index) in tabs"
:key="tab.key"
:id="`adt-${uid}-${tab.key}`"
class="adt-tab"
:class="{ 'adt-on': tab.key === modelValue }"
role="tab"
type="button"
:aria-selected="tab.key === modelValue ? 'true' : 'false'"
:aria-controls="`adtp-${uid}-${tab.key}`"
:tabindex="tab.key === modelValue ? 0 : -1"
:ref="el => setRef(el, index)"
@click="$emit('update:modelValue', tab.key)"
@keydown="onKey($event, index)"
>
{{ tab.label }}
<span v-if="tab.count != null" class="adt-count">{{ tab.count }}</span>
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
// [{ key, label, count? }] - the caller decides which tabs exist, so a page
// can drop a tab it has nothing for rather than offering an empty one.
tabs: { type: Array, required: true },
modelValue: { type: String, required: true },
label: { type: String, default: 'Detail sections' },
})
const emit = defineEmits(['update:modelValue'])
// Ids have to be unique per instance: a page may show two of these, and
// duplicate ids would break the aria-controls pairing.
const uid = Math.random().toString(36).slice(2, 8)
const els = ref([])
function setRef(el, index) { if (el) els.value[index] = el }
// Roving tabindex, per the WAI-ARIA tabs pattern: arrows move between tabs,
// Home and End jump to the ends. Without this a keyboard user tabs through
// every tab button to reach the panel.
function onKey(event, index) {
const last = props.tabs.length - 1
let next = null
if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1
else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = last
if (next === null) return
event.preventDefault()
emit('update:modelValue', props.tabs[next].key)
els.value[next]?.focus()
}
</script>
<style scoped>
.adt {
display: flex;
gap: 2px;
overflow-x: auto;
border-bottom: 1px solid var(--border);
margin-bottom: 1.25rem;
}
.adt-tab {
font: inherit;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.7rem 0.9rem;
color: var(--text-light);
cursor: pointer;
}
.adt-tab:hover { color: var(--text); }
.adt-on { color: var(--primary); border-bottom-color: var(--primary); }
.adt-tab:focus-visible { outline: 2px solid var(--primary); outline-offset: -3px; border-radius: 3px; }
.adt-count {
font-size: 0.75rem;
color: var(--text-light);
margin-left: 0.35rem;
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -2,7 +2,7 @@
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares <!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
a panel as JSON (title, endpoint, render mode, field map); this renders it a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. --> on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in panels" :key="panel.id"> <template v-for="panel in groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card"> <div v-if="panelVisible(panel)" class="section-card">
<!-- collapsible panels get a clickable header with a caret and, when the <!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst panel declares a status rollup, a green/yellow/red dot for the worst
@@ -149,7 +149,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, computed, watch } from 'vue'
import api from '../api' import api from '../api'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { import {
@@ -183,10 +183,29 @@ function visibleRows(panel) {
const props = defineProps({ const props = defineProps({
assetid: { type: [Number, String], default: null }, assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
}) })
const panels = ref([]) const panels = ref([])
// The server resolves every panel's group before we see it, so an unknown or
// absent one is already 'details' by the time it arrives here. Filtering can
// therefore be a plain equality check and nothing falls through the gap.
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => p.group === props.group) : panels.value
)
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
const populatedGroups = computed(() => {
const seen = new Set()
panels.value.forEach((p) => { if (panelVisible(p)) seen.add(p.group || 'details') })
return seen
})
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather // Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
// than on the panel object because panels are replaced wholesale on refetch. // than on the panel object because panels are replaced wholesale on refetch.
const tabs = ref({}) const tabs = ref({})
@@ -334,7 +353,7 @@ watch(() => props.assetid, load, { immediate: true })
// Let a parent detail page (for example PCDetail's header Refresh button) // Let a parent detail page (for example PCDetail's header Refresh button)
// trigger a panel reload without duplicating the fetch plumbing. // trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load }) defineExpose({ refresh: load, populatedGroups })
</script> </script>
<style scoped> <style scoped>

View File

@@ -84,8 +84,10 @@
Hardware and the rest follow. Panels self-order among themselves by Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin their declared position, and self-hide when a site has no plugin
answering for this asset type. --> answering for this asset type. -->
<div class="content-grid"> <AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<!-- Left Column --> <!-- Left Column -->
<div class="content-column"> <div class="content-column">
@@ -256,7 +258,25 @@
</div> </div>
</div> </div>
<!-- Installed Applications --> <!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software. The core installed-applications list plus anything a plugin
declared for this group. -->
<div v-show="activegroup === 'software'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div class="content-column">
<div class="section-card" v-if="installedApps.length > 0"> <div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3> <h3 class="section-title">Installed Applications</h3>
<div class="app-list"> <div class="app-list">
@@ -276,21 +296,22 @@
</router-link> </router-link>
</div> </div>
</div> </div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div> </div>
</div> </div>
<!-- Metrics, Actions and Activity are entirely plugin-contributed: the
telemetry, the service controls and the event feed all belong to the
endpoints plugin, and core knows nothing about them. -->
<div v-show="activegroup === 'metrics'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="metrics" />
</div>
<div v-show="activegroup === 'actions'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="actions" />
</div>
<div v-show="activegroup === 'activity'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Audit Footer --> <!-- Audit Footer -->
<div class="audit-footer"> <div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span> <span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -305,7 +326,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle" import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api' import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -315,6 +336,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue' import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue' import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle' import { setRecordTitle } from '@/composables/pageTitle'
@@ -344,6 +366,36 @@ const hasRecordCheckin = computed(() => {
}) })
const installedApps = ref([]) const installedApps = ref([])
const assetPanels = ref(null) const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }]
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('metrics')) tabs.push({ key: 'metrics', label: 'Metrics' })
if (has('actions')) tabs.push({ key: 'actions', label: 'Actions' })
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs
})
// A tab can disappear on refetch - a plugin gets disabled, telemetry stops
// answering. Fall back to Details rather than leaving the page showing nothing.
watch(detailtabs, (tabs) => {
if (!tabs.some((tab) => tab.key === activegroup.value)) activegroup.value = 'details'
})
const restarting = ref(false) const restarting = ref(false)
const refreshing = ref(false) const refreshing = ref(false)

View File

@@ -351,6 +351,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command', 'permission': 'endpoints.command',
'empty': 'No monitored services reported.', 'empty': 'No monitored services reported.',
'position': 12, 'position': 12,
# Start/Stop act on the machine, so this is a control surface
'group': 'actions',
}, },
{ {
'id': 'endpoints-tracked-processes', 'id': 'endpoints-tracked-processes',
@@ -389,6 +391,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command', 'permission': 'endpoints.command',
'empty': 'No monitored processes reported.', 'empty': 'No monitored processes reported.',
'position': 13, 'position': 13,
# observed process state, not something you act on here
'group': 'metrics',
}, },
{ {
# One feed instead of two cards. Events and commands answer the # One feed instead of two cards. Events and commands answer the
@@ -432,6 +436,8 @@ class EndpointsPlugin(BasePlugin):
}, },
'empty': 'Nothing recorded for this endpoint yet.', 'empty': 'Nothing recorded for this endpoint yet.',
'position': 20, 'position': 20,
# the event and command feed IS the activity tab
'group': 'activity',
}, },
{ {
'id': 'endpoints-interfaces', 'id': 'endpoints-interfaces',
@@ -451,6 +457,8 @@ class EndpointsPlugin(BasePlugin):
], ],
'empty': 'No network interfaces reported.', 'empty': 'No network interfaces reported.',
'position': 16, 'position': 16,
# adapter state is telemetry, not something you act on
'group': 'metrics',
}, },
{ {
# Installed-software (SBOM) inventory. Low-priority reference # Installed-software (SBOM) inventory. Low-priority reference
@@ -471,6 +479,8 @@ class EndpointsPlugin(BasePlugin):
], ],
'empty': 'No installed software reported.', 'empty': 'No installed software reported.',
'position': 30, 'position': 30,
# the installed-software inventory
'group': 'software',
}, },
] ]

View File

@@ -104,7 +104,7 @@ from .plugins import plugin_manager
# defeated by rotating the header. The helper resolves from remote_addr, which # defeated by rotating the header. The helper resolves from remote_addr, which
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a # ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
# proxy is really in front. Additive names on the surface, minor bump. # proxy is really in front. Additive names on the surface, minor bump.
__contract_version__ = '0.28.0' __contract_version__ = '0.29.0'
# Product release version (see ADR-007). The product version and the # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent

View File

@@ -19,6 +19,14 @@ from shopdb.utils.authz import require_permission
pluginui_bp = Blueprint('pluginui', __name__) 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): def _collect(hookname):
"""Aggregate a data-only hook across enabled plugins. """Aggregate a data-only hook across enabled plugins.
@@ -67,6 +75,12 @@ def asset_panels():
Query param assetid is required. A panel matches when its assettypes list Query param assetid is required. A panel matches when its assettypes list
contains the asset's type key or the wildcard '*'. 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) assetid = request.args.get('assetid', type=int)
if not assetid: if not assetid:
@@ -81,6 +95,9 @@ def asset_panels():
for panel in _collect('get_asset_panels'): for panel in _collect('get_asset_panels'):
assettypes = panel.get('assettypes') or [] assettypes = panel.get('assettypes') or []
if '*' in assettypes or (assettype and assettype in assettypes): 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.append(panel)
matched.sort(key=lambda panel: panel.get('position', 99)) matched.sort(key=lambda panel: panel.get('position', 99))
return success_response(matched) return success_response(matched)

View File

@@ -370,8 +370,23 @@ class BasePlugin(ABC):
# ('list' takes a 'map' of title/badge/meta # ('list' takes a 'map' of title/badge/meta
# keys, rendered generically - see warranty) # keys, rendered generically - see warranty)
'position': int, # order among panels 'position': int, # order among panels
'group': str, # OPTIONAL. Which tab on the detail page:
# 'details' | 'software' | 'metrics'
# | 'actions' | 'activity'. Omitted means
# 'details'.
} }
ON 'group'. The detail pages are tabbed, and a panel says which tab it
belongs to rather than the page listing panels by id - a page that knew
its plugins' panels by name would defeat the point of the hook.
Omitting it is the correct answer for most panels and keeps every
existing plugin working untouched: an unknown or absent group resolves
to 'details', which is the tab that holds what an asset IS. Reach for
another group only when the panel is the thing that tab exists for -
an inventory listing, live telemetry, controls that act on the machine,
or a history feed.
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
the panels matching that asset's type. Disabled plugins the panels matching that asset's type. Disabled plugins
are skipped; a broken plugin is isolated in prod, re-raised in dev/test. are skipped; a broken plugin is isolated in prod, re-raised in dev/test.

View File

@@ -142,3 +142,86 @@ def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monk
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers) entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
assert 'measuring_tool' not in {e.get('assettype') for e in entries} assert 'measuring_tool' not in {e.get('assettype') for e in entries}
# =============================================================================
# asset-panel groups (which detail-page tab a panel lands in)
# =============================================================================
def test_every_panel_comes_back_with_a_group(app, client, db, auth_headers,
monkeypatch):
"""The client filters by group, so a panel without one must not vanish.
Resolution happens on the server precisely so a plugin written before tabs
existed still lands somewhere a person can see.
"""
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
assert panels, 'no panels matched, so this test proves nothing'
for panel in panels:
assert panel.get('group') in ASSET_PANEL_GROUPS, panel.get('id')
def test_an_undeclared_group_defaults_to_details(app, client, db, auth_headers,
monkeypatch):
"""Omitting group is the documented answer for most panels."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'nogroup', 'title': 'No group', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'nogroup')
assert panel['group'] == 'details'
def test_a_group_nobody_defined_falls_back_rather_than_disappearing(
app, client, db, auth_headers, monkeypatch):
"""A typo must not drop the panel off every tab.
The page renders known groups only, so an unrecognised value that survived
to the client would be a panel that exists and is never shown anywhere.
"""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'typo', 'title': 'Typo', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': 'metrcis',
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'typo')
assert panel['group'] == 'details'
def test_group_matching_is_not_case_or_space_sensitive(app, client, db,
auth_headers, monkeypatch):
"""'Metrics ' is obviously meant to be metrics; do not send it to details."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'shouty', 'title': 'Shouty', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': ' Metrics ',
}])
assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers)
panel = next(p for p in panels if p['id'] == 'shouty')
assert panel['group'] == 'metrics'