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.
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""Endpoints plugin API endpoints (read + dashboard).
|
|
|
|
Ingest is NOT here: agent reports arrive on the generic collector route
|
|
POST /api/collector/endpoints (ADR-006), authorized by a collector-scoped
|
|
service token, and are handled by EndpointsPlugin.apply_collector_payload.
|
|
These routes are the human-facing read surface, gated by user JWT + RBAC.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.api import (
|
|
db, Asset, Setting, success_response, error_response, paginated_response,
|
|
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
|
)
|
|
|
|
from ..models import EndpointState, EndpointEvent
|
|
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
|
|
|
endpoints_bp = Blueprint('endpoints', __name__)
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _format_uptime(seconds):
|
|
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
|
|
if seconds is None:
|
|
return None
|
|
try:
|
|
seconds = int(seconds)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
days, rem = divmod(seconds, 86400)
|
|
hours, rem = divmod(rem, 3600)
|
|
minutes = rem // 60
|
|
parts = []
|
|
if days:
|
|
parts.append('{}d'.format(days))
|
|
if hours:
|
|
parts.append('{}h'.format(hours))
|
|
parts.append('{}m'.format(minutes))
|
|
return ' '.join(parts)
|
|
|
|
|
|
# =============================================================================
|
|
# Endpoint state
|
|
# =============================================================================
|
|
|
|
@endpoints_bp.route('/', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def list_endpoints():
|
|
"""List reported endpoint states.
|
|
|
|
Joins through Computer so each row carries computerid and can deep-link
|
|
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
|
|
exists for hostnames resolved to a Computer, so the join is not lossy.
|
|
"""
|
|
from plugins.computers.models import Computer
|
|
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = (db.session.query(EndpointState, Computer.computerid)
|
|
.join(Asset, Asset.assetid == EndpointState.assetid)
|
|
.join(Computer, Computer.assetid == EndpointState.assetid)
|
|
.filter(Asset.isactive.is_(True)))
|
|
|
|
if search := request.args.get('search'):
|
|
query = query.filter(EndpointState.hostname.ilike(f'%{search}%'))
|
|
|
|
query = query.order_by(EndpointState.lastreportedat.desc())
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
data = []
|
|
for state, computerid in items:
|
|
row = state.to_dict()
|
|
row['computerid'] = computerid
|
|
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
|
data.append(row)
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def get_endpoint(asset_id: int):
|
|
"""Get the current state for one endpoint by asset id."""
|
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
|
|
|
if not state:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'No endpoint state for asset {asset_id}',
|
|
http_code=404,
|
|
)
|
|
|
|
return success_response(state.to_dict())
|
|
|
|
|
|
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def list_endpoint_events(asset_id: int):
|
|
"""List curated Windows events for one endpoint, newest first."""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = EndpointEvent.query.filter_by(assetid=asset_id)
|
|
|
|
if eventid := request.args.get('eventid'):
|
|
try:
|
|
query = query.filter(EndpointEvent.eventid == int(eventid))
|
|
except (TypeError, ValueError):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'eventid must be an integer')
|
|
|
|
query = query.order_by(EndpointEvent.eventtime.desc())
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
data = [e.to_dict() for e in items]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
# =============================================================================
|
|
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
|
|
# =============================================================================
|
|
|
|
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def panel_status(asset_id: int):
|
|
"""Keyvalue panel: the endpoint's current status snapshot.
|
|
|
|
Single indexed lookup on endpointstates.assetid. Returns the {fields:[...]}
|
|
shape the keyvalue renderer expects; an absent state yields no fields so the
|
|
panel hides itself.
|
|
"""
|
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
|
if not state:
|
|
return success_response({'fields': []})
|
|
|
|
def field(label, value, mono=False):
|
|
return {'label': label, 'value': value, 'mono': mono}
|
|
|
|
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),
|
|
field('CPU %', state.cpupercent),
|
|
field('Memory %', state.memorypercent),
|
|
field('Agent', state.agentversion, mono=True),
|
|
field('Last reported',
|
|
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
|
]
|
|
fields = [f for f in fields if f['value'] not in (None, '')]
|
|
return success_response({'fields': fields})
|
|
|
|
|
|
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def panel_services(asset_id: int):
|
|
"""List panel: monitored services/processes with a run-state badge per row.
|
|
|
|
Reshapes the latest servicesjson snapshot into rows the list renderer maps
|
|
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
|
|
an unknown state renders as an Unknown badge rather than failing.
|
|
"""
|
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
|
if not state or not state.servicesjson:
|
|
return success_response([])
|
|
|
|
try:
|
|
services = json.loads(state.servicesjson)
|
|
except (TypeError, ValueError):
|
|
return success_response([])
|
|
|
|
rows = []
|
|
for entry in (services or []):
|
|
if isinstance(entry, dict):
|
|
name = entry.get('name') or entry.get('service') or 'Unknown'
|
|
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
|
|
else:
|
|
name, raw = str(entry), ''
|
|
running = raw in ('running', 'started', 'up', 'ok', 'active')
|
|
rows.append({
|
|
'name': name,
|
|
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
|
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
|
})
|
|
return success_response(rows)
|
|
|
|
|
|
@endpoints_bp.route('/asset/<int:asset_id>/recentevents', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def panel_recent_events(asset_id: int):
|
|
"""List panel: the endpoint's most recent curated events, newest first.
|
|
|
|
Indexed range scan on (assetid, eventtime), capped small - this is the
|
|
at-a-glance history card, not the full report (that is the events route
|
|
with pagination).
|
|
"""
|
|
events = (EndpointEvent.query
|
|
.filter_by(assetid=asset_id)
|
|
.order_by(EndpointEvent.eventtime.desc())
|
|
.limit(10)
|
|
.all())
|
|
|
|
rows = [{
|
|
'title': event_title(e.eventid),
|
|
'severity': event_severity(e.eventid),
|
|
'eventtime': e.eventtime.isoformat() + 'Z' if e.eventtime else None,
|
|
} for e in events]
|
|
return success_response(rows)
|
|
|
|
|
|
# =============================================================================
|
|
# Dashboard
|
|
# =============================================================================
|
|
|
|
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def dashboard_summary():
|
|
"""High-level fleet counts for the endpoints dashboard."""
|
|
total = db.session.query(EndpointState).join(
|
|
Asset, Asset.assetid == EndpointState.assetid
|
|
).filter(Asset.isactive.is_(True)).count()
|
|
|
|
hours = _quiet_hours()
|
|
cutoff = _naive_utcnow() - timedelta(hours=hours)
|
|
|
|
reporting = db.session.query(EndpointState).join(
|
|
Asset, Asset.assetid == EndpointState.assetid
|
|
).filter(
|
|
Asset.isactive.is_(True),
|
|
EndpointState.lastreportedat.isnot(None),
|
|
EndpointState.lastreportedat >= cutoff,
|
|
).count()
|
|
|
|
return success_response({
|
|
'total': total,
|
|
'reporting': reporting,
|
|
'quiet': total - reporting,
|
|
'quiethours': hours,
|
|
})
|
|
|
|
|
|
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def dashboard_quiet():
|
|
"""Endpoints that reported before, then went quiet past the window.
|
|
|
|
Never-reported endpoints are excluded: those are records without an agent,
|
|
not agents that stopped. Silence is the only signal - there is no heartbeat
|
|
separate from the report.
|
|
"""
|
|
hours = _quiet_hours()
|
|
cutoff = _naive_utcnow() - timedelta(hours=hours)
|
|
now = _naive_utcnow()
|
|
|
|
from plugins.computers.models import Computer
|
|
|
|
rows = (db.session.query(EndpointState, Computer.computerid)
|
|
.join(Asset, Asset.assetid == EndpointState.assetid)
|
|
.join(Computer, Computer.assetid == EndpointState.assetid)
|
|
.filter(Asset.isactive.is_(True),
|
|
EndpointState.lastreportedat.isnot(None),
|
|
EndpointState.lastreportedat < cutoff)
|
|
.all())
|
|
|
|
out = [{
|
|
'computerid': computerid,
|
|
'assetid': s.assetid,
|
|
'hostname': s.hostname,
|
|
'lastreported': s.lastreportedat.isoformat() + 'Z',
|
|
'quietdays': (now - s.lastreportedat).days,
|
|
} for s, computerid in rows]
|
|
|
|
out.sort(key=lambda r: -r['quietdays'])
|
|
return success_response(out[:50])
|
|
|
|
|
|
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('endpoints.view')
|
|
def dashboard_stability():
|
|
"""Endpoints with recent crash/shutdown/corruption events.
|
|
|
|
Bounded to a fixed 48h window and the critical event catalog, capped at 50
|
|
rows: cost is a range scan on eventtime filtered to a short id list, not a
|
|
scan of all history. Joins through Computer so each row links to the PC
|
|
detail page.
|
|
"""
|
|
from plugins.computers.models import Computer
|
|
|
|
if not CRITICAL_EVENT_IDS:
|
|
return success_response([])
|
|
|
|
cutoff = _naive_utcnow() - timedelta(hours=48)
|
|
|
|
rows = (db.session.query(
|
|
Computer.computerid,
|
|
EndpointState.hostname,
|
|
db.func.count(EndpointEvent.endpointeventid),
|
|
db.func.max(EndpointEvent.eventtime),
|
|
)
|
|
.select_from(EndpointEvent)
|
|
.join(Asset, Asset.assetid == EndpointEvent.assetid)
|
|
.join(Computer, Computer.assetid == EndpointEvent.assetid)
|
|
.outerjoin(EndpointState,
|
|
EndpointState.assetid == EndpointEvent.assetid)
|
|
.filter(Asset.isactive.is_(True),
|
|
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
|
|
EndpointEvent.eventtime.isnot(None),
|
|
EndpointEvent.eventtime >= cutoff)
|
|
.group_by(Computer.computerid, EndpointState.hostname)
|
|
.all())
|
|
|
|
out = [{
|
|
'computerid': computerid,
|
|
'hostname': hostname,
|
|
'eventcount': count,
|
|
'lastevent': lasttime.isoformat() + 'Z' if lasttime else None,
|
|
} for computerid, hostname, count, lasttime in rows]
|
|
|
|
out.sort(key=lambda r: -r['eventcount'])
|
|
return success_response(out[:50])
|
|
|
|
|
|
def _quiet_hours() -> int:
|
|
"""Quiet-report window in hours, from a site setting, default 24."""
|
|
hours = 24
|
|
setting = Setting.query.filter_by(key='endpoints_quietreporthours').first()
|
|
if setting and (setting.value or '').strip():
|
|
try:
|
|
hours = int(setting.value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return hours
|