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.
This commit is contained in:
benjamin.baker
2026-09-05 07:12:21 -04:00
parent e00bb44d6b
commit 0c9bd1ed32
5 changed files with 230 additions and 20 deletions

View File

@@ -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

View File

@@ -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/<int:asset_id>/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/<int:asset_id>/usercontext', methods=['GET'])

View File

@@ -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

View File

@@ -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:']

View File

@@ -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/')