Gate endpoint UI refresh on fresh telemetry and land endpoint agent
A command reaching 'succeeded' only proves the agent ran the action, not that it posted a new telemetry snapshot. Reloading panels on command success alone re-rendered stale service/process rows, so a stopped process could still show running right after Refresh Status. Add a lightweight telemetrymeta probe exposing lastreportedat and gate both the header Refresh Status action and inline row Start/Stop on that stamp advancing past a pre-action baseline. Polling is bounded so an offline or idle agent never hangs the button, and messaging distinguishes a confirmed refresh from a queued one. Bring the .NET endpoint agent source (service, tray, installer) into the repo so Cameron can merge and test, with an agent/.gitignore keeping bin/obj/publish out. Includes the DncMain launch-path fix, tray single-file host env scrub, and best-effort file logger.
This commit is contained in:
@@ -11,7 +11,7 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required, current_user
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset, Setting, success_response, error_response, paginated_response,
|
||||
@@ -21,7 +21,7 @@ from shopdb.api import (
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
@@ -56,7 +56,6 @@ def _format_uptime(seconds):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
@@ -91,7 +90,6 @@ def list_endpoints():
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -108,7 +106,6 @@ def get_endpoint(asset_id: int):
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -136,7 +133,6 @@ def list_endpoint_events(asset_id: int):
|
||||
# =============================================================================
|
||||
|
||||
@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.
|
||||
@@ -168,8 +164,25 @@ def panel_status(asset_id: int):
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
"""Lightweight freshness probe for the panels.
|
||||
|
||||
Returns just the endpoint's lastreportedat so the WebUI can tell whether a
|
||||
genuinely new telemetry snapshot has landed after it forces a collect. The
|
||||
UI captures this before enqueuing collect_now (or a row Start/Stop), then
|
||||
polls until the stamp advances - a command reaching 'succeeded' only means
|
||||
the agent ran it, not that it has yet posted the fresh services snapshot, so
|
||||
gating the refresh on this timestamp is what avoids re-rendering stale rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
stamp = (state.lastreportedat.isoformat() + 'Z'
|
||||
if state and state.lastreportedat else None)
|
||||
return success_response({'lastreportedat': stamp})
|
||||
|
||||
|
||||
@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.
|
||||
@@ -199,6 +212,7 @@ def panel_services(asset_id: int):
|
||||
running = raw in ('running', 'started', 'up', 'ok', 'active')
|
||||
row = {
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
||||
}
|
||||
@@ -217,7 +231,6 @@ def panel_services(asset_id: int):
|
||||
|
||||
|
||||
@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.
|
||||
@@ -241,7 +254,6 @@ def panel_recent_events(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
"""Table panel: every hardware NIC the agent last reported.
|
||||
@@ -287,7 +299,6 @@ def panel_interfaces(asset_id: int):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_summary():
|
||||
"""High-level fleet counts for the endpoints dashboard."""
|
||||
@@ -315,7 +326,6 @@ def dashboard_summary():
|
||||
|
||||
|
||||
@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.
|
||||
@@ -351,7 +361,6 @@ def dashboard_quiet():
|
||||
|
||||
|
||||
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_stability():
|
||||
"""Endpoints with recent crash/shutdown/corruption events.
|
||||
@@ -441,7 +450,6 @@ def _agentkey_status_payload():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def get_agentkey():
|
||||
"""Report the endpoint agent key state (no secret)."""
|
||||
@@ -449,7 +457,6 @@ def get_agentkey():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey/regenerate', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def regenerate_agentkey():
|
||||
"""Mint a fresh endpoint agent key, revoking any previous one.
|
||||
@@ -480,7 +487,6 @@ def regenerate_agentkey():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def revoke_agentkey():
|
||||
"""Revoke the endpoint agent key: stop all managed-token endpoint traffic.
|
||||
@@ -534,7 +540,6 @@ def _resolve_asset_for_hostname(hostname):
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_commands(asset_id: int):
|
||||
"""Recent commands for one endpoint, newest first (operator view)."""
|
||||
@@ -547,7 +552,6 @@ def list_endpoint_commands(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.command')
|
||||
def enqueue_endpoint_command(asset_id: int):
|
||||
"""Queue an allowlisted command for one endpoint.
|
||||
@@ -562,6 +566,16 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown or disallowed action: {action!r}')
|
||||
|
||||
# Destructive actions (for example system_restart) demand a stricter grant
|
||||
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
||||
# toggle permission.
|
||||
elevated = elevated_permission_for(action)
|
||||
if elevated and not current_user.haspermission(elevated):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
http_code=403)
|
||||
|
||||
if action not in ALLOWED_ACTIONS_FOR(assetid=asset_id):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
|
||||
Reference in New Issue
Block a user