Fleet console: triage band, activity feed, live state first
This commit is contained in:
@@ -18,7 +18,7 @@ from shopdb.api import (
|
||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||
revoke_service_tokens,
|
||||
AuditLog, collector_authorized,
|
||||
AuditLog, collector_authorized, User,
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||
@@ -306,7 +306,13 @@ def _health_for(state, criticalcount):
|
||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def get_endpoint(asset_id: int):
|
||||
"""Get the current state for one endpoint by asset id."""
|
||||
"""Get the current state for one endpoint by asset id.
|
||||
|
||||
Carries the same reporting/quiet/never verdict the fleet list uses. The
|
||||
quiet window is a site setting, so the SERVER decides it: a caller that
|
||||
guessed a threshold in the browser would disagree with the fleet list on
|
||||
the same endpoint the moment a site changed the setting.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
|
||||
if not state:
|
||||
@@ -316,7 +322,18 @@ def get_endpoint(asset_id: int):
|
||||
http_code=404,
|
||||
)
|
||||
|
||||
return success_response(state.to_dict())
|
||||
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
|
||||
if state.lastreportedat is None:
|
||||
status = 'never'
|
||||
elif state.lastreportedat < cutoff:
|
||||
status = 'quiet'
|
||||
else:
|
||||
status = 'reporting'
|
||||
|
||||
data = state.to_dict()
|
||||
data['status'] = status
|
||||
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||
@@ -473,6 +490,84 @@ def panel_recent_events(asset_id: int):
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/activity', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_activity(asset_id: int):
|
||||
"""List panel: one chronological feed of what happened to this endpoint.
|
||||
|
||||
Curated Windows events and operator commands merged and sorted newest
|
||||
first. They answer the same question - what happened to this box - and
|
||||
reading them as two separate cards means interleaving them by eye.
|
||||
|
||||
Both sources are already keyed by assetid, so this is two indexed reads and
|
||||
a merge sort, not a join. The core AuditLog is deliberately NOT a third
|
||||
source: its rows carry the action as the entity name and the asset only
|
||||
inside a details string, so pulling it in would mean parsing prose.
|
||||
|
||||
The requester's name is withheld from callers who cannot themselves issue
|
||||
commands. A read-only viewer - a guest, on a site that grants this panel -
|
||||
has no need for the roster of who restarted what, and the feed is just as
|
||||
useful without it.
|
||||
"""
|
||||
naming = caller_holds('endpoints.command')
|
||||
|
||||
events = (EndpointEvent.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointEvent.eventtime.desc())
|
||||
.limit(15)
|
||||
.all())
|
||||
|
||||
commands = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointCommand.createdat.desc())
|
||||
.limit(15)
|
||||
.all())
|
||||
|
||||
# requestedby holds a user ID, not a name. One batched lookup rather than a
|
||||
# query per row, and only when the caller is allowed to see the names at all.
|
||||
names = {}
|
||||
if naming:
|
||||
userids = {c.requestedby for c in commands if c.requestedby}
|
||||
if userids:
|
||||
names = {u.userid: (u.username or '')
|
||||
for u in User.query.filter(User.userid.in_(userids)).all()}
|
||||
|
||||
rows = []
|
||||
for event in events:
|
||||
rows.append({
|
||||
'title': event_title(event.eventid),
|
||||
'severity': event_severity(event.eventid),
|
||||
'detail': 'Windows event {}'.format(event.eventid),
|
||||
'eventtime': event.eventtime,
|
||||
})
|
||||
|
||||
for command in commands:
|
||||
label = COMMAND_ALLOWLIST.get(command.action, {}).get('label') or command.action
|
||||
target = ' on {}'.format(command.target) if command.target else ''
|
||||
who = ''
|
||||
if naming and command.requestedby:
|
||||
username = names.get(command.requestedby)
|
||||
if username:
|
||||
who = ' by {}'.format(username)
|
||||
rows.append({
|
||||
'title': '{}{}'.format(label, target),
|
||||
# A failed command is the one an operator needs to notice.
|
||||
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
||||
'detail': '{}{}'.format((command.status or 'queued').title(), who),
|
||||
'eventtime': command.createdat,
|
||||
})
|
||||
|
||||
# Undated rows sort last rather than crashing the comparison.
|
||||
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
|
||||
|
||||
return success_response([{
|
||||
'title': r['title'],
|
||||
'severity': r['severity'],
|
||||
'detail': r['detail'],
|
||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||
} for r in rows[:15]])
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
|
||||
Reference in New Issue
Block a user