Skip to content

Commit 17d5ed6

Browse files
committed
feat(agent+ui): resource metrics in heartbeat — CPU/mem/disk health bars
Migration (0006): • 3 nullable float columns added to agents table: cpu_percent, memory_percent, disk_percent Server: • HeartbeatRequest accepts optional cpu_percent, memory_percent, disk_percent • Heartbeat handler persists provided (non-None) metric values to the agent record; omitted / None fields leave existing values unchanged • AgentOut exposes the three metric fields (None when not yet reported) Agent: • _collect_resource_metrics() — uses psutil when installed, falls back gracefully to None values; any psutil error is caught • _heartbeat_loop: calls _collect_resource_metrics via asyncio.to_thread and merges the result into the heartbeat POST body UI (Agents.tsx): • MetricBar component: compact horizontal bar (green/yellow/red based on threshold) with a percentage label • CPU / Memory / Disk columns shown when at least one agent has reported metrics; hidden otherwise (clean view for fresh deployments) • Last-heartbeat timestamp replaces the stale 'last_seen' field • agents.ts interface updated with cpu/memory/disk_percent and last_heartbeat Tests: • 10 new tests: _collect_resource_metrics (4), heartbeat stores/skips metrics (3), AgentOut schema (3) • test_agent_auto_update.py: patched _collect_resource_metrics to avoid asyncio.to_thread blocking the single-yield test loop • 611 total tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbfhpQR6qzZAXcTacEcZvn
1 parent 41a995c commit 17d5ed6

13 files changed

Lines changed: 440 additions & 31 deletions

File tree

agent/core.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,10 @@ async def _heartbeat_loop(self) -> None:
320320

321321
try:
322322
async with self._build_client() as client:
323+
metrics = await asyncio.to_thread(_collect_resource_metrics)
323324
resp = await client.post(
324325
f"/api/v1/agents/{cfg.agent_id}/heartbeat",
325-
json={"agent_version": _agent_version()},
326+
json={"agent_version": _agent_version(), **metrics},
326327
)
327328
resp.raise_for_status()
328329
consecutive_failures = 0
@@ -586,3 +587,20 @@ def _agent_version() -> str:
586587
return version("discoverykastle-agent")
587588
except Exception:
588589
return "dev"
590+
591+
592+
def _collect_resource_metrics() -> dict[str, float | None]:
593+
"""
594+
Return current CPU, memory, and disk utilisation as percentages.
595+
596+
Uses ``psutil`` when available; falls back to ``None`` values so that
597+
agents without psutil still send valid heartbeats.
598+
"""
599+
try:
600+
import psutil
601+
cpu = psutil.cpu_percent(interval=0.1)
602+
mem = psutil.virtual_memory().percent
603+
disk = psutil.disk_usage("/").percent
604+
return {"cpu_percent": cpu, "memory_percent": mem, "disk_percent": disk}
605+
except Exception:
606+
return {"cpu_percent": None, "memory_percent": None, "disk_percent": None}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Add resource metric columns to agents table
2+
3+
Revision ID: 0006
4+
Revises: 0005
5+
Create Date: 2026-09-06 00:00:00.000000
6+
7+
Adds three nullable float columns to the ``agents`` table so that resource
8+
metrics reported in heartbeats (CPU %, memory %, disk %) can be stored and
9+
exposed in the API. All columns are nullable — agents that do not report
10+
metrics (older versions, or psutil not installed) leave them NULL.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Sequence, Union
16+
17+
import sqlalchemy as sa
18+
from alembic import op
19+
20+
revision: str = "0006"
21+
down_revision: Union[str, None] = "0005"
22+
branch_labels: Union[str, Sequence[str], None] = None
23+
depends_on: Union[str, Sequence[str], None] = None
24+
25+
26+
def upgrade() -> None:
27+
op.add_column("agents", sa.Column("cpu_percent", sa.Float, nullable=True))
28+
op.add_column("agents", sa.Column("memory_percent", sa.Float, nullable=True))
29+
op.add_column("agents", sa.Column("disk_percent", sa.Float, nullable=True))
30+
31+
32+
def downgrade() -> None:
33+
op.drop_column("agents", "disk_percent")
34+
op.drop_column("agents", "memory_percent")
35+
op.drop_column("agents", "cpu_percent")

server/api/agents.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class AgentOut(BaseModel):
6969
status: str
7070
authorized_cidrs: list[str]
7171
last_heartbeat: datetime | None
72+
# Resource metrics — None when the agent has not reported them yet
73+
cpu_percent: float | None = None
74+
memory_percent: float | None = None
75+
disk_percent: float | None = None
7276
created_at: datetime
7377

