From 0c9bd1ed321c28f6acc33416bd4639a44543fbf2 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sat, 5 Sep 2026 07:12:21 -0400 Subject: [PATCH] add mapped drives card Mapped drives existed only as a comma-joined line in the user context card, which is fine for three drives and unreadable for a dozen. They now get their own list card in Monitors: one row per letter, remote path as meta, health as a badge. Reuses the statecolor/levels vocabulary the tracked-services cards already declare rather than new CSS. Only unhealthy drives are coloured. Green on every working drive makes the broken one just another badge in a row of badges, and the card exists to make the exception findable at a glance. Unhealthy rows sort first for the same reason - a user with a dozen mappings should not hunt for the broken one - and the sort is on health alone, so drive-letter order survives within each group and the list does not reshuffle between refreshes. The merge logic is untouched. _mapped_drive_rows now returns structured rows and the user-context summary line is built from them, so the card and the line cannot drift apart about a drive's status. The two silent-failure paths are mutation-tested: forcing every badge green failed 7 tests, and the first sort test passed a mutation that added a secondary key because every broken drive in it said the same word - it now uses statuses that would reorder under a text sort. Also corrects a stale warning claiming the merge is untested; it has been covered since Phase 1. --- .../notes/endpoint-telemetry-and-ui-phases.md | 23 +++- plugins/endpoints/api/routes.py | 74 ++++++++--- plugins/endpoints/plugin.py | 33 +++++ .../test_endpoints_mapped_drives_card.py | 118 ++++++++++++++++++ tests/test_plugins/test_endpoints_plugin.py | 2 +- 5 files changed, 230 insertions(+), 20 deletions(-) create mode 100644 tests/test_plugins/test_endpoints_mapped_drives_card.py diff --git a/docs/notes/endpoint-telemetry-and-ui-phases.md b/docs/notes/endpoint-telemetry-and-ui-phases.md index 699d48a9..5e39258e 100644 --- a/docs/notes/endpoint-telemetry-and-ui-phases.md +++ b/docs/notes/endpoint-telemetry-and-ui-phases.md @@ -228,19 +228,34 @@ card now. Depends on Phase 2 data existing. -- **Mapped drives card** - `Get-SmbMapping`-shaped. `Disconnected` in red, - `Ok` in green. Reuse existing badge/severity styling rather than inventing - new CSS. Room reserved for the future one-click fix action. +- **Mapped drives card** - DONE. `list` render at position 21 in Monitors, one + row per drive letter with the remote path as meta and a status badge. Reuses + the existing status/badge vocabulary (`statecolor` + the ok/warning/critical + levels the tracked-services cards already declare) rather than new CSS. + Only UNHEALTHY drives are coloured. Green on every working drive makes the + broken one just another badge in a row of badges; the card exists to make the + exception findable. For the same reason unhealthy rows sort first - a user + with a dozen mappings should not hunt for the broken one - and the sort is on + health ALONE so drive-letter order survives within each group and the list + does not reshuffle between refreshes. + The merge itself was not touched. `_mapped_drive_rows` now returns structured + rows and `_mapped_drives` (the user-context summary line) is built on top of + it, so the card and the summary cannot drift apart about a drive's status. + Room reserved for the one-click reconnect: the `list` render already supports + a row action, so adding it is a map/commandendpoint change, not a new card. - **Storage card** - superseded. Disk health shipped in Phase 2 as its own "Disk health" card (position 18, directly under Storage) instead of being folded in: Storage is per volume, disk health is per physical device, so they do not share a key. Rows sort failure-first, then unknown, then OK - a failing drive must not sit below the fold on a multi-disk box, and a drive that stopped answering is a weaker claim of health than one that said OK. + Later merged into a single two-section card; see the entry below. - **Windows Update card** - BLOCKED, not scheduled. Depends on Phase 2 data that is pinned pending a COM-interference investigation. Do not build the card until the collection question is settled. -- Boot type placement to be decided when the data is visible. +- Boot type placement - DONE in Phase 3. Rendered in the OS card only when fast + startup applies, because that is the case where uptime understates how long + the kernel has been up. A permanent "Cold" row would be noise. - **Storage + Disk health into one card with two collapsible sections** - DONE. VISUAL GROUPING ONLY. These two tables are not merged into shared rows: Storage is per volume, Disk health is per physical device, and one drive can diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index ebfdf294..cb8453f4 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -864,8 +864,8 @@ def _worse_drive_status(left, right): ) -def _mapped_drives(context): - """Merge the two mapped-drive views into one row per drive letter. +def _mapped_drive_rows(context): + """Merge the two mapped-drive views into one structured row per letter. Windows exposes mapped drives through Win32_NetworkConnection (the 'net use' table) and through Get-SmbMapping, and they do not always agree - a share can @@ -875,8 +875,9 @@ def _mapped_drives(context): A drive present in only one source is still a drive: it is reported with the status that source gave it, rather than dropped for lack of a second opinion. - WARNING - this function is not covered by tests. Three behaviours here are - load-bearing and easy to break while "simplifying": + WARNING - three behaviours here are load-bearing and easy to break while + "simplifying". They are covered by tests/test_plugins/test_endpoints_mapped + _drives.py; if you touch this, read those first: 1. Worse-status-wins. Never report the healthier of the two sources. A drive that either source calls Unavailable IS unavailable to the @@ -888,8 +889,7 @@ def _mapped_drives(context): string must never be optimistically treated as healthy. Changing any of these makes a broken drive look fine, which is a silent - failure - the panel stays green and nobody is told. If you touch this, - add the tests first. + failure - the card stays green and nobody is told. """ drives = {} @@ -928,20 +928,64 @@ def _mapped_drives(context): rows = [] for letter in sorted(drives): drive = drives[letter] - status = (drive.get('status') or '').strip() - remote = (drive.get('remote') or '').strip() - label = '{} {}'.format(letter, remote).strip() # A drive neither source gave a status for is Unknown, never blank. An # unannotated row reads as healthy, so a failed check would render as a # working drive - the one outcome this card must never produce. - if not status: - status = 'Unknown' + status = (drive.get('status') or '').strip() or 'Unknown' + healthy = status.lower() in ('ok', 'connected') + rows.append({ + 'drive': letter, + 'remote': (drive.get('remote') or '').strip(), + 'statuslabel': status, + # Only unhealthy drives are coloured. Green on every working drive + # makes the broken one just another badge in a row of badges; the + # card's job is to make the exception findable at a glance. + 'statecolor': 'green' if healthy else 'red', + }) + return rows + + +def _mapped_drives(context): + """The mapped drives as one-line labels, for the user context card. + + Built on _mapped_drive_rows so the summary line and the card can never + disagree about a drive's status. + """ + labels = [] + for row in _mapped_drive_rows(context): + label = '{} {}'.format(row['drive'], row['remote']).strip() # Only the problem states are annotated. Tagging every healthy drive # with "(OK)" would bury the one that is not. - if status.lower() not in ('ok', 'connected'): - label = '{} ({})'.format(label, status) - rows.append(label) - return rows + if row['statecolor'] != 'green': + label = '{} ({})'.format(label, row['statuslabel']) + labels.append(label) + return labels + + +def _mapped_drives_unhealthy_first(rows): + """Order drive rows so anything not healthy sorts first. + + A user with a dozen mappings should not have to hunt for the broken one. + Sorting only on health keeps it stable: drive-letter order survives within + each group, so the list does not reshuffle between refreshes. + """ + return sorted(rows, key=lambda row: row['statecolor'] == 'green') + + +@endpoints_bp.route('/asset//mappeddrives', methods=['GET']) +@require_permission('endpoints.view') +def panel_mappeddrives(asset_id: int): + """List panel: mapped network drives with a health badge each. + + Session-scoped: mapped drives do not exist in the agent's session 0, so + these come from the interactive user probe. Nothing reported when nobody + is signed in, which is a valid state and not a failure. + """ + state = EndpointState.query.filter_by(assetid=asset_id).first() + context = _state_json(state, 'usercontextjson') if state else None + if not isinstance(context, dict) or not context: + return success_response([]) + return success_response(_mapped_drives_unhealthy_first(_mapped_drive_rows(context))) @endpoints_bp.route('/asset//usercontext', methods=['GET']) diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index f09d3298..a08506e5 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -557,6 +557,39 @@ class EndpointsPlugin(BasePlugin): 'position': 15, 'group': 'monitors', }, + { + # Session-scoped, so it sits with the other things watched + # rather than acted on. The user context card still carries a + # one-line summary; this is the same data with room to show a + # remote path and a per-drive badge, and both are built from + # _mapped_drive_rows so they cannot disagree. + # + # Room reserved here for the future one-click reconnect: the + # list render already supports a row action, so adding it is a + # map/commandendpoint change rather than a new card. + 'id': 'endpoints-mappeddrives', + 'title': 'Mapped drives', + 'assettypes': ['computer'], + 'endpoint': '/api/endpoints/asset/{assetid}/mappeddrives', + 'render': 'list', + 'collapsible': True, + 'status': { + 'key': 'statecolor', + 'levels': { + 'green': 'ok', + 'yellow': 'warning', + 'red': 'critical', + }, + }, + 'map': { + 'title': 'drive', + 'meta': [{'key': 'remote', 'mono': True}], + 'badge': {'label': 'statuslabel', 'color': 'statecolor'}, + }, + 'empty': 'No mapped drives reported.', + 'position': 21, + 'group': 'monitors', + }, { # Volumes and physical drive health in one card, as two # sections. VISUAL GROUPING ONLY: the two tables share no key diff --git a/tests/test_plugins/test_endpoints_mapped_drives_card.py b/tests/test_plugins/test_endpoints_mapped_drives_card.py new file mode 100644 index 00000000..4600527a --- /dev/null +++ b/tests/test_plugins/test_endpoints_mapped_drives_card.py @@ -0,0 +1,118 @@ +"""The mapped drives card: structured rows, badges, and row order. + +The merge logic itself is pinned in test_endpoints_mapped_drives.py. These +tests cover what the CARD adds on top: a per-drive badge colour and the row +order that decides what an operator sees first. + +The failure mode this guards is silent: a broken drive rendered green, or +sorted below the fold, leaves the card looking healthy and tells nobody. +""" +from plugins.endpoints.api.routes import ( + _mapped_drive_rows, _mapped_drives, _mapped_drives_unhealthy_first, +) + + +def _context(netuse=None, smbmapping=None): + context = {} + if netuse is not None: + context['netuse'] = netuse + if smbmapping is not None: + context['smbmapping'] = smbmapping + return context + + +def _netuse(local, remote, status): + return {'localname': local, 'remotename': remote, 'status': status} + + +def test_a_healthy_drive_is_green(): + rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])) + assert rows[0]['statecolor'] == 'green' + assert rows[0]['statuslabel'] == 'OK' + + +def test_a_disconnected_drive_is_red(): + rows = _mapped_drive_rows( + _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')])) + assert rows[0]['statecolor'] == 'red' + + +def test_connected_counts_as_healthy_alongside_ok(): + """net use and Get-SmbMapping use different words for the same state.""" + rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Connected')])) + assert rows[0]['statecolor'] == 'green' + + +def test_a_drive_with_no_status_is_red_not_green(): + """An uncoloured row reads as working. Unknown health is not health.""" + rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', None)])) + assert rows[0]['statuslabel'] == 'Unknown' + assert rows[0]['statecolor'] == 'red' + + +def test_an_unrecognised_status_is_not_coloured_green(): + rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Weird')])) + assert rows[0]['statecolor'] == 'red' + + +def test_the_worse_of_two_sources_drives_the_badge_colour(): + """The merge already picks the worse status; the badge must follow it.""" + rows = _mapped_drive_rows(_context( + netuse=[_netuse('Z:', r'\\srv\share', 'OK')], + smbmapping=[{'localpath': 'Z:', 'remotepath': r'\\srv\share', + 'status': 'Disconnected'}], + )) + assert len(rows) == 1 + assert rows[0]['statecolor'] == 'red' + + +def test_the_remote_path_is_carried_as_its_own_field(): + """The card shows the path separately rather than glued into the title.""" + rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])) + assert rows[0]['drive'] == 'Z:' + assert rows[0]['remote'] == r'\\srv\share' + + +def test_the_summary_line_and_the_card_agree_about_a_broken_drive(): + """Both are built from the same rows, so they cannot drift apart.""" + context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')]) + assert _mapped_drive_rows(context)[0]['statecolor'] == 'red' + assert '(Disconnected)' in _mapped_drives(context)[0] + + +def test_a_healthy_drive_is_not_annotated_in_the_summary_line(): + context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]) + assert _mapped_drives(context) == [r'Z: \\srv\share'] + + +def test_no_user_context_yields_no_rows(): + assert _mapped_drive_rows({}) == [] + + +def test_broken_drives_sort_above_healthy_ones(): + """The broken one must be on screen without scrolling past nine good ones.""" + rows = _mapped_drive_rows(_context(netuse=[ + _netuse('H:', r'\\srv\home', 'OK'), + _netuse('S:', r'\\srv\shared', 'OK'), + _netuse('Z:', r'\\srv\dead', 'Disconnected'), + ])) + ordered = _mapped_drives_unhealthy_first(rows) + assert [row['drive'] for row in ordered] == ['Z:', 'H:', 'S:'] + + +def test_drive_letter_order_survives_within_each_health_group(): + """Sorting only on health keeps the list from reshuffling on refresh. + + The unhealthy drives here carry DIFFERENT status words, chosen so that + sorting on the status text would reorder them (Disconnected before + Unavailable) against drive-letter order. A secondary sort key would pass + a test where every broken drive said the same thing. + """ + rows = _mapped_drive_rows(_context(netuse=[ + _netuse('A:', r'\\srv\a', 'OK'), + _netuse('B:', r'\\srv\b', 'Unavailable'), + _netuse('C:', r'\\srv\c', 'Connected'), + _netuse('D:', r'\\srv\d', 'Disconnected'), + ])) + ordered = _mapped_drives_unhealthy_first(rows) + assert [row['drive'] for row in ordered] == ['B:', 'D:', 'A:', 'C:'] diff --git a/tests/test_plugins/test_endpoints_plugin.py b/tests/test_plugins/test_endpoints_plugin.py index 583618f5..45d927cc 100644 --- a/tests/test_plugins/test_endpoints_plugin.py +++ b/tests/test_plugins/test_endpoints_plugin.py @@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped(): """Every asset panel targets computer assets only, with an endpoint.""" plugin = EndpointsPlugin() panels = plugin.get_asset_panels() - assert len(panels) == 12 + assert len(panels) == 13 for panel in panels: assert panel['assettypes'] == ['computer'] assert panel['endpoint'].startswith('/api/endpoints/')