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"))]
|
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||||
MinerUser = Annotated[dict, Depends(require_permission("miners.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:
|
def render_page(view: str, current_user: dict, **context) -> str:
|
||||||
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
|
|||||||
headers={"Cache-Control": "no-store"},
|
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)
|
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def dashboard_page(current_user: DashboardUser) -> str:
|
def dashboard_page(current_user: DashboardUser) -> str:
|
||||||
return render_page("dashboard", current_user)
|
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;
|
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.miner_detail_view import miner_detail_content
|
||||||
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||||
from app.frontend.system_health_view import system_health_content
|
from app.frontend.system_health_view import system_health_content
|
||||||
|
from app.frontend.agent_management_view import agent_management_content
|
||||||
|
|
||||||
|
|
||||||
NAVIGATION = (
|
NAVIGATION = (
|
||||||
@@ -15,6 +16,7 @@ NAVIGATION = (
|
|||||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||||
("system_health", "System Health", "/system-health", "system_health.view"),
|
("system_health", "System Health", "/system-health", "system_health.view"),
|
||||||
|
("agent_management", "Agents", "/agent/management", "agents.view"),
|
||||||
)
|
)
|
||||||
|
|
||||||
PAGE_DETAILS = {
|
PAGE_DETAILS = {
|
||||||
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
|
|||||||
"System Health",
|
"System Health",
|
||||||
"Inspect application services, operational data, and maintenance facts.",
|
"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>'
|
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||||
)
|
)
|
||||||
content = (
|
content = (
|
||||||
system_health_content()
|
agent_management_content(has_permission("agents.manage"))
|
||||||
|
if view == "agent_management"
|
||||||
|
else system_health_content()
|
||||||
if view == "system_health"
|
if view == "system_health"
|
||||||
else miner_detail_content()
|
else miner_detail_content()
|
||||||
if view == "miner_detail"
|
if view == "miner_detail"
|
||||||
|
|||||||
@@ -779,6 +779,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
|||||||
return "repairs.read"
|
return "repairs.read"
|
||||||
if component_request(path):
|
if component_request(path):
|
||||||
return "components.create" if method == "POST" else "components.read"
|
return "components.create" if method == "POST" else "components.read"
|
||||||
|
if path == "/api/agents":
|
||||||
|
return "agents.view"
|
||||||
if path == "/api/agent-zones":
|
if path == "/api/agent-zones":
|
||||||
return "agents.view" if method == "GET" else "agents.manage"
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
if path == "/api/agent-enrollments":
|
if path == "/api/agent-enrollments":
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.agent_commands.dependencies import require_agent_identity
|
from app.agent_commands.dependencies import require_agent_identity
|
||||||
from app.agent_commands.router import router as agent_commands_router
|
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.dependencies import get_agent_zone_service
|
||||||
from app.agent_zones.models import MinerZoneAssociationResult
|
from app.agent_zones.models import MinerZoneAssociationResult
|
||||||
from app.agent_zones.service import AgentZoneService
|
from app.agent_zones.service import AgentZoneService
|
||||||
@@ -73,6 +74,7 @@ app.include_router(frontend_router)
|
|||||||
app.include_router(inventory_router)
|
app.include_router(inventory_router)
|
||||||
app.include_router(miner_control_router)
|
app.include_router(miner_control_router)
|
||||||
app.include_router(agent_commands_router)
|
app.include_router(agent_commands_router)
|
||||||
|
app.include_router(agent_registry_router)
|
||||||
app.include_router(agent_zones_router)
|
app.include_router(agent_zones_router)
|
||||||
app.include_router(agent_enrollment_router)
|
app.include_router(agent_enrollment_router)
|
||||||
app.include_router(components_router)
|
app.include_router(components_router)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+90
@@ -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."
|
||||||
Reference in new issue
Block a user