diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index acb7130f..0497af10 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -461,7 +461,7 @@ const panels = ref([]) // 'actions', this build had no such tab, and the card was simply gone with // nothing logged. The same applies to the 'metrics' to 'monitors' rename. // Fall back the same way the server does rather than drop it. -const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'software', 'activity'] +const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity'] function groupOf(panel) { return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details' } diff --git a/frontend/src/components/panelGroups.spec.js b/frontend/src/components/panelGroups.spec.js index e12f942f..9942d3f7 100644 --- a/frontend/src/components/panelGroups.spec.js +++ b/frontend/src/components/panelGroups.spec.js @@ -66,4 +66,30 @@ describe('panel group vocabulary', () => { expect(renderedGroups()).toContain('monitors') expect(pcDetailSource()).toContain("label: 'System Monitors'") }) + + it('agrees with the server vocabulary that resolves groups first', () => { + // The fourth file this comment block claims to cover, and the one that was + // NOT actually being checked. asset_panels() rewrites any group missing + // from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the + // frontend can be perfectly consistent with itself and still show nothing: + // that is exactly how the Service/Process tab went missing after Phase 3. + const source = read('../../../shopdb/core/api/pluginui.py') + const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/) + if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py') + const serverGroups = match[1].split(',') + .map((entry) => entry.trim().replace(/^'|'$/g, '')) + .filter(Boolean) + + for (const group of renderedGroups()) { + expect(serverGroups).toContain(group) + } + }) + + it('keeps operator reports in a tab of its own', () => { + const source = pcDetailSource() + + expect(renderedGroups()).toContain('reports') + expect(source).toContain("key: 'reports'") + expect(source).toContain('group="reports"') + }) }) diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index efae085a..1f3e899f 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -349,6 +349,12 @@
+ +
+ +
@@ -456,6 +462,10 @@ const detailtabs = computed(() => { if (has('serviceprocess')) { tabs.push({ key: 'serviceprocess', label: 'Service/Process Monitors' }) } + // Operator Reports earns a tab rather than a card in System Monitors: it is + // what a person reported, not what the agent measured, and the thumbnail + // grid is much bulkier than the fact cards around it. + if (has('reports')) tabs.push({ key: 'reports', label: 'Operator Reports' }) if (has('software') || installedApps.value.length > 0) { tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null }) } diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index a08506e5..45c26397 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -647,8 +647,11 @@ class EndpointsPlugin(BasePlugin): }, { # Operator-submitted issue reports: a free-text note and an - # optional screenshot the operator captured from the tray. Sits - # in the Monitors tab beside the other watch-and-observe panels. + # optional screenshot the operator captured from the tray. Has + # a tab of its own rather than sitting in System Monitors: that + # tab is machine-collected health, this is a human telling you + # something, and the thumbnail grid is far bulkier than the + # fact cards it used to sit between. # A custom render carries the thumbnail grid and the expanding # lightbox with left/right cycling the generic modes cannot. 'id': 'endpoints-operator-reports', @@ -660,7 +663,7 @@ class EndpointsPlugin(BasePlugin): 'collapsible': True, 'empty': 'No operator reports for this endpoint.', 'position': 18, - 'group': 'monitors', + 'group': 'reports', }, ] diff --git a/shopdb/core/api/pluginui.py b/shopdb/core/api/pluginui.py index 20cf1508..da33e88c 100644 --- a/shopdb/core/api/pluginui.py +++ b/shopdb/core/api/pluginui.py @@ -25,7 +25,14 @@ pluginui_bp = Blueprint('pluginui', __name__) # # 'details' is first and is the fallback: it holds what an asset IS, which is # what a panel declaring nothing almost always contributes. -ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity') +# +# This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in +# PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two. +# Adding a group means touching all three: a value missing here is rewritten to +# 'details' before the client ever sees it, so the panels quietly render in the +# wrong tab and the intended tab never appears. No error, no log line. +ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports', + 'software', 'activity') def _collect(hookname): diff --git a/tests/test_core/test_pluginui_panel_groups.py b/tests/test_core/test_pluginui_panel_groups.py new file mode 100644 index 00000000..2027b993 --- /dev/null +++ b/tests/test_core/test_pluginui_panel_groups.py @@ -0,0 +1,44 @@ +"""The server-side panel group vocabulary must admit every group a plugin uses. + +`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing +any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy +of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and +the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two +frontend copies and missed this one, so both Service/Process panels were +silently relabelled 'details' on the wire and the tab never appeared. + +The failure is invisible: no error, no log line, and the panels still render - +just in the wrong tab, underneath the Details cards. +""" +from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS +from plugins.endpoints.plugin import EndpointsPlugin + + +def test_every_group_the_endpoints_plugin_declares_survives_resolution(): + declared = { + str(panel.get('group') or '').strip().lower() + for panel in EndpointsPlugin().get_asset_panels() + } + declared.discard('') + unknown = declared - set(ASSET_PANEL_GROUPS) + assert not unknown, ( + f'panels declare {sorted(unknown)}, which the server rewrites to ' + "'details' - the tab will not appear" + ) + + +def test_serviceprocess_is_admitted(): + # Named explicitly: the generic test above would also pass if the + # serviceprocess panels were deleted outright. + assert 'serviceprocess' in ASSET_PANEL_GROUPS + + +def test_reports_is_admitted(): + # Same reasoning as serviceprocess above. Operator Reports was moved out of + # System Monitors into a tab of its own, which is the same three-copy + # change that Phase 3 got wrong. + assert 'reports' in ASSET_PANEL_GROUPS + + +def test_details_remains_the_fallback(): + assert ASSET_PANEL_GROUPS[0] == 'details' diff --git a/tests/test_plugins/test_endpoints_panel_groups.py b/tests/test_plugins/test_endpoints_panel_groups.py index 27e63de4..78c8e730 100644 --- a/tests/test_plugins/test_endpoints_panel_groups.py +++ b/tests/test_plugins/test_endpoints_panel_groups.py @@ -16,12 +16,16 @@ a group nothing renders. The frontend half is pinned by its own spec. """ from plugins.endpoints.plugin import EndpointsPlugin +from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS -# Must match RENDERED_GROUPS in frontend/src/components/PluginAssetPanels.vue. -# A panel in any group not listed here is fetched, held, and shown nowhere. -RENDERED_GROUPS = {'details', 'monitors', 'serviceprocess', 'software', - 'activity'} +# Derived from the server tuple rather than hand-copied. This used to be a +# literal set duplicating RENDERED_GROUPS, which made it a FIFTH copy of the +# vocabulary and one more thing to forget: it passed for a group the server +# would rewrite to 'details' anyway. ASSET_PANEL_GROUPS is the value that +# actually decides what reaches the client, and panelGroups.spec.js pins it +# against the frontend copy. +RENDERED_GROUPS = set(ASSET_PANEL_GROUPS) def _panels():