Surface the endpoint inventory, and let an event show its message

The hardware, OS and storage inventory had columns and data but nowhere to
be read: the only per-PC card was the status keyvalue, so a machine's
processor, firmware, disks and listening ports were reachable only by
querying the database.

Adds four Monitors cards - Hardware, Operating system, Storage, Listening
ports - split by the question each answers rather than by which JSON blob
they came from. OS and BIOS share one card deliberately; apart they are
three short rows each. A card yields no fields when its columns are null,
so an endpoint on an older agent hides them rather than showing a grid of
blanks.

User context is declared last on the page. The probe feeding it is not yet
producing output, so it reads empty for now, and being last means an empty
card sits below the populated ones instead of pushing them down.

The Activity feed showed a title and an event id, but the title is the
catalog name for that id - the same text on every occurrence - so two
different faults read identically. Rows now carry the provider name, and
the stored message body expands in place when the severity badge is
clicked. Commands expand to their result message, which is the reason a
failed one failed.

The badge doubles as the expander rather than gaining a control beside it,
and it keeps its severity label when open: swapping that for "Hide" would
cost the at-a-glance severity scan down the feed. Being a button rather
than a span, it is also now keyboard reachable.

map.body is additive in the shared renderer - panels that do not declare
it render exactly as before.
This commit is contained in:
benjamin.baker
2026-09-04 19:14:16 -04:00
parent 862b290545
commit 9ac711ce2d
6 changed files with 580 additions and 17 deletions

View File

@@ -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
# =============================================================================
@@ -465,6 +540,180 @@ 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})
@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 {}
pendingreboot = None
if state.pendingreboot is not None:
pendingreboot = 'Yes' if state.pendingreboot else 'No'
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': 'Pending reboot', 'value': pendingreboot},
]
fields = [f for f in fields
if f['value'] not in (None, '')]
return success_response({'fields': fields})
@endpoints_bp.route('/asset/<int:asset_id>/storage', methods=['GET'])
@require_permission('endpoints.view')
def panel_storage(asset_id: int):
"""Table panel: fixed disks with capacity and free space.
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.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
disks = _state_json(state, 'disksjson') if state else None
if not isinstance(disks, list):
return success_response([])
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 success_response(rows)
@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)
@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.
"""
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': []})
netuse = context.get('netuse')
drives = []
if isinstance(netuse, list):
for entry in netuse:
if isinstance(entry, dict):
local = entry.get('localname') or entry.get('local') or ''
remote = entry.get('remotename') or entry.get('remote') or ''
drives.append('{} {}'.format(local, remote).strip())
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('timestamp')},
{'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 +972,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 +999,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,6 +1027,7 @@ 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]])