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
|
||||
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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user