Tab the PC detail page and add panel groups to the ADR-010 contract
This commit is contained in:
99
frontend/src/components/AssetDetailTabs.vue
Normal file
99
frontend/src/components/AssetDetailTabs.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,19 +296,20 @@
|
|||||||
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Audit Footer -->
|
<!-- Audit Footer -->
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user