Tab the PC detail page and add panel groups to the ADR-010 contract

This commit is contained in:
2026-08-31 14:25:53 -04:00
parent 73a91ef916
commit 0d49341a2a
8 changed files with 315 additions and 20 deletions

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
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. -->
<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">
<!-- 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
@@ -149,7 +149,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
@@ -183,10 +183,29 @@ function visibleRows(panel) {
const props = defineProps({
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([])
// 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
// than on the panel object because panels are replaced wholesale on refetch.
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)
// trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load })
defineExpose({ refresh: load, populatedGroups })
</script>
<style scoped>

View File

@@ -84,8 +84,10 @@
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<div class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<!-- Left Column -->
<div class="content-column">
@@ -256,7 +258,25 @@
</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">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
@@ -276,21 +296,22 @@
</router-link>
</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>
<!-- 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 -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -305,7 +326,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -315,6 +336,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
@@ -344,6 +366,36 @@ const hasRecordCheckin = computed(() => {
})
const installedApps = ref([])
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 refreshing = ref(false)

View File

@@ -351,6 +351,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
# Start/Stop act on the machine, so this is a control surface
'group': 'actions',
},
{
'id': 'endpoints-tracked-processes',
@@ -389,6 +391,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
'group': 'metrics',
},
{
# 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.',
'position': 20,
# the event and command feed IS the activity tab
'group': 'activity',
},
{
'id': 'endpoints-interfaces',
@@ -451,6 +457,8 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No network interfaces reported.',
'position': 16,
# adapter state is telemetry, not something you act on
'group': 'metrics',
},
{
# Installed-software (SBOM) inventory. Low-priority reference
@@ -471,6 +479,8 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No installed software reported.',
'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
# 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.
__contract_version__ = '0.28.0'
__contract_version__ = '0.29.0'
# Product release version (see ADR-007). The product version and the
# 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__)
# 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.
@@ -67,6 +75,12 @@ def 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:
@@ -81,6 +95,9 @@ def asset_panels():
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)

View File

@@ -370,8 +370,23 @@ class BasePlugin(ABC):
# ('list' takes a 'map' of title/badge/meta
# keys, rendered generically - see warranty)
'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
the panels matching that asset's type. Disabled plugins
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)
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'