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'