Server-side RMM telemetry plugin: current-snapshot endpointstates plus append-only curated endpointevents, resolved to existing Computer assets by hostname. Adds ADR-010 asset panels (status, services, recent events), dashboard quiet and stability cards, and a minimal Fleet list view that links into existing PC detail pages. Registers the activity sidebar icon and a dev-only sample seeder. Ingest arrives on the generic collector route; the future C# agent design record lives in docs/DESIGN-DECISIONS-agent.md.
240 lines
9.3 KiB
Python
240 lines
9.3 KiB
Python
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
|
|
|
|
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
|
|
existing Computer assets so the fleet list, the PC-detail asset panels
|
|
(status / services / recent events), and the dashboard quiet + stability cards
|
|
have something to render before a real agent exists.
|
|
|
|
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
|
|
be listed and cleared without touching real agent data.
|
|
|
|
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
|
|
production image (see devnotes/live-container-and-testing-workflow.md). Do not
|
|
import it from application code.
|
|
|
|
Usage (in the API container):
|
|
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
|
|
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
|
|
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
|
|
|
from shopdb import create_app # noqa: E402
|
|
from shopdb.api import db # noqa: E402
|
|
|
|
SAMPLE_MARKER = '0.0.0-sample'
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _services(*running_then_stopped):
|
|
"""Build a services snapshot. Positional pairs are (name, state)."""
|
|
return [{'name': name, 'state': state} for name, state in running_then_stopped]
|
|
|
|
|
|
def _ports(*pairs):
|
|
return [{'port': port, 'process': process} for port, process in pairs]
|
|
|
|
|
|
def _interfaces(ip, mac):
|
|
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
|
|
|
|
|
|
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
|
|
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
|
|
# the dashboard quiet card has rows. Everything is offline-plausible because
|
|
# there is no live agent.
|
|
SAMPLE_PROFILES = [
|
|
{
|
|
'loggedinuser': 'CORP\\jmartin',
|
|
'agentversion': SAMPLE_MARKER,
|
|
'minutesago': 3,
|
|
'uptimeseconds': 4 * 86400 + 6 * 3600,
|
|
'idleseconds': 12 * 60,
|
|
'cpupercent': 14.0,
|
|
'memorypercent': 46.0,
|
|
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
|
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
|
|
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
|
|
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
|
|
'events': [(4624, -8), (4672, -8), (4698, -120)],
|
|
},
|
|
{
|
|
'loggedinuser': 'CORP\\operator2',
|
|
'agentversion': SAMPLE_MARKER,
|
|
'minutesago': 9,
|
|
'uptimeseconds': 2 * 3600 + 40 * 60,
|
|
'idleseconds': 3 * 60,
|
|
'cpupercent': 61.0,
|
|
'memorypercent': 72.0,
|
|
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
|
('WinRM', 'Stopped')),
|
|
'ports': _ports((3389, 'svchost'), (445, 'System')),
|
|
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
|
|
# Recent unexpected shutdown -> shows on the stability card.
|
|
'events': [(41, -50), (1001, -30), (4625, -12)],
|
|
},
|
|
{
|
|
'loggedinuser': None,
|
|
'agentversion': SAMPLE_MARKER,
|
|
'minutesago': 22,
|
|
'uptimeseconds': 11 * 86400,
|
|
'idleseconds': 9 * 3600,
|
|
'cpupercent': 3.0,
|
|
'memorypercent': 38.0,
|
|
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
|
('WinRM', 'Running')),
|
|
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
|
|
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
|
|
'events': [(4624, -600), (7011, -180)],
|
|
},
|
|
{
|
|
'loggedinuser': 'CORP\\nightshift',
|
|
'agentversion': SAMPLE_MARKER,
|
|
# Quiet: no report in over a day.
|
|
'minutesago': 1500,
|
|
'uptimeseconds': 30 * 60,
|
|
'idleseconds': 30 * 60,
|
|
'cpupercent': 1.0,
|
|
'memorypercent': 29.0,
|
|
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
|
|
'ports': _ports((445, 'System'),),
|
|
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
|
|
'events': [(55, -1600), (41, -1580)],
|
|
},
|
|
{
|
|
'loggedinuser': 'CORP\\qa_kiosk',
|
|
'agentversion': SAMPLE_MARKER,
|
|
'minutesago': 6,
|
|
'uptimeseconds': 6 * 3600,
|
|
'idleseconds': 45 * 60,
|
|
'cpupercent': 22.0,
|
|
'memorypercent': 55.0,
|
|
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
|
('WinRM', 'Running'), ('BITS', 'Running')),
|
|
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
|
|
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
|
|
'events': [(4624, -20), (10016, -90), (4697, -240)],
|
|
},
|
|
]
|
|
|
|
|
|
def _clear(EndpointState, EndpointEvent):
|
|
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
|
|
asset_ids = [s.assetid for s in sample_states]
|
|
events_removed = 0
|
|
if asset_ids:
|
|
events_removed = (EndpointEvent.query
|
|
.filter(EndpointEvent.assetid.in_(asset_ids))
|
|
.delete(synchronize_session=False))
|
|
states_removed = (EndpointState.query
|
|
.filter_by(agentversion=SAMPLE_MARKER)
|
|
.delete(synchronize_session=False))
|
|
db.session.commit()
|
|
print('Cleared {} sample state(s) and {} sample event(s).'
|
|
.format(states_removed, events_removed))
|
|
|
|
|
|
def _seed(limit):
|
|
from plugins.computers.models import Computer
|
|
from plugins.endpoints.models import EndpointState, EndpointEvent
|
|
from plugins.endpoints.eventcatalog import EVENT_CATALOG
|
|
|
|
computers = (Computer.query
|
|
.filter(Computer.assetid.isnot(None))
|
|
.order_by(Computer.computerid.asc())
|
|
.limit(limit)
|
|
.all())
|
|
if not computers:
|
|
print('No Computer assets found to attach sample endpoints to. '
|
|
'Seed the computers plugin first.')
|
|
return
|
|
|
|
now = _naive_utcnow()
|
|
created, updated, events_added = 0, 0, 0
|
|
|
|
for index, computer in enumerate(computers):
|
|
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
|
|
reported = now - timedelta(minutes=profile['minutesago'])
|
|
boot = reported - timedelta(seconds=profile['uptimeseconds'])
|
|
|
|
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
|
|
if state and state.agentversion not in (None, SAMPLE_MARKER):
|
|
# Never stomp a real agent's snapshot.
|
|
continue
|
|
if not state:
|
|
state = EndpointState(assetid=computer.assetid, createdat=now)
|
|
db.session.add(state)
|
|
created += 1
|
|
else:
|
|
updated += 1
|
|
|
|
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
|
|
state.loggedinuser = profile['loggedinuser']
|
|
state.agentversion = SAMPLE_MARKER
|
|
state.lastboottime = boot
|
|
state.uptimeseconds = profile['uptimeseconds']
|
|
state.idleseconds = profile['idleseconds']
|
|
state.cpupercent = profile['cpupercent']
|
|
state.memorypercent = profile['memorypercent']
|
|
state.servicesjson = json.dumps(profile['services'])
|
|
state.portsjson = json.dumps(profile['ports'])
|
|
state.interfacesjson = json.dumps(profile['interfaces'])
|
|
state.scheduledtasksjson = json.dumps([])
|
|
state.lastreportedat = reported
|
|
|
|
# Refresh this asset's sample events so re-running stays idempotent.
|
|
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
|
|
synchronize_session=False)
|
|
for eventid, offset_minutes in profile['events']:
|
|
title, severity, channel = EVENT_CATALOG.get(
|
|
eventid, ('Event {}'.format(eventid), 'info', 'System'))
|
|
event = EndpointEvent(
|
|
assetid=computer.assetid,
|
|
eventid=eventid,
|
|
eventchannel=channel,
|
|
eventsource='sample-seed',
|
|
eventtime=reported + timedelta(minutes=offset_minutes),
|
|
eventmessage='{} (sample)'.format(title),
|
|
payloadjson=json.dumps({'sample': True, 'severity': severity}),
|
|
recordedat=now,
|
|
)
|
|
db.session.add(event)
|
|
events_added += 1
|
|
|
|
db.session.commit()
|
|
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
|
|
'across {} computer(s).'
|
|
.format(created, updated, events_added, len(computers)))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--clear', action='store_true',
|
|
help='Remove sample-marked endpoint states and their events.')
|
|
parser.add_argument('--limit', type=int, default=5,
|
|
help='Max number of computers to attach sample endpoints to.')
|
|
args = parser.parse_args()
|
|
|
|
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
|
with app.app_context():
|
|
from plugins.endpoints.models import EndpointState, EndpointEvent
|
|
if args.clear:
|
|
_clear(EndpointState, EndpointEvent)
|
|
else:
|
|
_seed(args.limit)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|