"""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('/', 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('//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//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//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//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