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():