7478

@@ -216,8 +220,13 @@ class CertRenewResponse(BaseModel):
216220

217221

218222
class HeartbeatRequest(BaseModel):
219-
"""Optional body sent by the agent with its current version."""
223+
"""Optional body sent by the agent with its current version and resource metrics."""
220224
agent_version: str | None = None
225+
# Resource utilisation — collected via psutil on the agent host.
226+
# Values are percentages (0.0–100.0). Omitted when psutil is unavailable.
227+
cpu_percent: float | None = None
228+
memory_percent: float | None = None
229+
disk_percent: float | None = None
221230

222231

223232
@router.post("/{agent_id}/heartbeat", response_model=HeartbeatResponse)
@@ -241,6 +250,14 @@ async def heartbeat(
241250
if body.agent_version and body.agent_version != agent.version:
242251
agent.version = body.agent_version
243252

253+
# Persist resource metrics when provided.
254+
if body.cpu_percent is not None:
255+
agent.cpu_percent = body.cpu_percent
256+
if body.memory_percent is not None:
257+
agent.memory_percent = body.memory_percent
258+
if body.disk_percent is not None:
259+
agent.disk_percent = body.disk_percent
260+
244261
agent.last_heartbeat = datetime.utcnow()
245262
agent.status = "online"
246263
await db.commit()

server/models/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ class Agent(Base):
1717
status: Mapped[str] = mapped_column(String(20), nullable=False, default="offline")
1818
authorized_cidrs: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
1919
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
20+
# Resource metrics — populated from heartbeat payloads when psutil is
21+
# available on the agent host. Nullable so older agent versions that
22+
# do not send metrics don't fail.
23+
cpu_percent: Mapped[float | None] = mapped_column(nullable=True)
24+
memory_percent: Mapped[float | None] = mapped_column(nullable=True)
25+
disk_percent: Mapped[float | None] = mapped_column(nullable=True)
2026
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
2127
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
2228

server/static/ui/assets/index-C5c7_b5A.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

server/static/ui/assets/index-CTgcwoO2.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/static/ui/assets/index-DF9VHKCk.js

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/static/ui/assets/index-pILZ0F_3.js

Lines changed: 0 additions & 10 deletions
This file was deleted.

server/static/ui/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Discoverykastle</title>
77
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8-
<script type="module" crossorigin src="/assets/index-pILZ0F_3.js"></script>
9-
<link rel="stylesheet" crossorigin href="/assets/index-C5c7_b5A.css">
8+
<script type="module" crossorigin src="/assets/index-DF9VHKCk.js"></script>
9+
<link rel="stylesheet" crossorigin href="/assets/index-CTgcwoO2.css">
1010
</head>
1111
<body class="bg-surface-0 text-gray-100 antialiased">
1212
<div id="root"></div>

tests/test_agent_auto_update.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,22 @@ async def _fake_post(url, **kwargs):
8080
mock_client.__aexit__ = AsyncMock(return_value=False)
8181
mock_client.post = AsyncMock(side_effect=_fake_post)
8282

83+
# Stub out resource-metric collection (runs in a thread) so it
84+
# resolves instantly and the heartbeat POST is reached before the
85+
# task is cancelled.
86+
_empty_metrics = {"cpu_percent": None, "memory_percent": None, "disk_percent": None}
8387
with patch.object(agent, "_build_client", return_value=mock_client):
8488
with patch("agent.core._agent_version", return_value="0.1.0"):
85-
loop_task = asyncio.create_task(agent._heartbeat_loop())
86-
await asyncio.sleep(0)
87-
loop_task.cancel()
88-
try:
89-
await loop_task
90-
except asyncio.CancelledError:
91-
pass
89+
with patch("agent.core._collect_resource_metrics", return_value=_empty_metrics):
90+
loop_task = asyncio.create_task(agent._heartbeat_loop())
91+
await asyncio.sleep(0) # cert-renewal noop
92+
await asyncio.sleep(0) # to_thread for metrics
93+
await asyncio.sleep(0) # POST
94+
loop_task.cancel()
95+
try:
96+
await loop_task
97+
except asyncio.CancelledError:
98+
pass
9299

93100
assert len(posted_bodies) >= 1
94101
assert posted_bodies[0].get("agent_version") == "0.1.0"

0 commit comments

Comments
 (0)