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:
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal file
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal 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:']
|
||||
@@ -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/')
|
||||
|
||||
Reference in New Issue
Block a user