diff --git a/backend/app/agent_registry/__init__.py b/backend/app/agent_registry/__init__.py
new file mode 100644
index 0000000..26ec0b1
--- /dev/null
+++ b/backend/app/agent_registry/__init__.py
@@ -0,0 +1 @@
+"""Read-only Agent registry projection."""
diff --git a/backend/app/agent_registry/dependencies.py b/backend/app/agent_registry/dependencies.py
new file mode 100644
index 0000000..49d05e2
--- /dev/null
+++ b/backend/app/agent_registry/dependencies.py
@@ -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())
diff --git a/backend/app/agent_registry/models.py b/backend/app/agent_registry/models.py
new file mode 100644
index 0000000..ea5eec3
--- /dev/null
+++ b/backend/app/agent_registry/models.py
@@ -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
diff --git a/backend/app/agent_registry/repository.py b/backend/app/agent_registry/repository.py
new file mode 100644
index 0000000..4cd37a4
--- /dev/null
+++ b/backend/app/agent_registry/repository.py
@@ -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()
diff --git a/backend/app/agent_registry/router.py b/backend/app/agent_registry/router.py
new file mode 100644
index 0000000..94eab00
--- /dev/null
+++ b/backend/app/agent_registry/router.py
@@ -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),
+ },
+ )
diff --git a/backend/app/agent_registry/service.py b/backend/app/agent_registry/service.py
new file mode 100644
index 0000000..f200e6c
--- /dev/null
+++ b/backend/app/agent_registry/service.py
@@ -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,
+ )
diff --git a/backend/app/frontend/agent_management_view.py b/backend/app/frontend/agent_management_view.py
new file mode 100644
index 0000000..23fae42
--- /dev/null
+++ b/backend/app/frontend/agent_management_view.py
@@ -0,0 +1,268 @@
+"""Semantic HTML for the Internal Alpha Agent Management page."""
+
+
+def agent_management_content(can_manage_agents: bool) -> str:
+ management = """
+
+
+
+
Agent deployment
+
Add Agent
+
+ Create a short-lived one-time enrollment for an Agent Zone.
+ Windows is the currently supported deployment platform.
+
+
+
+
+
+
+
+
+
One-time credential
+
+ Enrollment created
+
+
+
+
+ This token is displayed only for this enrollment step.
+ Keep it private. MinerOps does not store the plaintext token.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The self-enrolling Windows installer is the next deployment
+ milestone. For now this verifies the complete secure
+ enrollment workflow.
+
+
+
+ """ if can_manage_agents else """
+
+
+
+
Agent deployment
+
Agent enrollment
+
+
+
+ Your account can view Agent status but cannot create Agent
+ enrollments.
+
+
+ """
+
+ return f"""
+
+ Loading Agents
+ Requesting Agent registry and enrollment information.
+
+
+
+ Agent Management unavailable
+
+ Agent information could not be loaded.
+
+
+
+
+
+
+
+ Registered Agents
+ -
+
+
+ Agent Zones
+ -
+
+
+ Waiting enrollments
+ -
+
+
+
+
+
+
+
+
Durable identities
+
Agents
+
+ Registered Agent identities and their latest factual
+ check-in information.
+
+
+
+
+
+
+
+
+
+ | Agent |
+ Site / Zone |
+ Host |
+ Version |
+ Last check-in |
+ Credential |
+ Routing |
+
+
+
+
+
+
+ No registered Agents.
+
+
+
+
+
+
+
Network routing boundaries
+
Agent Zones
+
+
+
+
+
+
+
+ | Site |
+ Zone |
+ Networks |
+ Assigned Agent |
+ State |
+
+
+
+
+
+
+ No Agent Zones.
+
+
+
+
+ {management}
+
+
+
+
+
Bootstrap history
+
Enrollment history
+
+ Metadata only. Plaintext enrollment tokens are never
+ returned by this history view.
+
+
+
+
+
+
+
+
+ | Agent name |
+ Site / Zone |
+ Status |
+ Created |
+ Expires |
+ Enrolled Agent |
+
+
+
+
+
+
+ No Agent enrollment records.
+
+
+
+
+
+"""
diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py
index 458be5b..494e653 100644
--- a/backend/app/frontend/router.py
+++ b/backend/app/frontend/router.py
@@ -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)
diff --git a/backend/app/frontend/static/agent-management.js b/backend/app/frontend/static/agent-management.js
new file mode 100644
index 0000000..ff5830f
--- /dev/null
+++ b/backend/app/frontend/static/agent-management.js
@@ -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);
+}());
diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css
index d405bb4..365f5e4 100644
--- a/backend/app/frontend/static/app.css
+++ b/backend/app/frontend/static/app.css
@@ -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);
+}
diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py
index c5908bc..7b63db3 100644
--- a/backend/app/frontend/views.py
+++ b/backend/app/frontend/views.py
@@ -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 ''
)
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"
diff --git a/backend/app/identity.py b/backend/app/identity.py
index 10a687d..9a16d28 100644
--- a/backend/app/identity.py
+++ b/backend/app/identity.py
@@ -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":
diff --git a/backend/app/main.py b/backend/app/main.py
index 509d8f8..ef76a33 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/tests/test_agent_management_frontend.py b/backend/tests/test_agent_management_frontend.py
new file mode 100644
index 0000000..816c765
--- /dev/null
+++ b/backend/tests/test_agent_management_frontend.py
@@ -0,0 +1,141 @@
+"""Frontend contract tests for Agent Management."""
+
+import unittest
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.frontend.router import router
+from app.identity import get_current_user, required_page_permission
+
+
+STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
+
+
+class AgentManagementFrontendTests(unittest.TestCase):
+ def setUp(self):
+ self.app = FastAPI()
+ self.app.include_router(router)
+ self.client = TestClient(self.app)
+
+ def authenticate(self, permissions):
+ self.app.dependency_overrides[get_current_user] = lambda: {
+ "id": "00000000-0000-4000-8000-000000009001",
+ "username": "agent-test-user",
+ "display_name": "Agent Test User",
+ "role": "Test Role",
+ "permissions": tuple(permissions),
+ }
+
+ def test_page_requires_agents_view(self):
+ self.authenticate(("unrelated.permission",))
+ self.assertEqual(
+ self.client.get("/agent/management").status_code,
+ 403,
+ )
+
+ self.authenticate(("agents.view",))
+ response = self.client.get("/agent/management")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Agent Management", response.text)
+ self.assertIn('href="/agent/management"', response.text)
+
+ def test_manage_controls_follow_agents_manage_permission(self):
+ self.authenticate(("agents.view",))
+
+ restricted = self.client.get("/agent/management").text
+
+ self.assertNotIn('id="agentCreateForm"', restricted)
+ restricted_text = " ".join(restricted.split())
+ self.assertIn(
+ "cannot create Agent enrollments.",
+ restricted_text,
+ )
+
+ self.authenticate(("agents.view", "agents.manage"))
+
+ managed = self.client.get("/agent/management").text
+
+ self.assertIn('id="agentCreateForm"', managed)
+ self.assertIn('id="agentSite"', managed)
+ self.assertIn('id="agentZone"', managed)
+ self.assertIn('id="agentDisplayName"', managed)
+ self.assertIn("Linux x64 — coming later", managed)
+ self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
+
+ def test_route_and_asset_contract(self):
+ self.assertEqual(
+ required_page_permission("/agent/management"),
+ "agents.view",
+ )
+
+ self.authenticate(("agents.view",))
+
+ page = self.client.get("/agent/management")
+ self.assertIn(
+ "/assets/minerops-agent-management.js",
+ page.text,
+ )
+
+ asset = self.client.get(
+ "/assets/minerops-agent-management.js"
+ )
+
+ self.assertEqual(asset.status_code, 200)
+ self.assertEqual(
+ asset.headers.get("cache-control"),
+ "no-store",
+ )
+
+ def test_javascript_is_secret_conscious_and_safe_dom_only(self):
+ source = (
+ STATIC_ROOT / "agent-management.js"
+ ).read_text(encoding="utf-8")
+
+ self.assertIn('api("/api/agents")', source)
+ self.assertIn('api("/api/agent-enrollments")', source)
+ self.assertIn(
+ 'api("/api/agent-enrollments", {',
+ source,
+ )
+
+ for unsafe in (
+ "innerHTML",
+ "outerHTML",
+ "insertAdjacentHTML",
+ "localStorage",
+ "sessionStorage",
+ "console.log",
+ ):
+ self.assertNotIn(unsafe, source)
+
+ self.assertIn(
+ "el.agentEnrollmentToken.textContent",
+ source,
+ )
+ self.assertIn(
+ "el.agentEnrollmentToken.textContent = \"\"",
+ source,
+ )
+
+ def test_agent_management_quiet_actions_have_visible_text(self):
+ css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
+
+ self.assertIn(
+ ".agent-management-columns .quiet-button,",
+ css,
+ )
+ self.assertIn(
+ ".agent-enrollment-panel .quiet-button {",
+ css,
+ )
+ self.assertIn(
+ "color: var(--ink);",
+ css,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_agent_registry.py b/backend/tests/test_agent_registry.py
new file mode 100644
index 0000000..86eaede
--- /dev/null
+++ b/backend/tests/test_agent_registry.py
@@ -0,0 +1,120 @@
+"""Focused tests for the Agent Management read model."""
+
+import unittest
+from uuid import UUID
+
+from app.agent_registry.service import AgentRegistryService
+from app.identity import required_api_permission
+
+
+SITE_ID = UUID("00000000-0000-4000-8000-000000001800")
+ZONE_ID = UUID("00000000-0000-4000-8000-000000001801")
+
+
+class FakeAgentRegistryRepository:
+ def list_registry(self):
+ return {
+ "sites": [
+ {
+ "id": SITE_ID,
+ "name": "Test Site",
+ }
+ ],
+ "zones": [
+ {
+ "id": ZONE_ID,
+ "site_id": SITE_ID,
+ "site_name": "Test Site",
+ "name": "Network A",
+ "description": None,
+ "is_enabled": True,
+ "assigned_agent_id": "agent-test",
+ "networks": ["10.50.0.0/24"],
+ }
+ ],
+ "agents": [
+ {
+ "agent_id": "agent-test",
+ "display_name": "Test Agent",
+ "site_id": SITE_ID,
+ "site_name": "Test Site",
+ "agent_zone_id": ZONE_ID,
+ "agent_zone_name": "Network A",
+ "agent_zone_enabled": True,
+ "hostname": "test-agent",
+ "version": "1.0.0-alpha",
+ "ip_address": "10.50.0.10",
+ "last_checkin_at": "2026-08-26T20:00:00+00:00",
+ "credential_state": "active",
+ "credential_created_at": "2026-08-26T19:00:00+00:00",
+ "credential_revoked_at": None,
+ }
+ ],
+ }
+
+
+class AgentRegistryServiceTests(unittest.TestCase):
+ def test_projection_is_factual_and_zone_aware(self):
+ data = AgentRegistryService(
+ FakeAgentRegistryRepository()
+ ).list_registry()
+
+ self.assertEqual(len(data.agents), 1)
+ self.assertEqual(len(data.sites), 1)
+ self.assertEqual(len(data.zones), 1)
+
+ agent = data.agents[0]
+ self.assertEqual(agent.agent_id, "agent-test")
+ self.assertEqual(agent.credential_state, "active")
+ self.assertEqual(agent.routing_state, "assigned")
+ self.assertEqual(
+ data.zones[0].networks,
+ ["10.50.0.0/24"],
+ )
+
+ def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self):
+ repository = FakeAgentRegistryRepository()
+
+ repository.list_registry = lambda: {
+ "sites": [],
+ "zones": [],
+ "agents": [
+ {
+ "agent_id": "legacy",
+ "display_name": None,
+ "site_id": None,
+ "site_name": "Legacy Site",
+ "agent_zone_id": None,
+ "agent_zone_name": None,
+ "agent_zone_enabled": None,
+ "hostname": None,
+ "version": None,
+ "ip_address": None,
+ "last_checkin_at": "2026-08-26T20:00:00+00:00",
+ "credential_state": "legacy",
+ "credential_created_at": None,
+ "credential_revoked_at": None,
+ }
+ ],
+ }
+
+ data = AgentRegistryService(repository).list_registry()
+
+ self.assertEqual(
+ data.agents[0].routing_state,
+ "unassigned",
+ )
+ self.assertEqual(
+ data.agents[0].credential_state,
+ "legacy",
+ )
+
+ def test_api_permission_mapping_is_explicit(self):
+ self.assertEqual(
+ required_api_permission("GET", "/api/agents"),
+ "agents.view",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_agent_registry_postgres.py b/backend/tests/test_agent_registry_postgres.py
new file mode 100644
index 0000000..7f7ac06
--- /dev/null
+++ b/backend/tests/test_agent_registry_postgres.py
@@ -0,0 +1,194 @@
+"""PostgreSQL integration test for the Agent registry projection."""
+
+import unittest
+from uuid import uuid4
+
+from app.agent_registry.repository import (
+ AgentRegistryRepository,
+ default_connection_factory,
+)
+from app.agent_registry.service import AgentRegistryService
+
+
+class AgentRegistryPostgresTests(unittest.TestCase):
+ def setUp(self):
+ self.site_id = uuid4()
+ self.zone_id = uuid4()
+ self.agent_id = f"agent-registry-{uuid4().hex}"
+
+ connection = default_connection_factory()
+
+ try:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ INSERT INTO sites (id, name)
+ VALUES (%s, %s)
+ """,
+ (
+ self.site_id,
+ "Registry Integration Site",
+ ),
+ )
+
+ cur.execute(
+ """
+ INSERT INTO agent_zones (
+ id,
+ site_id,
+ name,
+ description,
+ is_enabled
+ )
+ VALUES (%s, %s, %s, %s, true)
+ """,
+ (
+ self.zone_id,
+ self.site_id,
+ "Registry Network",
+ "Integration-test network",
+ ),
+ )
+
+ cur.execute(
+ """
+ INSERT INTO agent_zone_network_ranges (
+ agent_zone_id,
+ network
+ )
+ VALUES (%s, '10.251.0.0/24'::cidr)
+ """,
+ (self.zone_id,),
+ )
+
+ cur.execute(
+ """
+ INSERT INTO minerops_site_agents (
+ agent_id,
+ site_name,
+ site_id,
+ agent_zone_id,
+ display_name,
+ hostname,
+ version,
+ ip_address,
+ agent_secret_hash,
+ credential_created_at
+ )
+ VALUES (
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ %s,
+ now()
+ )
+ """,
+ (
+ self.agent_id,
+ "Registry Integration Site",
+ self.site_id,
+ self.zone_id,
+ "Registry Test Agent",
+ "registry-agent",
+ "1.0.0-alpha",
+ "10.251.0.10",
+ "a" * 64,
+ ),
+ )
+
+ connection.commit()
+ finally:
+ connection.close()
+
+ def tearDown(self):
+ connection = default_connection_factory()
+
+ try:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ DELETE FROM minerops_site_agents
+ WHERE agent_id = %s
+ """,
+ (self.agent_id,),
+ )
+
+ cur.execute(
+ """
+ DELETE FROM agent_zone_network_ranges
+ WHERE agent_zone_id = %s
+ """,
+ (self.zone_id,),
+ )
+
+ cur.execute(
+ """
+ DELETE FROM agent_zones
+ WHERE id = %s
+ """,
+ (self.zone_id,),
+ )
+
+ cur.execute(
+ """
+ DELETE FROM sites
+ WHERE id = %s
+ """,
+ (self.site_id,),
+ )
+
+ connection.commit()
+ finally:
+ connection.close()
+
+ def test_registry_joins_site_zone_network_and_redacts_secret_hash(self):
+ data = AgentRegistryService(
+ AgentRegistryRepository()
+ ).list_registry()
+
+ agents = [
+ item
+ for item in data.agents
+ if item.agent_id == self.agent_id
+ ]
+
+ self.assertEqual(len(agents), 1)
+
+ agent = agents[0]
+
+ self.assertEqual(
+ agent.site_name,
+ "Registry Integration Site",
+ )
+ self.assertEqual(
+ agent.agent_zone_name,
+ "Registry Network",
+ )
+ self.assertEqual(agent.credential_state, "active")
+ self.assertEqual(agent.routing_state, "assigned")
+
+ zones = [
+ zone
+ for zone in data.zones
+ if zone.id == self.zone_id
+ ]
+
+ self.assertEqual(len(zones), 1)
+ self.assertEqual(
+ zones[0].networks,
+ ["10.251.0.0/24"],
+ )
+
+ serialized = data.model_dump_json()
+
+ self.assertNotIn("agent_secret_hash", serialized)
+ self.assertNotIn("a" * 64, serialized)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test-agent-management.sh b/scripts/test-agent-management.sh
new file mode 100755
index 0000000..d7cb929
--- /dev/null
+++ b/scripts/test-agent-management.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-test-$$}"
+
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+
+ if (( status != 0 )); then
+ "${compose[@]}" logs \
+ --no-color \
+ --tail 120 \
+ postgres || true
+ fi
+
+ "${compose[@]}" down \
+ --volumes \
+ --remove-orphans \
+ >/dev/null 2>&1 || true
+
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+[[ -f .env.dev ]] || {
+ echo "Missing .env.dev" >&2
+ exit 1
+}
+
+for source_file in \
+ backend/app/agent_registry/__init__.py \
+ backend/app/agent_registry/models.py \
+ backend/app/agent_registry/repository.py \
+ backend/app/agent_registry/service.py \
+ backend/app/agent_registry/dependencies.py \
+ backend/app/agent_registry/router.py \
+ backend/tests/test_agent_registry.py \
+ backend/tests/test_agent_registry_postgres.py \
+ backend/tests/test_agent_management_frontend.py \
+ backend/app/frontend/agent_management_view.py \
+ backend/app/frontend/static/agent-management.js
+do
+ [[ -r "${source_file}" ]] || {
+ echo "Missing ${source_file}" >&2
+ exit 1
+ }
+done
+
+"${compose[@]}" up --detach postgres
+
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1
+ then
+ break
+ fi
+
+ (( attempt < 60 )) || {
+ echo "PostgreSQL did not become ready" >&2
+ exit 1
+ }
+
+ sleep 1
+done
+
+"${compose[@]}" build backend
+
+"${compose[@]}" run --rm --no-deps \
+ backend alembic upgrade head
+
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_agent_registry \
+ tests.test_agent_registry_postgres \
+ tests.test_agent_management_frontend
+
+echo "Agent Management tests passed."