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:
@@ -52,6 +52,8 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||
'scheduledtasks', 'applications', 'events',
|
||||
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||
'pendingreboot', 'usercontext',
|
||||
})
|
||||
|
||||
|
||||
@@ -472,6 +474,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
{'key': 'detail'},
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
],
|
||||
# Expands in place on click. The message body is several
|
||||
# hundred words of Windows boilerplate around the one or
|
||||
# two lines that matter, so it stays collapsed until asked
|
||||
# for rather than pushing every other row off the card.
|
||||
'body': 'body',
|
||||
},
|
||||
'empty': 'Nothing recorded for this endpoint yet.',
|
||||
'position': 20,
|
||||
@@ -524,6 +531,85 @@ class EndpointsPlugin(BasePlugin):
|
||||
# the installed-software inventory
|
||||
'group': 'software',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-hardware',
|
||||
'title': 'Hardware',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/hardware',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No hardware inventory reported.',
|
||||
'position': 14,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-osinfo',
|
||||
'title': 'Operating system',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/osinfo',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No OS inventory reported.',
|
||||
'position': 15,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-storage',
|
||||
'title': 'Storage',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/storage',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'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 %'},
|
||||
],
|
||||
'empty': 'No disks reported.',
|
||||
'position': 17,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# The listening set is the box's exposed network surface. The
|
||||
# full connection table is deliberately not here: it is an
|
||||
# on-demand command, because a per-cycle capture of every
|
||||
# connection is a lot of rows nobody reads.
|
||||
'id': 'endpoints-listeners',
|
||||
'title': 'Listening ports',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/listeners',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'localport', 'label': 'Port'},
|
||||
{'key': 'protocol', 'label': 'Protocol'},
|
||||
{'key': 'localaddress', 'label': 'Address'},
|
||||
{'key': 'processname', 'label': 'Process'},
|
||||
{'key': 'processid', 'label': 'PID'},
|
||||
],
|
||||
'empty': 'No listening ports reported.',
|
||||
'position': 19,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Session-scoped data collected by a probe in the interactive
|
||||
# user's session, so it is absent whenever nobody is signed in.
|
||||
# Last on the page: it is the least reliably present card, and
|
||||
# an empty one at the bottom reads as "not reported" rather
|
||||
# than pushing the populated cards down.
|
||||
'id': 'endpoints-usercontext',
|
||||
'title': 'User context',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/usercontext',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No user session data reported.',
|
||||
'position': 40,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Operator-submitted issue reports: a free-text note and an
|
||||
# optional screenshot the operator captured from the tray. Sits
|
||||
@@ -636,6 +722,49 @@ class EndpointsPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'description': 'Scheduled task inventory + last run.',
|
||||
},
|
||||
'disks': {
|
||||
'type': 'array',
|
||||
'description': 'Fixed local disks: {drive, label, '
|
||||
'filesystem, sizebytes, freebytes, '
|
||||
'usedbytes, freepercent}. Newest '
|
||||
'snapshot, replaced on every report.',
|
||||
},
|
||||
'bios': {
|
||||
'type': 'object',
|
||||
'description': 'Firmware identity: vendor, version, '
|
||||
'release date.',
|
||||
},
|
||||
'memory': {
|
||||
'type': 'object',
|
||||
'description': 'Physical memory totals in bytes.',
|
||||
},
|
||||
'processor': {
|
||||
'type': 'object',
|
||||
'description': 'CPU identity, core and logical '
|
||||
'processor counts, max clock.',
|
||||
},
|
||||
'osinfo': {
|
||||
'type': 'object',
|
||||
'description': 'OS build, version, and install date.',
|
||||
},
|
||||
'domain': {
|
||||
'type': 'string',
|
||||
'description': 'Windows domain or workgroup membership.',
|
||||
},
|
||||
'pendingreboot': {
|
||||
'type': 'boolean',
|
||||
'description': 'True when the endpoint is holding a '
|
||||
'reboot. Absent means never reported, '
|
||||
'which is not the same as false.',
|
||||
},
|
||||
'usercontext': {
|
||||
'type': 'object',
|
||||
'description': 'Session-scoped data collected by a probe '
|
||||
'running as the signed-in user: mapped '
|
||||
'drives (netuse), user-owned listeners, '
|
||||
'and idle seconds. Absent when nobody is '
|
||||
'signed in.',
|
||||
},
|
||||
'applications': {
|
||||
'type': 'array',
|
||||
'description': 'Installed-software SBOM from both '
|
||||
@@ -663,6 +792,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
'required': ['eventid'],
|
||||
'properties': {
|
||||
'eventid': {'type': 'integer'},
|
||||
'eventrecordid': {
|
||||
'type': 'integer',
|
||||
'description': 'Windows event record id; '
|
||||
'unique per channel per '
|
||||
'machine, used to dedup.',
|
||||
},
|
||||
'eventchannel': {'type': 'string'},
|
||||
'eventsource': {'type': 'string'},
|
||||
'eventtime': {'type': 'string',
|
||||
@@ -846,6 +981,33 @@ class EndpointsPlugin(BasePlugin):
|
||||
if 'scheduledtasks' in payload:
|
||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
||||
|
||||
# Hardware/OS inventory. Same presence guard as above: a report that
|
||||
# omits a key must not wipe the last-known value. These change rarely
|
||||
# (a RAM upgrade, a BIOS flash), so a transient collection failure on
|
||||
# one cycle should leave the previous snapshot standing rather than
|
||||
# blank the asset record.
|
||||
if 'disks' in payload:
|
||||
state.disksjson = _as_json(payload.get('disks'))
|
||||
if 'bios' in payload:
|
||||
state.biosjson = _as_json(payload.get('bios'))
|
||||
if 'memory' in payload:
|
||||
state.memoryjson = _as_json(payload.get('memory'))
|
||||
if 'processor' in payload:
|
||||
state.processorjson = _as_json(payload.get('processor'))
|
||||
if 'osinfo' in payload:
|
||||
state.osinfojson = _as_json(payload.get('osinfo'))
|
||||
if 'domain' in payload:
|
||||
state.domain = payload.get('domain')
|
||||
if 'pendingreboot' in payload:
|
||||
state.pendingreboot = payload.get('pendingreboot')
|
||||
|
||||
# Session-scoped probe output. The agent repeats its last result between
|
||||
# probe runs and omits the key entirely when nobody is signed in, so the
|
||||
# presence guard preserves the last known mapped drives across an
|
||||
# unattended period instead of flapping the field to null.
|
||||
if 'usercontext' in payload:
|
||||
state.usercontextjson = _as_json(payload.get('usercontext'))
|
||||
|
||||
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
|
||||
# table (endpointapplications), not a JSON blob on the state, so it can
|
||||
# later join the applications catalog ("Managed Applications"). Only
|
||||
@@ -897,6 +1059,19 @@ class EndpointsPlugin(BasePlugin):
|
||||
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
|
||||
|
||||
stored_events = 0
|
||||
skipped_events = 0
|
||||
|
||||
# Records the agent already sent must not be appended a second time.
|
||||
# The agent carries a per-channel watermark so the steady state is
|
||||
# "only what is new", but a reinstalled agent, a restored image, or two
|
||||
# overlapping reports can all replay records. Resolve that here rather
|
||||
# than relying on the unique index to raise, because an IntegrityError
|
||||
# would abort the whole collector transaction and lose the state upsert
|
||||
# along with it.
|
||||
#
|
||||
# One query for the whole batch, not one per record: a seeding agent
|
||||
# sends up to the per-channel cap at once.
|
||||
candidates = []
|
||||
for record in (payload.get('events') or []):
|
||||
try:
|
||||
eventid = int(record.get('eventid'))
|
||||
@@ -906,10 +1081,45 @@ class EndpointsPlugin(BasePlugin):
|
||||
if eventid not in CURATED_EVENT_IDS:
|
||||
# Outside the catalog: ignored on purpose, not an error.
|
||||
continue
|
||||
|
||||
try:
|
||||
eventrecordid = record.get('eventrecordid')
|
||||
eventrecordid = (int(eventrecordid)
|
||||
if eventrecordid is not None else None)
|
||||
except (TypeError, ValueError):
|
||||
eventrecordid = None
|
||||
|
||||
candidates.append((eventid, eventrecordid, record))
|
||||
|
||||
keyed = [(record.get('eventchannel'), eventrecordid)
|
||||
for _e, eventrecordid, record in candidates
|
||||
if eventrecordid is not None]
|
||||
already_stored = set()
|
||||
if keyed:
|
||||
rows = (db.session.query(EndpointEvent.eventchannel,
|
||||
EndpointEvent.eventrecordid)
|
||||
.filter(EndpointEvent.assetid == assetid,
|
||||
EndpointEvent.eventrecordid.in_(
|
||||
[recordid for _c, recordid in keyed]))
|
||||
.all())
|
||||
already_stored = {(channel, recordid) for channel, recordid in rows}
|
||||
|
||||
for eventid, eventrecordid, record in candidates:
|
||||
eventchannel = record.get('eventchannel')
|
||||
if eventrecordid is not None:
|
||||
key = (eventchannel, eventrecordid)
|
||||
if key in already_stored:
|
||||
skipped_events += 1
|
||||
continue
|
||||
# Guard against the same record appearing twice inside one
|
||||
# payload as well as against rows already in the table.
|
||||
already_stored.add(key)
|
||||
|
||||
db.session.add(EndpointEvent(
|
||||
assetid=assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=record.get('eventchannel'),
|
||||
eventrecordid=eventrecordid,
|
||||
eventchannel=eventchannel,
|
||||
eventsource=record.get('eventsource'),
|
||||
eventtime=_parse_dt(record.get('eventtime')),
|
||||
eventmessage=record.get('eventmessage'),
|
||||
@@ -917,6 +1127,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
))
|
||||
stored_events += 1
|
||||
|
||||
if skipped_events:
|
||||
logger.debug(
|
||||
'endpoint %s replayed %d already-stored event record(s)',
|
||||
hostname, skipped_events)
|
||||
|
||||
# flush, not commit: the collector dispatcher owns the transaction.
|
||||
db.session.flush()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user