add endpoints plugin with fleet list view and monitoring drafts
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.
This commit is contained in:
5
plugins/endpoints/models/__init__.py
Normal file
5
plugins/endpoints/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin models."""
|
||||
|
||||
from .endpoint import EndpointState, EndpointEvent
|
||||
|
||||
__all__ = ['EndpointState', 'EndpointEvent']
|
||||
163
plugins/endpoints/models/endpoint.py
Normal file
163
plugins/endpoints/models/endpoint.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Endpoint RMM telemetry models.
|
||||
|
||||
Two shapes, one per concern:
|
||||
|
||||
EndpointState The cached CURRENT snapshot for an asset - one row per asset,
|
||||
overwritten in place on every agent report. Holds the fast-
|
||||
moving metrics (uptime, idle, last boot, logged-in user) and
|
||||
the latest inventory snapshots (services/processes, open ports,
|
||||
interfaces, scheduled tasks) as serialized JSON. This is the
|
||||
cached-current-value half of the ledger pattern: it moves in
|
||||
the SAME commit as any EndpointEvent rows the report carries.
|
||||
|
||||
EndpointEvent Append-only record of a curated Windows Event Log entry. The
|
||||
agent only forwards a fixed catalog of IDs (logon/logoff,
|
||||
privilege use, service install, unexpected shutdown, DCOM,
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None.
|
||||
|
||||
Every datetime this plugin stores is naive UTC. Serializing without the Z
|
||||
leaves the receiver to guess, and JavaScript guesses browser-local.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class EndpointState(db.Model):
|
||||
"""Latest reported state for a single endpoint (one row per asset)."""
|
||||
|
||||
__tablename__ = 'endpointstates'
|
||||
|
||||
endpointstateid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The asset this state belongs to. Unique: exactly one current snapshot per
|
||||
# endpoint. Resolved from the reported hostname against the computers plugin.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Read-only view of the owning asset. No cascade or backref: the asset side
|
||||
# must not gain a dependency on this plugin.
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
hostname = db.Column(db.String(100), nullable=True)
|
||||
loggedinuser = db.Column(db.String(100), nullable=True)
|
||||
agentversion = db.Column(db.String(40), nullable=True)
|
||||
|
||||
# Fast-moving metrics
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
uptimeseconds = db.Column(db.BigInteger, nullable=True)
|
||||
idleseconds = db.Column(db.BigInteger, nullable=True)
|
||||
cpupercent = db.Column(db.Float, nullable=True)
|
||||
memorypercent = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Latest inventory snapshots, serialized JSON (MEDIUMTEXT). Kept as the
|
||||
# newest snapshot rather than one row per sample: the agent reports the full
|
||||
# list each cycle and history of these lists is not what operators ask for.
|
||||
servicesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
portsjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# When the agent last successfully reported. There is no heartbeat separate
|
||||
# from the report, so silence past the quiet-hours window is the only
|
||||
# evidence an endpoint has stopped checking in.
|
||||
lastreportedat = db.Column(db.DateTime, nullable=True, index=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointstates_lastreportedat', 'lastreportedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointState {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointstateid': self.endpointstateid,
|
||||
'assetid': self.assetid,
|
||||
'hostname': self.hostname,
|
||||
'loggedinuser': self.loggedinuser,
|
||||
'agentversion': self.agentversion,
|
||||
'lastboottime': _utciso(self.lastboottime),
|
||||
'uptimeseconds': self.uptimeseconds,
|
||||
'idleseconds': self.idleseconds,
|
||||
'cpupercent': self.cpupercent,
|
||||
'memorypercent': self.memorypercent,
|
||||
'lastreportedat': _utciso(self.lastreportedat),
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
"""A single curated Windows Event Log record forwarded by the agent."""
|
||||
|
||||
__tablename__ = 'endpointevents'
|
||||
|
||||
endpointeventid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
# The Windows Event ID (for example 4625 failed logon, 41 unexpected
|
||||
# shutdown, 1001 application crash). Indexed because alerting queries filter
|
||||
# on it.
|
||||
eventid = db.Column(db.Integer, nullable=False, index=True)
|
||||
|
||||
# Event Log channel the record came from: Security, System, Application.
|
||||
eventchannel = db.Column(db.String(50), nullable=True)
|
||||
eventsource = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# When the event happened on the endpoint (from the record), not when it
|
||||
# reached ShopDB.
|
||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||
|
||||
eventmessage = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Structured fields the agent extracted for this ID (for example the target
|
||||
# account on a 4625). Advisory index only, not part of dedup.
|
||||
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointEvent {self.eventid} asset={self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointeventid': self.endpointeventid,
|
||||
'assetid': self.assetid,
|
||||
'eventid': self.eventid,
|
||||
'eventchannel': self.eventchannel,
|
||||
'eventsource': self.eventsource,
|
||||
'eventtime': _utciso(self.eventtime),
|
||||
'eventmessage': self.eventmessage,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
Reference in New Issue
Block a user