diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue
index 18f2e193..922f1509 100644
--- a/frontend/src/components/PluginAssetPanels.vue
+++ b/frontend/src/components/PluginAssetPanels.vue
@@ -420,7 +420,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', 'software', 'activity']
+const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', '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
new file mode 100644
index 00000000..e12f942f
--- /dev/null
+++ b/frontend/src/components/panelGroups.spec.js
@@ -0,0 +1,69 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+// The group vocabulary is split across four files and there is no runtime that
+// checks they agree. When they disagree the failure is SILENT: a panel in a
+// group nothing renders is fetched, held, and shown on no tab, with nothing
+// logged. That has eaten cards twice - tracked services moving out of
+// 'actions', and the 'metrics' to 'monitors' rename.
+//
+// So this reads the sources and asserts the vocabularies match. It is a source
+// check rather than a behaviour check because RENDERED_GROUPS is a module-local
+// constant in the .vue file with no export to assert against.
+
+function read(relative) {
+ return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8')
+}
+
+function renderedGroups() {
+ const source = read('./PluginAssetPanels.vue')
+ const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
+ if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
+ return match[1].split(',')
+ .map((entry) => entry.trim().replace(/^'|'$/g, ''))
+ .filter(Boolean)
+}
+
+function pcDetailSource() {
+ return read('../../../plugins/computers/frontend/views/PCDetail.vue')
+}
+
+describe('panel group vocabulary', () => {
+ it('renders a group for every tab PCDetail offers', () => {
+ const groups = renderedGroups()
+ const source = pcDetailSource()
+ const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
+ .map((match) => match[1])
+
+ expect(tabKeys.length).toBeGreaterThan(0)
+ for (const key of tabKeys) {
+ expect(groups).toContain(key)
+ }
+ })
+
+ it('gives every rendered group somewhere to actually show', () => {
+ // A group in RENDERED_GROUPS with no v-show block is the mirror failure:
+ // the panel is kept rather than dropped, but still appears nowhere.
+ const source = pcDetailSource()
+ for (const group of renderedGroups()) {
+ expect(source).toContain(`activegroup === '${group}'`)
+ }
+ })
+
+ it('keeps the service/process split wired end to end', () => {
+ // Pins the specific split rather than only the general rule, so removing
+ // the tab is a deliberate edit to this test and not a silent regression.
+ const source = pcDetailSource()
+
+ expect(renderedGroups()).toContain('serviceprocess')
+ expect(source).toContain("key: 'serviceprocess'")
+ expect(source).toContain("group=\"serviceprocess\"")
+ })
+
+ it('still renders the system monitors group after the rename', () => {
+ // The rename is label-only; the group key must not drift with it.
+ expect(renderedGroups()).toContain('monitors')
+ expect(pcDetailSource()).toContain("label: 'System Monitors'")
+ })
+})
diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue
index 6af38998..efae085a 100644
--- a/plugins/computers/frontend/views/PCDetail.vue
+++ b/plugins/computers/frontend/views/PCDetail.vue
@@ -339,10 +339,16 @@
feed all belong to the endpoints plugin, and core knows nothing about
them.
- Monitors keeps the two-column flow because it is short cards. -->
+ Monitors keeps the two-column flow because it is short cards, and
+ Service/Process Monitors does the same: both are short fact cards
+ rather than the long lists that make Software and Activity go full
+ width. -->
+
@@ -444,7 +450,12 @@ const detailtabs = computed(() => {
const tabs = [{ key: 'details', label: 'Details' }]
// Monitors sits second: what is being watched on this box is the question
// asked right after "what is it", and well before its software inventory.
- if (has('monitors')) tabs.push({ key: 'monitors', label: 'Monitors' })
+ // Renamed to System Monitors now that service/process watching is its own
+ // tab: this one is the box's own health, that one is what runs on it.
+ if (has('monitors')) tabs.push({ key: 'monitors', label: 'System Monitors' })
+ if (has('serviceprocess')) {
+ tabs.push({ key: 'serviceprocess', label: 'Service/Process Monitors' })
+ }
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py
index 9a63a92d..1ea232e2 100644
--- a/plugins/endpoints/api/routes.py
+++ b/plugins/endpoints/api/routes.py
@@ -570,6 +570,15 @@ def panel_hardware(asset_id: int):
return success_response({'fields': fields})
+# Wire values to operator-readable text. The wire keys are stable contract;
+# these labels are not, so they live here rather than in the agent.
+_REBOOT_REASON_LABELS = {
+ 'componentbasedservicing': 'servicing',
+ 'windowsupdate': 'Windows Update',
+ 'pendingfilerename': 'files staged for replacement',
+}
+
+
@endpoints_bp.route('/asset//osinfo', methods=['GET'])
@require_permission('endpoints.view')
def panel_osinfo(asset_id: int):
@@ -584,10 +593,31 @@ def panel_osinfo(asset_id: int):
osinfo = _state_json(state, 'osinfojson') or {}
bios = _state_json(state, 'biosjson') or {}
+ bootinfo = _state_json(state, 'bootinfojson') or {}
pendingreboot = None
if state.pendingreboot is not None:
pendingreboot = 'Yes' if state.pendingreboot else 'No'
+ # Say WHICH signal fired when we know. A staged file rename means the
+ # box is mid-servicing; a lone servicing key can sit set for weeks on a
+ # machine nobody rebooted. Same "Yes", very different urgency.
+ reasons = _state_json(state, 'rebootreasonsjson')
+ if state.pendingreboot and isinstance(reasons, list) and reasons:
+ pendingreboot = '{} ({})'.format(
+ pendingreboot, ', '.join(_REBOOT_REASON_LABELS.get(r, r)
+ for r in reasons if isinstance(r, str)))
+
+ # Only worth a row when fast startup actually applies. On a box with it
+ # disabled and a cold last boot there is nothing to warn about, and a
+ # permanent "Cold" row is noise on a card that is already dense.
+ boottype = bootinfo.get('lastboottype')
+ if boottype in (None, '', 'Cold', 'Unknown'):
+ boottype = None
+ elif boottype == 'Fast startup':
+ # Uptime is measured from the resume, not the last true boot, so it
+ # understates how long the kernel has actually been up. Said plainly
+ # because this is the field that makes uptime read wrong.
+ boottype = 'Fast startup (uptime not since cold boot)'
fields = [
{'label': 'OS version', 'value': osinfo.get('version'), 'mono': True},
@@ -598,6 +628,7 @@ def panel_osinfo(asset_id: int):
{'label': 'BIOS vendor', 'value': bios.get('vendor')},
{'label': 'BIOS version', 'value': bios.get('version'), 'mono': True},
{'label': 'BIOS date', 'value': _format_agent_date(bios.get('releasedate'))},
+ {'label': 'Last boot', 'value': boottype},
{'label': 'Pending reboot', 'value': pendingreboot},
]
fields = [f for f in fields
@@ -1218,6 +1249,32 @@ def panel_activity(asset_id: int):
} for r in rows[:15]])
+def _link_label(speedbps, fullduplex):
+ """Render NIC link speed and duplex as one cell.
+
+ Null speed means the agent could not read link state OR the adapter is
+ disconnected - verified on a live box that a disconnected NIC returns empty
+ values. Either way we do not know, so it reads '-' rather than 0 Mb.
+
+ Half duplex is called out explicitly because that is the actionable case: a
+ NIC that negotiated half duplex on a switch port is a cable or port fault
+ that otherwise presents as "the machine is slow sometimes". Full duplex is
+ the normal case and gets no annotation, so the only thing standing out in
+ the column is the thing worth looking at.
+ """
+ if not speedbps:
+ return '-'
+
+ if speedbps >= 1000000000:
+ speed = '{:g} Gb'.format(speedbps / 1000000000)
+ else:
+ speed = '{:g} Mb'.format(speedbps / 1000000)
+
+ if fullduplex is False:
+ return '{} half duplex'.format(speed)
+ return speed
+
+
@endpoints_bp.route('/asset//interfaces', methods=['GET'])
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
@@ -1255,6 +1312,8 @@ def panel_interfaces(asset_id: int):
# A gatewayless NIC with an address is the machine link; the gateway
# NIC is the corporate LAN.
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
+ 'link': _link_label(entry.get('linkspeedbps'),
+ entry.get('fullduplex')),
})
return success_response(rows)
diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py
index 44062c46..b0e66ac1 100644
--- a/plugins/endpoints/plugin.py
+++ b/plugins/endpoints/plugin.py
@@ -394,7 +394,9 @@ class EndpointsPlugin(BasePlugin):
'position': 12,
# Service state is what is being watched; the Start/Stop
# controls belong beside it, not in a tab of their own.
- 'group': 'monitors',
+ # Lives in serviceprocess rather than monitors: that tab is the
+ # box's own health, this is what runs on it.
+ 'group': 'serviceprocess',
},
{
'id': 'endpoints-tracked-processes',
@@ -434,7 +436,7 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
- 'group': 'monitors',
+ 'group': 'serviceprocess',
},
{
# One feed instead of two cards. Events and commands answer the
@@ -501,6 +503,7 @@ class EndpointsPlugin(BasePlugin):
{'key': 'subnet', 'label': 'Subnet'},
{'key': 'dhcp', 'label': 'Assignment'},
{'key': 'role', 'label': 'Role'},
+ {'key': 'link', 'label': 'Link'},
],
'empty': 'No network interfaces reported.',
'position': 16,
diff --git a/tests/test_plugins/test_endpoints_link_label.py b/tests/test_plugins/test_endpoints_link_label.py
new file mode 100644
index 00000000..0c25a86f
--- /dev/null
+++ b/tests/test_plugins/test_endpoints_link_label.py
@@ -0,0 +1,49 @@
+"""NIC link label: an unknown link must never read as a working one.
+
+Link speed and duplex are paired onto the interface rows from a second WMI
+class, so three states are possible: read successfully, unreadable, and
+disconnected. Verified on a live box that a DISCONNECTED adapter returns empty
+Speed and FullDuplex - two of four NICs on the test machine were in that state.
+
+That is the trap these tests pin. Defaulting an absent speed to 0 or an absent
+duplex to False would report every unplugged NIC as half duplex, which is the
+exact signature of the cable/port fault this field exists to catch. A column
+that cries fault on every disconnected NIC is a column nobody reads.
+"""
+
+from plugins.endpoints.api.routes import _link_label
+
+
+def test_a_gigabit_full_duplex_link_is_not_annotated():
+ """The normal case should be quiet so the abnormal case stands out."""
+ assert _link_label(1000000000, True) == '1 Gb'
+
+
+def test_half_duplex_is_called_out_because_it_is_the_actionable_case():
+ assert _link_label(100000000, False) == '100 Mb half duplex'
+
+
+def test_a_disconnected_adapter_reads_as_unknown_not_half_duplex():
+ """The live-box case: disconnected NICs return empty speed and duplex.
+
+ Reading this as half duplex would flag every unplugged NIC as a fault.
+ """
+ assert _link_label(None, None) == '-'
+
+
+def test_an_unreadable_duplex_on_a_live_link_is_not_assumed_half():
+ """Speed known, duplex not. Unknown duplex must not render as a fault."""
+ assert _link_label(10000000000, None) == '10 Gb'
+
+
+def test_zero_speed_is_unknown_rather_than_a_zero_speed_link():
+ assert _link_label(0, True) == '-'
+
+
+def test_sub_gigabit_speeds_stay_in_megabits():
+ assert _link_label(10000000, True) == '10 Mb'
+
+
+def test_speeds_do_not_render_trailing_zeros():
+ """2.5Gb NICs are common now; the label should not read '2.5000 Gb'."""
+ assert _link_label(2500000000, True) == '2.5 Gb'
diff --git a/tests/test_plugins/test_endpoints_panel_groups.py b/tests/test_plugins/test_endpoints_panel_groups.py
new file mode 100644
index 00000000..7bf3c823
--- /dev/null
+++ b/tests/test_plugins/test_endpoints_panel_groups.py
@@ -0,0 +1,65 @@
+"""The Monitors tab split: a panel in a group no tab renders disappears.
+
+This has silently eaten cards twice - once when tracked services moved out of
+'actions', once on the 'metrics' to 'monitors' rename. Both times the backend
+named a group the frontend did not render, and the panel was not shown, not
+logged, and not missed until someone went looking for it.
+
+The split is only correct if four places agree, and only two of them are Python:
+ 1. the panels' group here
+ 2. RENDERED_GROUPS in PluginAssetPanels.vue
+ 3. the tab label map in PCDetail.vue
+ 4. the v-show block that gives the tab a layout
+
+These tests pin the Python half so a later edit cannot quietly move a panel into
+a group nothing renders. The frontend half is pinned by its own spec.
+"""
+
+from plugins.endpoints.plugin import EndpointsPlugin
+
+
+# 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'}
+
+
+def _panels():
+ return {panel['id']: panel for panel in EndpointsPlugin().get_asset_panels()}
+
+
+def test_no_panel_lands_in_a_group_the_frontend_does_not_render():
+ """The failure mode that ate cards twice: a group with no tab."""
+ for panel in EndpointsPlugin().get_asset_panels():
+ group = panel.get('group', 'details')
+ assert group in RENDERED_GROUPS, (
+ f"panel {panel['id']} is in group {group!r}, which no tab renders"
+ )
+
+
+def test_tracked_services_and_processes_moved_to_their_own_group():
+ panels = _panels()
+
+ assert panels['endpoints-tracked-services']['group'] == 'serviceprocess'
+ assert panels['endpoints-tracked-processes']['group'] == 'serviceprocess'
+
+
+def test_the_service_process_tab_is_not_left_empty():
+ """A tab whose panels all moved away reads as broken, not as absent."""
+ groups = [panel.get('group', 'details')
+ for panel in EndpointsPlugin().get_asset_panels()]
+
+ assert 'serviceprocess' in groups
+
+
+def test_system_monitors_keeps_panels_of_its_own():
+ """The rename is only safe if Monitors still has content after the move."""
+ groups = [panel.get('group', 'details')
+ for panel in EndpointsPlugin().get_asset_panels()]
+
+ assert 'monitors' in groups
+
+
+def test_disk_health_stayed_with_the_system_health_panels():
+ """Disk health is the box's own health, not something running on it."""
+ assert _panels()['endpoints-diskhealth']['group'] == 'monitors'