feat: add agent management
This commit is contained in:
1 parent
d9f0da694b
commit
b9b5e5e36f
17 files changed
+1644
-1
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Read-only Agent registry projection."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependencies for the Agent registry read model."""
|
||||
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
|
||||
|
||||
def get_agent_registry_service() -> AgentRegistryService:
|
||||
return AgentRegistryService(AgentRegistryRepository())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Typed read models for Agent Management."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CredentialState = Literal["legacy", "active", "revoked"]
|
||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||
|
||||
|
||||
class AgentRegistrySite(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class AgentRegistryZone(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_enabled: bool
|
||||
assigned_agent_id: str | None = None
|
||||
networks: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryAgent(BaseModel):
|
||||
agent_id: str
|
||||
display_name: str | None = None
|
||||
|
||||
site_id: UUID | None = None
|
||||
site_name: str
|
||||
|
||||
agent_zone_id: UUID | None = None
|
||||
agent_zone_name: str | None = None
|
||||
agent_zone_enabled: bool | None = None
|
||||
|
||||
hostname: str | None = None
|
||||
version: str | None = None
|
||||
ip_address: str | None = None
|
||||
last_checkin_at: datetime
|
||||
|
||||
credential_state: CredentialState
|
||||
credential_created_at: datetime | None = None
|
||||
credential_revoked_at: datetime | None = None
|
||||
|
||||
routing_state: RoutingState
|
||||
|
||||
|
||||
class AgentRegistryData(BaseModel):
|
||||
agents: list[AgentRegistryAgent] = Field(default_factory=list)
|
||||
sites: list[AgentRegistrySite] = Field(default_factory=list)
|
||||
zones: list[AgentRegistryZone] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryResponse(BaseModel):
|
||||
data: AgentRegistryData
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,127 @@
|
||||
"""PostgreSQL reads for the durable Agent registry."""
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def default_connection_factory():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
row_factory=dict_row,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryRepository:
|
||||
def __init__(self, connection_factory=default_connection_factory):
|
||||
self.connection_factory = connection_factory
|
||||
|
||||
def list_registry(self) -> dict[str, list[dict]]:
|
||||
connection = self.connection_factory()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
site.id,
|
||||
site.name
|
||||
FROM sites AS site
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM agent_zones AS zone
|
||||
WHERE zone.site_id = site.id
|
||||
)
|
||||
ORDER BY lower(site.name), site.id
|
||||
"""
|
||||
)
|
||||
sites = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name AS site_name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id AS assigned_agent_id,
|
||||
COALESCE(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site
|
||||
ON site.id = zone.site_id
|
||||
LEFT JOIN minerops_site_agents AS assigned
|
||||
ON assigned.agent_zone_id = zone.id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
GROUP BY
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id
|
||||
ORDER BY
|
||||
lower(site.name),
|
||||
lower(zone.name),
|
||||
zone.id
|
||||
"""
|
||||
)
|
||||
zones = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.display_name,
|
||||
agent.site_id,
|
||||
COALESCE(site.name, agent.site_name) AS site_name,
|
||||
agent.agent_zone_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
agent.hostname,
|
||||
agent.version,
|
||||
agent.ip_address,
|
||||
agent.last_checkin_at,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
CASE
|
||||
WHEN agent.credential_revoked_at IS NOT NULL
|
||||
THEN 'revoked'
|
||||
WHEN agent.agent_secret_hash IS NOT NULL
|
||||
THEN 'active'
|
||||
ELSE 'legacy'
|
||||
END AS credential_state
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
ORDER BY
|
||||
lower(COALESCE(site.name, agent.site_name)),
|
||||
lower(COALESCE(agent.display_name, agent.agent_id)),
|
||||
agent.agent_id
|
||||
"""
|
||||
)
|
||||
agents = cur.fetchall()
|
||||
|
||||
return {
|
||||
"sites": sites,
|
||||
"zones": zones,
|
||||
"agents": agents,
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Read-only Agent Management API."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.agent_registry.dependencies import get_agent_registry_service
|
||||
from app.agent_registry.models import AgentRegistryResponse
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agents"])
|
||||
|
||||
RegistryService = Annotated[
|
||||
AgentRegistryService,
|
||||
Depends(get_agent_registry_service),
|
||||
]
|
||||
|
||||
AgentViewer = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.view")),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agents",
|
||||
response_model=AgentRegistryResponse,
|
||||
)
|
||||
def list_agents(
|
||||
response: Response,
|
||||
current_user: AgentViewer,
|
||||
service: RegistryService,
|
||||
) -> AgentRegistryResponse:
|
||||
del current_user
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
data = service.list_registry()
|
||||
|
||||
return AgentRegistryResponse(
|
||||
data=data,
|
||||
meta={
|
||||
"agent_count": len(data.agents),
|
||||
"site_count": len(data.sites),
|
||||
"zone_count": len(data.zones),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Application service for the Agent Management read model."""
|
||||
|
||||
from app.agent_registry.models import (
|
||||
AgentRegistryAgent,
|
||||
AgentRegistryData,
|
||||
AgentRegistrySite,
|
||||
AgentRegistryZone,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def list_registry(self) -> AgentRegistryData:
|
||||
rows = self.repository.list_registry()
|
||||
|
||||
sites = [
|
||||
AgentRegistrySite(**row)
|
||||
for row in rows["sites"]
|
||||
]
|
||||
|
||||
zones = [
|
||||
AgentRegistryZone(**row)
|
||||
for row in rows["zones"]
|
||||
]
|
||||
|
||||
agents: list[AgentRegistryAgent] = []
|
||||
|
||||
for row in rows["agents"]:
|
||||
zone_id = row.get("agent_zone_id")
|
||||
zone_enabled = row.get("agent_zone_enabled")
|
||||
|
||||
if zone_id is None:
|
||||
routing_state = "unassigned"
|
||||
elif zone_enabled is False:
|
||||
routing_state = "disabled"
|
||||
else:
|
||||
routing_state = "assigned"
|
||||
|
||||
agents.append(
|
||||
AgentRegistryAgent(
|
||||
**row,
|
||||
routing_state=routing_state,
|
||||
)
|
||||
)
|
||||
|
||||
return AgentRegistryData(
|
||||
agents=agents,
|
||||
sites=sites,
|
||||
zones=zones,
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Semantic HTML for the Internal Alpha Agent Management page."""
|
||||
|
||||
|
||||
def agent_management_content(can_manage_agents: bool) -> str:
|
||||
management = """
|
||||
<section class="panel agent-enrollment-panel"
|
||||
aria-labelledby="agentEnrollmentCreateTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
|
||||
<p class="panel-copy">
|
||||
Create a short-lived one-time enrollment for an Agent Zone.
|
||||
Windows is the currently supported deployment platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="agentCreateForm" class="agent-create-form">
|
||||
<div class="field">
|
||||
<label for="agentSite">Site</label>
|
||||
<select id="agentSite" required>
|
||||
<option value="">Choose a site</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentZone">Agent Zone</label>
|
||||
<select id="agentZone" required disabled>
|
||||
<option value="">Choose a site first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentDisplayName">Agent name</label>
|
||||
<input id="agentDisplayName"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
autocomplete="off"
|
||||
placeholder="Example: Plexus Unit 4 Agent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentPlatform">Platform</label>
|
||||
<select id="agentPlatform">
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux-x64" disabled>Linux x64 — coming later</option>
|
||||
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentCreateSubmit"
|
||||
class="command-button"
|
||||
type="submit">
|
||||
Create Windows enrollment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentCreateStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<section id="agentEnrollmentResult"
|
||||
class="agent-enrollment-result"
|
||||
aria-labelledby="agentEnrollmentResultTitle"
|
||||
hidden>
|
||||
<div>
|
||||
<p class="eyebrow">One-time credential</p>
|
||||
<h3 id="agentEnrollmentResultTitle">
|
||||
Enrollment created
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
|
||||
<div class="agent-token-box">
|
||||
<code id="agentEnrollmentToken"></code>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentExpires" class="freshness"></p>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentEnrollmentCopy"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Copy token
|
||||
</button>
|
||||
<button id="agentEnrollmentDismiss"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Clear token from screen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentCopyStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
|
||||
<p class="panel-copy">
|
||||
The self-enrolling Windows installer is the next deployment
|
||||
milestone. For now this verifies the complete secure
|
||||
enrollment workflow.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
""" if can_manage_agents else """
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2>Agent enrollment</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="permission-note">
|
||||
Your account can view Agent status but cannot create Agent
|
||||
enrollments.
|
||||
</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div id="agentManagementLoading" class="state-message" role="status">
|
||||
<strong>Loading Agents</strong>
|
||||
<span>Requesting Agent registry and enrollment information.</span>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementError"
|
||||
class="state-message"
|
||||
data-kind="error"
|
||||
role="alert"
|
||||
hidden>
|
||||
<strong>Agent Management unavailable</strong>
|
||||
<span id="agentManagementErrorText">
|
||||
Agent information could not be loaded.
|
||||
</span>
|
||||
<button id="agentManagementRetry"
|
||||
class="command-button"
|
||||
type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent" hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
<strong id="agentCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Agent Zones</span>
|
||||
<strong id="agentZoneCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Waiting enrollments</span>
|
||||
<strong id="agentWaitingEnrollmentCount">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="agent-management-columns">
|
||||
<section class="panel" aria-labelledby="registeredAgentsTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Durable identities</p>
|
||||
<h2 id="registeredAgentsTitle">Agents</h2>
|
||||
<p class="panel-copy">
|
||||
Registered Agent identities and their latest factual
|
||||
check-in information.
|
||||
</p>
|
||||
</div>
|
||||
<button id="agentManagementRefresh"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Host</th>
|
||||
<th>Version</th>
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEmpty" class="panel-copy" hidden>
|
||||
No registered Agents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="agentZonesTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network routing boundaries</p>
|
||||
<h2 id="agentZonesTitle">Agent Zones</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-zone-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Zone</th>
|
||||
<th>Networks</th>
|
||||
<th>Assigned Agent</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentZoneRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentZoneEmpty" class="panel-copy" hidden>
|
||||
No Agent Zones.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{management}
|
||||
|
||||
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Bootstrap history</p>
|
||||
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
|
||||
<p class="panel-copy">
|
||||
Metadata only. Plaintext enrollment tokens are never
|
||||
returned by this history view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-enrollment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent name</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Enrolled Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentEnrollmentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
|
||||
No Agent enrollment records.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/assets/minerops-agent-management.js" defer></script>
|
||||
"""
|
||||
@@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||
|
||||
|
||||
def render_page(view: str, current_user: dict, **context) -> str:
|
||||
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/assets/minerops-agent-management.js",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/management",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_page(current_user: AgentUser) -> str:
|
||||
return render_page("agent_management", current_user)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard_page(current_user: DashboardUser) -> str:
|
||||
return render_page("dashboard", current_user)
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "agent_management") return;
|
||||
|
||||
const ids = [
|
||||
"agentManagementLoading",
|
||||
"agentManagementError",
|
||||
"agentManagementErrorText",
|
||||
"agentManagementRetry",
|
||||
"agentManagementContent",
|
||||
"agentManagementRefresh",
|
||||
"agentCount",
|
||||
"agentZoneCount",
|
||||
"agentWaitingEnrollmentCount",
|
||||
"agentRows",
|
||||
"agentEmpty",
|
||||
"agentZoneRows",
|
||||
"agentZoneEmpty",
|
||||
"agentEnrollmentRows",
|
||||
"agentEnrollmentEmpty",
|
||||
"agentCreateForm",
|
||||
"agentSite",
|
||||
"agentZone",
|
||||
"agentDisplayName",
|
||||
"agentPlatform",
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentCopy",
|
||||
"agentEnrollmentDismiss",
|
||||
"agentEnrollmentCopyStatus"
|
||||
];
|
||||
|
||||
const el = {};
|
||||
|
||||
ids.forEach(function (id) {
|
||||
el[id] = document.getElementById(id);
|
||||
});
|
||||
|
||||
let registry = {
|
||||
agents: [],
|
||||
sites: [],
|
||||
zones: []
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return fallback || "Unavailable";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Never";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return text(value, "Unknown")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, function (letter) {
|
||||
return letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function cell(value, className) {
|
||||
const node = document.createElement("td");
|
||||
if (className) node.className = className;
|
||||
node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function setState(name) {
|
||||
el.agentManagementLoading.hidden = name !== "loading";
|
||||
el.agentManagementError.hidden = name !== "error";
|
||||
el.agentManagementContent.hidden = name !== "ready";
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, Object.assign({
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}, options || {}));
|
||||
|
||||
const payload = await response.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload && payload.detail
|
||||
? String(payload.detail)
|
||||
: "Request failed with status " + response.status + ".";
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderAgents() {
|
||||
el.agentRows.replaceChildren();
|
||||
|
||||
registry.agents.forEach(function (agent) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const identity = document.createElement("td");
|
||||
const name = document.createElement("strong");
|
||||
const id = document.createElement("span");
|
||||
|
||||
name.textContent = text(agent.display_name, "Unnamed Agent");
|
||||
id.className = "secondary";
|
||||
id.textContent = agent.agent_id;
|
||||
|
||||
identity.append(name, id);
|
||||
|
||||
const location = [
|
||||
text(agent.site_name, "Unknown site"),
|
||||
text(agent.agent_zone_name, "No Agent Zone")
|
||||
].join(" / ");
|
||||
|
||||
const host = [
|
||||
text(agent.hostname, "Unknown host"),
|
||||
text(agent.ip_address, "No IP")
|
||||
].join(" / ");
|
||||
|
||||
row.append(
|
||||
identity,
|
||||
cell(location),
|
||||
cell(host),
|
||||
cell(text(agent.version, "Unknown")),
|
||||
cell(dateTime(agent.last_checkin_at)),
|
||||
cell(words(agent.credential_state)),
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEmpty.hidden = registry.agents.length !== 0;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
el.agentZoneRows.replaceChildren();
|
||||
|
||||
registry.zones.forEach(function (zone) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const networks = Array.isArray(zone.networks) && zone.networks.length
|
||||
? zone.networks.join(", ")
|
||||
: "No networks recorded";
|
||||
|
||||
row.append(
|
||||
cell(zone.site_name),
|
||||
cell(zone.name),
|
||||
cell(networks),
|
||||
cell(text(zone.assigned_agent_id, "Unassigned")),
|
||||
cell(zone.is_enabled ? "Enabled" : "Disabled")
|
||||
);
|
||||
|
||||
el.agentZoneRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
|
||||
}
|
||||
|
||||
function renderEnrollments() {
|
||||
el.agentEnrollmentRows.replaceChildren();
|
||||
|
||||
enrollments.forEach(function (enrollment) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
row.append(
|
||||
cell(enrollment.display_name),
|
||||
cell(
|
||||
enrollment.site_name + " / " +
|
||||
enrollment.agent_zone_name
|
||||
),
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
|
||||
}
|
||||
|
||||
function populateSites() {
|
||||
if (!el.agentSite) return;
|
||||
|
||||
const selected = el.agentSite.value;
|
||||
|
||||
el.agentSite.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Choose a site";
|
||||
el.agentSite.appendChild(placeholder);
|
||||
|
||||
registry.sites.forEach(function (site) {
|
||||
const option = document.createElement("option");
|
||||
option.value = site.id;
|
||||
option.textContent = site.name;
|
||||
el.agentSite.appendChild(option);
|
||||
});
|
||||
|
||||
if ([...el.agentSite.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentSite.value = selected;
|
||||
}
|
||||
|
||||
populateZones();
|
||||
}
|
||||
|
||||
function populateZones() {
|
||||
if (!el.agentZone || !el.agentSite) return;
|
||||
|
||||
const siteId = el.agentSite.value;
|
||||
const selected = el.agentZone.value;
|
||||
|
||||
el.agentZone.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
|
||||
if (!siteId) {
|
||||
placeholder.textContent = "Choose a site first";
|
||||
el.agentZone.disabled = true;
|
||||
el.agentZone.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.textContent = "Choose an Agent Zone";
|
||||
el.agentZone.appendChild(placeholder);
|
||||
|
||||
const available = registry.zones.filter(function (zone) {
|
||||
return (
|
||||
zone.site_id === siteId &&
|
||||
zone.is_enabled === true &&
|
||||
!zone.assigned_agent_id
|
||||
);
|
||||
});
|
||||
|
||||
available.forEach(function (zone) {
|
||||
const option = document.createElement("option");
|
||||
option.value = zone.id;
|
||||
option.textContent = zone.name;
|
||||
el.agentZone.appendChild(option);
|
||||
});
|
||||
|
||||
el.agentZone.disabled = available.length === 0;
|
||||
|
||||
if ([...el.agentZone.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentZone.value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
el.agentCount.textContent = String(registry.agents.length);
|
||||
el.agentZoneCount.textContent = String(registry.zones.length);
|
||||
|
||||
const waiting = enrollments.filter(function (item) {
|
||||
return item.status === "waiting";
|
||||
}).length;
|
||||
|
||||
el.agentWaitingEnrollmentCount.textContent = String(waiting);
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSummary();
|
||||
renderAgents();
|
||||
renderZones();
|
||||
renderEnrollments();
|
||||
populateSites();
|
||||
}
|
||||
|
||||
async function refreshData(showLoading) {
|
||||
if (showLoading !== false) setState("loading");
|
||||
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api("/api/agents"),
|
||||
api("/api/agent-enrollments")
|
||||
]);
|
||||
|
||||
registry = results[0] && results[0].data
|
||||
? results[0].data
|
||||
: { agents: [], sites: [], zones: [] };
|
||||
|
||||
enrollments = results[1] && Array.isArray(results[1].data)
|
||||
? results[1].data
|
||||
: [];
|
||||
|
||||
render();
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
el.agentManagementErrorText.textContent =
|
||||
error.message || "Agent information could not be loaded.";
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function createEnrollment(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!el.agentCreateForm) return;
|
||||
|
||||
el.agentCreateSubmit.disabled = true;
|
||||
el.agentCreateStatus.textContent = "Creating enrollment...";
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-enrollments", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
site_id: el.agentSite.value,
|
||||
agent_zone_id: el.agentZone.value,
|
||||
display_name: el.agentDisplayName.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
|
||||
el.agentEnrollmentToken.textContent =
|
||||
enrollment.enrollment_token;
|
||||
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(enrollment.expires_at);
|
||||
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Enrollment could not be created.";
|
||||
} finally {
|
||||
el.agentCreateSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Enrollment token copied.";
|
||||
} catch (error) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Copy was unavailable. Select and copy the token manually.";
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToken() {
|
||||
el.agentEnrollmentToken.textContent = "";
|
||||
el.agentEnrollmentExpires.textContent = "";
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = true;
|
||||
}
|
||||
|
||||
el.agentManagementRetry.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
|
||||
if (el.agentManagementRefresh) {
|
||||
el.agentManagementRefresh.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (el.agentSite) {
|
||||
el.agentSite.addEventListener("change", populateZones);
|
||||
}
|
||||
|
||||
if (el.agentCreateForm) {
|
||||
el.agentCreateForm.addEventListener("submit", createEnrollment);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentCopy) {
|
||||
el.agentEnrollmentCopy.addEventListener("click", copyToken);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDismiss) {
|
||||
el.agentEnrollmentDismiss.addEventListener(
|
||||
"click",
|
||||
dismissToken
|
||||
);
|
||||
}
|
||||
|
||||
refreshData(true);
|
||||
}());
|
||||
@@ -1771,3 +1771,86 @@ button.map-position:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent Management v1 */
|
||||
|
||||
.agent-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-summary-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.agent-summary-card span {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.agent-summary-card strong {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.agent-management-columns {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-table td:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agent-create-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.agent-create-form .agent-form-actions,
|
||||
.agent-create-form #agentCreateStatus {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-enrollment-result {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.agent-token-box {
|
||||
overflow-wrap: anywhere;
|
||||
padding: 14px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.agent-token-box code {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.agent-summary-grid,
|
||||
.agent-create-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent Management action visibility */
|
||||
.agent-management-columns .quiet-button,
|
||||
.agent-enrollment-panel .quiet-button {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from typing import Callable
|
||||
from app.frontend.miner_detail_view import miner_detail_content
|
||||
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||
from app.frontend.system_health_view import system_health_content
|
||||
from app.frontend.agent_management_view import agent_management_content
|
||||
|
||||
|
||||
NAVIGATION = (
|
||||
@@ -15,6 +16,7 @@ NAVIGATION = (
|
||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||
("system_health", "System Health", "/system-health", "system_health.view"),
|
||||
("agent_management", "Agents", "/agent/management", "agents.view"),
|
||||
)
|
||||
|
||||
PAGE_DETAILS = {
|
||||
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
|
||||
"System Health",
|
||||
"Inspect application services, operational data, and maintenance facts.",
|
||||
),
|
||||
"agent_management": (
|
||||
"Administration / Agents",
|
||||
"Agent Management",
|
||||
"Inspect Agent identities, routing boundaries, and secure enrollment.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -462,7 +469,9 @@ def render_app_page(
|
||||
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||
)
|
||||
content = (
|
||||
system_health_content()
|
||||
agent_management_content(has_permission("agents.manage"))
|
||||
if view == "agent_management"
|
||||
else system_health_content()
|
||||
if view == "system_health"
|
||||
else miner_detail_content()
|
||||
if view == "miner_detail"
|
||||
|
||||
@@ -779,6 +779,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "repairs.read"
|
||||
if component_request(path):
|
||||
return "components.create" if method == "POST" else "components.read"
|
||||
if path == "/api/agents":
|
||||
return "agents.view"
|
||||
if path == "/api/agent-zones":
|
||||
return "agents.view" if method == "GET" else "agents.manage"
|
||||
if path == "/api/agent-enrollments":
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||
from app.config import settings
|
||||
from app.agent_commands.dependencies import require_agent_identity
|
||||
from app.agent_commands.router import router as agent_commands_router
|
||||
from app.agent_registry.router import router as agent_registry_router
|
||||
from app.agent_zones.dependencies import get_agent_zone_service
|
||||
from app.agent_zones.models import MinerZoneAssociationResult
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
@@ -73,6 +74,7 @@ app.include_router(frontend_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(miner_control_router)
|
||||
app.include_router(agent_commands_router)
|
||||
app.include_router(agent_registry_router)
|
||||
app.include_router(agent_zones_router)
|
||||
app.include_router(agent_enrollment_router)
|
||||
app.include_router(components_router)
|
||||
|
||||
Reference in new issue
Block a user