Endpoint telemetry, PC Detail rework and a checked backup/restore path
This commit is contained in:
@@ -87,6 +87,81 @@ def _format_uptime(seconds):
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def _state_json(state, attr):
|
||||
"""Parse one of the EndpointState inventory JSON blobs.
|
||||
|
||||
These columns are written straight from the agent snapshot, so a partial
|
||||
or malformed blob from one bad check-in must not take the whole panel
|
||||
down. Anything unparseable reads as absent.
|
||||
"""
|
||||
raw = getattr(state, attr, None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_bytes(value):
|
||||
"""Byte count -> 'N.N GB'. Sizes here are disks and RAM, never sub-MB."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if abs(value) < 1024.0 or unit == 'TB':
|
||||
return '{:.1f} {}'.format(value, unit) if unit != 'B' else '{:.0f} B'.format(value)
|
||||
value /= 1024.0
|
||||
return None
|
||||
|
||||
|
||||
def _format_agent_date(value):
|
||||
"""Agent timestamps are ISO strings, not datetimes; show the date only.
|
||||
|
||||
A BIOS release date or an OS install date is only ever read at day
|
||||
resolution, and the agent sends a full 7-digit-fraction UTC stamp.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value)
|
||||
return text[:10] if len(text) >= 10 else text
|
||||
|
||||
|
||||
# A Windows event message runs to several hundred words. It is shown in an
|
||||
# expander that is closed by default, so the cap here only exists to keep one
|
||||
# pathological message from bloating the payload - not to fit a line. Blank
|
||||
# runs are collapsed but real line breaks are kept, because the body is read
|
||||
# as a block once opened.
|
||||
_EVENT_BODY_LIMIT = 4000
|
||||
|
||||
|
||||
def _event_body(event):
|
||||
"""Readable message body for one curated event, bounded and normalized.
|
||||
|
||||
Windows messages arrive padded with long runs of blank lines that render
|
||||
as ragged gaps. Runs of blank lines collapse to one and trailing spaces
|
||||
go, but single newlines survive so the text keeps its shape when opened.
|
||||
"""
|
||||
text = (getattr(event, 'eventmessage', None) or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
lines = [line.rstrip() for line in text.splitlines()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if not line and cleaned and not cleaned[-1]:
|
||||
continue
|
||||
cleaned.append(line)
|
||||
text = '\n'.join(cleaned).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= _EVENT_BODY_LIMIT:
|
||||
return text
|
||||
return text[:_EVENT_BODY_LIMIT].rstrip() + '...'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
@@ -419,6 +494,60 @@ def _drift_for_state(state):
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _dell_updates_summary(state):
|
||||
"""Parse the cached Dell Command | Update summary off an EndpointState.
|
||||
|
||||
dellupdatesjson is written on check-in from the shopdb-dellupdates agent
|
||||
utility, which reads the log the \\ShopDB-DellCommandScan task produces.
|
||||
Null means the endpoint has never reported a scan. Tolerant of a malformed
|
||||
blob so a bad cache never breaks the panel.
|
||||
"""
|
||||
raw = getattr(state, 'dellupdatesjson', None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _dell_updates_value(state):
|
||||
"""Render the informational 'Pending Dell updates' status value.
|
||||
|
||||
Informational only - this row never claims an endpoint needs attention,
|
||||
because a pending optional driver is not a support problem. Three states are
|
||||
kept distinct on purpose, and all three are VISIBLE:
|
||||
|
||||
never scanned -> 'Never scanned'. Deliberately not 'No': those are
|
||||
different facts, and claiming 'No' would tell an operator
|
||||
the machine is current when nobody has ever checked. It
|
||||
is also not hidden - a missing row reads as a broken
|
||||
panel, so the unknown state says so out loud.
|
||||
scanned clean -> 'No'.
|
||||
pending -> 'Yes (n)', with the count because one optional update and
|
||||
fourteen urgent ones warrant different urgency.
|
||||
"""
|
||||
summary = _dell_updates_summary(state)
|
||||
if not summary:
|
||||
return 'Never scanned'
|
||||
status = summary.get('status')
|
||||
if status == 'error':
|
||||
# The scan log exists but could not be read. A different problem from
|
||||
# never having scanned, and worth saying so rather than implying the
|
||||
# endpoint was never checked.
|
||||
return 'Unknown (scan unreadable)'
|
||||
if status != 'ok':
|
||||
return 'Never scanned'
|
||||
try:
|
||||
count = int(summary.get('count') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 'Unknown (scan unreadable)'
|
||||
if count <= 0:
|
||||
return 'No'
|
||||
return 'Yes ({})'.format(count)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
@@ -432,24 +561,34 @@ def panel_status(asset_id: int):
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
def field(label, value, mono=False):
|
||||
return {'label': label, 'value': value, 'mono': mono}
|
||||
def field(label, value, mono=False, fmt=None):
|
||||
entry = {'label': label, 'value': value, 'mono': mono}
|
||||
if fmt:
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
field('Idle', _format_uptime(state.idleseconds)),
|
||||
field('Last boot',
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None,
|
||||
fmt='datetime'),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Tray', state.trayversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
fmt='datetime'),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Informational only, and appended after the null-filter above because it
|
||||
# always renders - including an explicit "Never scanned", which a hidden row
|
||||
# would leave looking like a broken panel.
|
||||
fields.append(field('Pending Dell updates', _dell_updates_value(state)))
|
||||
|
||||
# Warn-only reconciler summary: monitors site policy expects to be running
|
||||
# that the last check-in did not report as running. Surfaced as a status
|
||||
# field so an operator sees the drift on the panel; nothing is auto-started.
|
||||
@@ -465,6 +604,492 @@ def panel_status(asset_id: int):
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_hardware(asset_id: int):
|
||||
"""Keyvalue panel: processor, memory and system chassis inventory.
|
||||
|
||||
Only reported by agent 1.7.0 and later. An older agent leaves these
|
||||
columns null, so the panel yields no fields and hides itself rather than
|
||||
rendering a card full of blanks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
processor = _state_json(state, 'processorjson') or {}
|
||||
memory = _state_json(state, 'memoryjson') or {}
|
||||
|
||||
fields = [
|
||||
{'label': 'Processor', 'value': processor.get('name')},
|
||||
{'label': 'Cores', 'value': processor.get('cores')},
|
||||
{'label': 'Logical processors', 'value': processor.get('logicalprocessors')},
|
||||
{'label': 'Max clock',
|
||||
'value': '{} MHz'.format(processor['maxclockmhz'])
|
||||
if processor.get('maxclockmhz') else None},
|
||||
{'label': 'Total memory', 'value': _format_bytes(memory.get('totalbytes'))},
|
||||
{'label': 'Free memory', 'value': _format_bytes(memory.get('freebytes'))},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
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/<int:asset_id>/osinfo', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_osinfo(asset_id: int):
|
||||
"""Keyvalue panel: operating system, firmware and domain membership.
|
||||
|
||||
OS and BIOS are one card on purpose: both answer "what is this box
|
||||
running", and split across two cards each would be three short rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
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},
|
||||
{'label': 'Build', 'value': osinfo.get('build'), 'mono': True},
|
||||
{'label': 'Architecture', 'value': osinfo.get('architecture')},
|
||||
{'label': 'Installed', 'value': _format_agent_date(osinfo.get('installdate'))},
|
||||
{'label': 'Domain', 'value': state.domain},
|
||||
{'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
|
||||
if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
def _storage_rows(disks):
|
||||
"""Turn the agent's volume list into display rows.
|
||||
|
||||
freepercent is computed by the agent from 1.7.0; for a snapshot written
|
||||
by an older agent it is derived here from the byte counts so the column
|
||||
is not empty for the one field an operator actually scans.
|
||||
"""
|
||||
if not isinstance(disks, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for disk in disks:
|
||||
if not isinstance(disk, dict):
|
||||
continue
|
||||
freepercent = disk.get('freepercent')
|
||||
if freepercent is None:
|
||||
size = disk.get('sizebytes') or 0
|
||||
free = disk.get('freebytes')
|
||||
if size and free is not None:
|
||||
freepercent = round(free * 100.0 / size, 1)
|
||||
rows.append({
|
||||
'drive': disk.get('drive'),
|
||||
'label': disk.get('label') or '',
|
||||
'filesystem': disk.get('filesystem'),
|
||||
'size': _format_bytes(disk.get('sizebytes')),
|
||||
'free': _format_bytes(disk.get('freebytes')),
|
||||
'freepercent': '{}%'.format(freepercent) if freepercent is not None else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
_STORAGE_COLUMNS = [
|
||||
{'key': 'drive', 'label': 'Drive'},
|
||||
{'key': 'label', 'label': 'Label'},
|
||||
{'key': 'filesystem', 'label': 'Filesystem'},
|
||||
{'key': 'size', 'label': 'Size'},
|
||||
{'key': 'free', 'label': 'Free'},
|
||||
{'key': 'freepercent', 'label': 'Free %'},
|
||||
]
|
||||
|
||||
_DISKHEALTH_COLUMNS = [
|
||||
{'key': 'model', 'label': 'Drive'},
|
||||
{'key': 'mediatype', 'label': 'Media'},
|
||||
{'key': 'bustype', 'label': 'Bus'},
|
||||
{'key': 'healthstatus', 'label': 'Health'},
|
||||
{'key': 'verdict', 'label': 'Verdict'},
|
||||
{'key': 'source', 'label': 'Source'},
|
||||
]
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/storage', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_storage(asset_id: int):
|
||||
"""Tabs panel: volume capacity and physical drive health as two sections.
|
||||
|
||||
One card because an operator looking at a full disk and an operator looking
|
||||
at a dying disk are usually the same person in the same minute. TWO
|
||||
SECTIONS because the two do not share a key: storage is per volume (C:, D:)
|
||||
and health is per physical device, and one drive can back several volumes
|
||||
or none the operator sees. Nothing is reconciled across the sections -
|
||||
this is not the mapped-drive merge, where two sources described the same
|
||||
drive and the worse status won.
|
||||
|
||||
Both sections are emitted even when empty so the section keeps its own
|
||||
empty message; a dropped section would take its tab with it and read as
|
||||
"no disks" rather than "nothing reported".
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
disks = _state_json(state, 'disksjson') if state else None
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response({'sections': [
|
||||
{
|
||||
'label': 'Volumes',
|
||||
'columns': _STORAGE_COLUMNS,
|
||||
'rows': _storage_rows(disks),
|
||||
'empty': 'No disks reported.',
|
||||
},
|
||||
{
|
||||
'label': 'Disk health',
|
||||
'columns': _DISKHEALTH_COLUMNS,
|
||||
'rows': _diskhealth_rows(drives),
|
||||
'empty': 'No disk health reported.',
|
||||
},
|
||||
]})
|
||||
|
||||
|
||||
def _diskhealth_rows(drives):
|
||||
"""Turn the agent's disk health list into display rows, worst first.
|
||||
|
||||
Both agent sources are kept rather than merged. MSFT_PhysicalDisk is the
|
||||
only source that reports NVMe at all, and Win32_DiskDrive covers hardware
|
||||
and hosts where the Storage namespace is unavailable. When both answer for
|
||||
the same hardware, two rows that disagree is the useful signal; collapsing
|
||||
them would hide exactly the case worth looking at.
|
||||
"""
|
||||
if not isinstance(drives, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for drive in drives:
|
||||
if not isinstance(drive, dict):
|
||||
continue
|
||||
predictfailure = drive.get('predictfailure')
|
||||
if predictfailure is None:
|
||||
verdict = 'Unknown'
|
||||
elif predictfailure:
|
||||
verdict = 'Failure predicted'
|
||||
else:
|
||||
verdict = 'OK'
|
||||
rows.append({
|
||||
'deviceid': drive.get('deviceid'),
|
||||
'model': drive.get('model') or '',
|
||||
'mediatype': drive.get('mediatype') or '',
|
||||
'bustype': drive.get('bustype') or '',
|
||||
'healthstatus': drive.get('healthstatus') or 'Unknown',
|
||||
'verdict': verdict,
|
||||
'source': drive.get('source') or '',
|
||||
})
|
||||
|
||||
# Failure first, then unknown, then OK. Unknown outranks OK because a drive
|
||||
# that stopped answering is a weaker claim of health than one that said OK.
|
||||
# An unrecognised verdict sorts as unknown rather than last, so a future
|
||||
# verdict string cannot bury itself under the healthy drives.
|
||||
order = {'Failure predicted': 0, 'Unknown': 1, 'OK': 2}
|
||||
rows.sort(key=lambda row: (order.get(row['verdict'], 1),
|
||||
row['deviceid'] or ''))
|
||||
return rows
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/diskhealth', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_diskhealth(asset_id: int):
|
||||
"""Physical drive health rows.
|
||||
|
||||
No longer backs a panel of its own - the Storage card renders it as a
|
||||
section. Kept as a route because it is a stable read for callers outside
|
||||
the panel renderer and removing it would break them for a UI-only change.
|
||||
|
||||
Rows are ordered so anything predicting failure sorts first: a failing
|
||||
drive must not be below the fold on a machine with several disks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response(_diskhealth_rows(drives))
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/listeners', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_listeners(asset_id: int):
|
||||
"""Table panel: TCP sockets in LISTEN, with the owning process.
|
||||
|
||||
The listening set is the box's exposed surface, so it is sorted by port
|
||||
to make an unexpected one easy to spot. The full connection table is a
|
||||
deliberately on-demand command, not standard telemetry.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
ports = _state_json(state, 'portsjson') if state else None
|
||||
if not isinstance(ports, list):
|
||||
return success_response([])
|
||||
|
||||
rows = []
|
||||
for entry in ports:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
# Sample-data rows predate the agent listener collector and carry a
|
||||
# flat port/process shape; read both rather than showing them blank.
|
||||
port = entry.get('localport', entry.get('port'))
|
||||
rows.append({
|
||||
'localport': port,
|
||||
'localaddress': entry.get('localaddress'),
|
||||
'protocol': (entry.get('protocol') or 'tcp').upper(),
|
||||
'processname': entry.get('processname') or entry.get('process'),
|
||||
'processid': entry.get('processid'),
|
||||
})
|
||||
rows.sort(key=lambda r: (r['localport'] is None, r['localport']))
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
def _drive_letter(value):
|
||||
"""Normalise a drive to its bare letter so the two sources can be paired.
|
||||
|
||||
net use reports 'Z:' and Get-SmbMapping reports 'Z:' or occasionally a
|
||||
trailing separator, so compare on the letter alone.
|
||||
"""
|
||||
text = (value or '').strip().rstrip('\\/')
|
||||
return text.upper() or None
|
||||
|
||||
|
||||
# Worst-first. The panel shows the worse of the two sources for a drive, so a
|
||||
# share that one source calls OK and the other calls Unavailable is reported as
|
||||
# Unavailable: a drive that is broken in either view is broken for the operator.
|
||||
#
|
||||
# WARNING - ranks must stay DISTINCT across statuses that can genuinely differ.
|
||||
# _worse_drive_status resolves by min(), which breaks a tie by argument order,
|
||||
# so two statuses sharing a rank make the verdict depend on which SOURCE
|
||||
# happened to report which - the same drive reads healthy or broken based on
|
||||
# enumeration order. Deliberate synonyms are fine (disconnected/unavailable,
|
||||
# ok/connected) because either answer is the same answer. Adding a NEW status at
|
||||
# an existing rank is not. test_which_source_reported_a_status_does_not_change
|
||||
# _the_verdict in tests/test_plugins/test_endpoints_mapped_drives.py asserts
|
||||
# both directions of each pair and is what catches this; the single-direction
|
||||
# tests stay green through it.
|
||||
_DRIVE_STATUS_RANK = {
|
||||
'unavailable': 0,
|
||||
'disconnected': 0,
|
||||
'paused': 1,
|
||||
'reconnecting': 2,
|
||||
'ok': 3,
|
||||
'connected': 3,
|
||||
}
|
||||
|
||||
|
||||
def _worse_drive_status(left, right):
|
||||
"""Return whichever status is worse, preferring a known one over None.
|
||||
|
||||
An unrecognised status ranks as Reconnecting, deliberately below OK: a
|
||||
status we cannot interpret must not be assumed healthy.
|
||||
"""
|
||||
candidates = [s for s in (left, right) if s]
|
||||
if not candidates:
|
||||
return None
|
||||
unknown_rank = _DRIVE_STATUS_RANK['reconnecting']
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda s: _DRIVE_STATUS_RANK.get(str(s).strip().lower(), unknown_rank)
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
be Unavailable in one and OK or absent in the other. The probe collects both
|
||||
without reconciling them, so the disagreement survives to here.
|
||||
|
||||
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 - 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
|
||||
operator, and rendering it OK sends someone to a share that is down.
|
||||
2. Single-source drives are kept, not dropped. Get-SmbMapping is absent on
|
||||
some builds and session types; requiring agreement would silently hide
|
||||
every mapped drive on those machines.
|
||||
3. Unknown statuses rank as Reconnecting, not as OK. An unrecognised
|
||||
string must never be optimistically treated as healthy.
|
||||
|
||||
Changing any of these makes a broken drive look fine, which is a silent
|
||||
failure - the card stays green and nobody is told.
|
||||
"""
|
||||
drives = {}
|
||||
|
||||
netuse = context.get('netuse')
|
||||
if isinstance(netuse, list):
|
||||
for entry in netuse:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localname') or entry.get('local'))
|
||||
if not letter:
|
||||
continue
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotename') or entry.get('remote') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
smbmapping = context.get('smbmapping')
|
||||
if isinstance(smbmapping, list):
|
||||
for entry in smbmapping:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localpath'))
|
||||
if not letter:
|
||||
continue
|
||||
existing = drives.get(letter)
|
||||
if existing:
|
||||
existing['status'] = _worse_drive_status(
|
||||
existing.get('status'), entry.get('status'))
|
||||
existing['remote'] = existing['remote'] or entry.get('remotepath') or ''
|
||||
else:
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotepath') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for letter in sorted(drives):
|
||||
drive = drives[letter]
|
||||
# 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.
|
||||
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 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'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_usercontext(asset_id: int):
|
||||
"""Keyvalue panel: session-scoped data from the interactive user probe.
|
||||
|
||||
Mapped drives and user-owned listeners do not exist in the agent's
|
||||
session 0, so they are collected by a probe run in the logged-in user's
|
||||
session. Nothing is reported when no one is signed in, which is a valid
|
||||
state and not a failure.
|
||||
|
||||
Mapped drives arrive from two sources that can disagree; the worse of the
|
||||
two statuses is shown so a half-broken share is never rendered as healthy.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
context = _state_json(state, 'usercontextjson')
|
||||
if not isinstance(context, dict) or not context:
|
||||
return success_response({'fields': []})
|
||||
|
||||
drives = _mapped_drives(context)
|
||||
|
||||
listeners = context.get('userlisteners')
|
||||
listenercount = len(listeners) if isinstance(listeners, list) else None
|
||||
|
||||
fields = [
|
||||
{'label': 'Probed user', 'value': context.get('username')},
|
||||
{'label': 'Collected', 'value': context.get('collectedat')},
|
||||
{'label': 'Idle', 'value': _format_uptime(context.get('idleseconds'))},
|
||||
{'label': 'Mapped drives',
|
||||
'value': ', '.join(drives) if drives else None, 'mono': True},
|
||||
{'label': 'User listeners', 'value': listenercount},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
@@ -723,10 +1348,18 @@ def panel_activity(asset_id: int):
|
||||
|
||||
rows = []
|
||||
for event in events:
|
||||
# The provider name is what distinguishes two events sharing an ID,
|
||||
# and the message body is the line an operator actually reads. Both
|
||||
# are already stored; the feed just was not showing them.
|
||||
source = event.eventsource or event.eventchannel
|
||||
detail = 'Windows event {}'.format(event.eventid)
|
||||
if source:
|
||||
detail = '{} - {}'.format(detail, source)
|
||||
rows.append({
|
||||
'title': event_title(event.eventid),
|
||||
'severity': event_severity(event.eventid),
|
||||
'detail': 'Windows event {}'.format(event.eventid),
|
||||
'detail': detail,
|
||||
'body': _event_body(event),
|
||||
'eventtime': event.eventtime,
|
||||
})
|
||||
|
||||
@@ -742,7 +1375,9 @@ def panel_activity(asset_id: int):
|
||||
'title': '{}{}'.format(label, target),
|
||||
# A failed command is the one an operator needs to notice.
|
||||
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
||||
'detail': '{}{}'.format((command.status or 'queued').title(), who),
|
||||
'detail': '{}{}{}'.format('Command - ', (command.status or 'queued').title(), who),
|
||||
# A failed command's reason is the whole point of noticing it.
|
||||
'body': (command.resultmessage or '').strip() or None,
|
||||
'eventtime': command.createdat,
|
||||
})
|
||||
|
||||
@@ -768,10 +1403,37 @@ def panel_activity(asset_id: int):
|
||||
'severity': r['severity'],
|
||||
'severitycolor': colours.get(r['severity'], 'gray'),
|
||||
'detail': r['detail'],
|
||||
'body': r['body'],
|
||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||
} 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/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
@@ -809,6 +1471,99 @@ 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)
|
||||
|
||||
|
||||
# Columns for the two cards carved out of the old hand-written Network card on
|
||||
# the Details tab. That card listed current addresses with a Primary badge and
|
||||
# nothing else, which undersold what the rows actually hold.
|
||||
_IPHISTORY_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'macaddress', 'label': 'MAC'},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen'},
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
|
||||
Formatting is left to the client so it can render in the viewer's locale
|
||||
and timezone; returning a preformatted string here would pin every reader
|
||||
to the server's.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
def _communication_rows(asset_id: int):
|
||||
"""Every Communication row for an asset, newest change first.
|
||||
|
||||
These rows are an ACCIDENTAL history rather than a designed one.
|
||||
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||
then on IP and inserts when neither matches, and it never deletes - so an
|
||||
address the machine has not held for a year is still sitting here next to
|
||||
the current one. That is genuinely useful (it answers "what was this box on
|
||||
before?"), so it is surfaced as history rather than cleaned away.
|
||||
"""
|
||||
from shopdb.api import Communication
|
||||
|
||||
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||
return (Communication.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc())
|
||||
.all())
|
||||
|
||||
|
||||
def _iphistory_status(comm):
|
||||
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||
|
||||
Primary is the address the collector last picked as this box's main one.
|
||||
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||
presence in the list is a record of a past network fault, not a past
|
||||
subnet.
|
||||
"""
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if address.startswith('169.254.'):
|
||||
return 'APIPA (no DHCP)'
|
||||
if comm.isprimary:
|
||||
return 'Primary'
|
||||
if comm.ismachinenetwork:
|
||||
return 'Machine network'
|
||||
return 'Previous'
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_iphistory(asset_id: int):
|
||||
"""Table panel: every address this asset has held, newest change first.
|
||||
|
||||
Replaces the hand-written Network card that used to sit on the Details tab
|
||||
and show only the current addresses. Dates come from the row's own
|
||||
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||
is when a field on the row last moved, NOT when the address was last seen.
|
||||
An address the machine still holds and reports unchanged does not update
|
||||
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||
cannot support.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||
'status': _iphistory_status(comm),
|
||||
'firstseen': _isostamp(comm.createddate),
|
||||
'lastchanged': _isostamp(comm.modifieddate),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user