feat: implement Swapper metrics collection and UI

This commit is contained in:
Apple
2025-11-30 15:12:49 -08:00
parent 5b5160ad8b
commit fd814b2059
11 changed files with 1224 additions and 4543 deletions

View File

@@ -18,6 +18,7 @@ import {
NodeMetricsCard
} from '@/components/node-dashboard';
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
import { NodeSwapperCard } from '@/components/node-dashboard/NodeSwapperCard';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
function getNodeLabel(nodeId: string): string {
@@ -151,6 +152,9 @@ export default function NodeCabinetPage() {
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
{/* Swapper Service */}
<NodeSwapperCard nodeId={nodeId} />
{/* MicroDAO Presence */}
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
@@ -310,6 +314,11 @@ export default function NodeCabinetPage() {
/>
</div>
{/* Swapper Service (if production or dev with services) */}
<div className="mb-6">
<NodeSwapperCard nodeId={nodeId} />
</div>
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
<div className="mb-6">
<NodeMetricsCard nodeId={nodeId} />

View File

@@ -0,0 +1,110 @@
'use client';
import { useNodeSwapper, SwapperModel } from '@/hooks/useNodeSwapper';
import { useState } from 'react';
interface NodeSwapperCardProps {
nodeId: string;
}
export function NodeSwapperCard({ nodeId }: NodeSwapperCardProps) {
const { swapper, isLoading, error, mutate } = useNodeSwapper(nodeId);
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 animate-pulse">
<div className="h-6 bg-white/10 rounded w-1/3 mb-4" />
<div className="h-4 bg-white/10 rounded w-1/2" />
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 backdrop-blur-md rounded-2xl border border-red-500/20 p-6 text-red-400">
<h3 className="font-semibold flex items-center gap-2">
<span>🧠</span> Swapper Service
</h3>
<p className="text-sm mt-2">Failed to load status</p>
</div>
);
}
if (!swapper) return null;
const statusColor = swapper.healthy ? 'text-green-400' : 'text-red-400';
const statusIcon = swapper.healthy ? '🟢' : '🔴';
const statusText = swapper.healthy ? 'Healthy' : 'Down';
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 transition-all hover:border-cyan-500/30">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<span>🧠</span> Swapper Service
</h3>
<button
onClick={() => mutate()}
className="text-white/30 hover:text-white/70 transition-colors"
title="Refresh"
>
</button>
</div>
<div className="space-y-3">
{/* Status Row */}
<div className="flex items-center justify-between text-sm">
<span className="text-white/50">Status:</span>
<span className={`font-medium flex items-center gap-1.5 ${statusColor}`}>
{statusIcon} {statusText}
</span>
</div>
{/* Summary Row */}
<div className="flex items-center justify-between text-sm">
<span className="text-white/50">Models:</span>
<span className="text-white font-medium">
{swapper.models_loaded} / {swapper.models_total} loaded
</span>
</div>
{/* Details Toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full mt-2 py-2 text-xs font-medium text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-1"
>
{expanded ? 'Hide Models' : 'View Models'}
<span>{expanded ? '▲' : '▼'}</span>
</button>
{/* Models List */}
{expanded && (
<div className="mt-3 space-y-2 border-t border-white/10 pt-3">
{swapper.models.length === 0 ? (
<p className="text-white/30 text-xs text-center py-2">No models found</p>
) : (
swapper.models.map((model, idx) => (
<div key={`${model.name}-${idx}`} className="flex items-center justify-between text-xs bg-black/20 p-2 rounded border border-white/5">
<div className="flex flex-col gap-0.5">
<span className="text-white font-medium">{model.name}</span>
<span className="text-white/40 uppercase text-[10px]">{model.type || 'unknown'}</span>
</div>
<div className="flex flex-col items-end gap-0.5">
<span className={model.loaded ? 'text-green-400' : 'text-white/30'}>
{model.loaded ? '● LOADED' : '○ UNLOADED'}
</span>
{model.vram_gb && (
<span className="text-white/30">{model.vram_gb.toFixed(1)} GB</span>
)}
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import useSWR from "swr";
const fetcher = (url: string) => fetch(url, { credentials: "include" }).then(r => {
if (!r.ok) throw new Error(`Failed to load ${url}`);
return r.json();
});
export interface SwapperModel {
name: string;
loaded: boolean;
type?: string;
vram_gb?: number;
}
export interface NodeSwapperDetail {
node_id: string;
healthy: boolean;
models_loaded: number;
models_total: number;
models: SwapperModel[];
}
export function useNodeSwapper(nodeId?: string) {
const { data, error, isLoading, mutate } = useSWR<NodeSwapperDetail>(
nodeId ? `/api/v1/internal/node/${nodeId}/swapper` : null,
fetcher
);
return {
swapper: data,
isLoading,
error,
mutate,
};
}

View File

@@ -0,0 +1,261 @@
# TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1
Проєкт: DAARION.city — Swapper Service / Node Cabinet
Фаза: Метрики Swapper + відображення у Кабінеті Ноди
Мета: зробити так, щоб Swapper був «першокласним» сервісом у нодовій архітектурі:
- node-guardian-loop збирає метрики Swapper;
- ці метрики зберігаються в node_cache / внутрішньому API;
- у Кабінеті Ноди з’являється блок Swapper (статус + моделі);
- (опційно) Swapper Agent використовує ці дані для self-healing.
---
## 0. Поточний стан
- Heartbeat нод живий, node-guardian-loop запущений на NODE1 і NODE2.
- Node metrics (`/internal/node/{id}/metrics/current`) оновлюються.
- DAGI Router UI більше не дає «Unknown error» (оновлений URL).
- Swapper Service працює, але:
- не збираються і не передаються метрики Swapper;
- Кабінет Ноди не відображає стан Swapper;
- Swapper Agent поки не має даних для діагностики.
---
## 1. Scope
### Включено
1. Збір метрик Swapper у node-guardian-loop.
2. Розширення node_cache / node metrics API полями для Swapper.
3. Новий internal endpoint для детальнішої інформації про Swapper:
- списки моделей / базові показники.
4. Оновлення Кабінету Ноди:
- блок «Swapper Service» зі статусом і основною інформацією.
5. Базові тести (backend + frontend).
### Виключено
- Повний self-healing Swapper Agent (рестарти, pull моделей) — це окрема фаза (service agents).
- Розгорнуті Charts по Swapper (latency history) — можна додати пізніше.
---
## 2. Swapper API — припущення
Для MVP вважаємо:
- Swapper HTTP base URL (з кабінетів/compose):
`http://swapper-service:8890`
- Корисні endpoints:
- `GET /healthz` — здоров’я Swapper.
- `GET /v1/models` — список моделей, щось на кшталт:
```json
{
"models": [
{ "name": "model-a", "loaded": true, "type": "llm", "vram_gb": 8.0 },
{ "name": "model-b", "loaded": false, "type": "vlm" }
]
}
```
Якщо формат інший — адаптувати, але інтерфейс на стороні city-service робити уніфікованим.
---
## 3. Backend: node-guardian-loop → Swapper метрики
### 3.1. Розширити node_cache (якщо потрібно)
Перевірити існуючу структуру `node_cache`. Якщо полів для Swapper немає — додати міграцію, наприклад:
```sql
alter table node_cache
add column swapper_healthy boolean,
add column swapper_models_loaded integer,
add column swapper_models_total integer,
add column swapper_state jsonb; -- Для повного списку моделей
```
Міграція: `migrations/039_node_cache_swapper_metrics.sql`.
### 3.2. Оновити node-guardian-loop
У `node-guardian-loop.py` (або відповідному worker):
1. Додати функцію для збору Swapper-метрик:
```python
import requests
def collect_swapper_metrics(swapper_base_url: str) -> dict:
result = {
"swapper_healthy": False,
"swapper_models_loaded": 0,
"swapper_models_total": 0,
"swapper_state": {}
}
try:
# healthz
r = requests.get(f"{swapper_base_url}/healthz", timeout=3)
result["swapper_healthy"] = (r.status_code == 200)
except Exception:
result["swapper_healthy"] = False
try:
r = requests.get(f"{swapper_base_url}/v1/models", timeout=5)
if r.status_code == 200:
data = r.json()
models = data.get("models", [])
total = len(models)
loaded = sum(1 for m in models if m.get("loaded") is True)
result["swapper_models_total"] = total
result["swapper_models_loaded"] = loaded
result["swapper_state"] = data # Зберігаємо весь стан
except Exception:
pass
return result
```
2. У циклі node-guardian-loop (перед `metrics/update`):
* викликати `collect_swapper_metrics(...)`;
* передавати ці поля до city-service через `POST /internal/node/{id}/metrics/update`:
```python
payload = {
"agent_count_router": ...,
"agent_count_system": ...,
"gpu": {...},
"cpu": {...},
# ...
"swapper_healthy": swapper_metrics["swapper_healthy"],
"swapper_models_loaded": swapper_metrics["swapper_models_loaded"],
"swapper_models_total": swapper_metrics["swapper_models_total"],
"swapper_state": swapper_metrics["swapper_state"]
}
requests.post(f"{CITY_URL}/internal/node/{node_id}/metrics/update", json=payload, timeout=5)
```
### 3.3. Оновити обробник `/internal/node/{id}/metrics/update`
У `routes_city.py`:
* приймати нові поля;
* оновлювати відповідні колонки в `node_cache`.
---
## 4. Backend: Swapper detail endpoint
Додати окремий internal endpoint для детального перегляду Swapper-стану ноди:
`GET /internal/node/{node_id}/swapper`
Відповідь:
```json
{
"node_id": "node-2-macbook-m4max",
"healthy": true,
"models_loaded": 3,
"models_total": 5,
"models": [
{ "name": "daarion-small-3b", "loaded": true, "type": "llm" },
{ "name": "daarion-code-7b", "loaded": true, "type": "code" },
{ "name": "vision-8b", "loaded": false, "type": "vlm" }
]
}
```
Джерело:
* `node_cache` (нові колонки + jsonb `swapper_state`).
---
## 5. Frontend: Кабінет Ноди → Swapper
### 5.1. Хук `useNodeSwapper(nodeId)`
У `apps/web/src/hooks`:
```ts
import useSWR from "swr";
export function useNodeSwapper(nodeId: string) {
return useSWR(`/internal/node/${nodeId}/swapper`, fetcher);
}
```
### 5.2. Компонент `NodeSwapperCard`
Новий компонент у `components/node-dashboard/NodeSwapperCard.tsx`, який показує:
* заголовок: `Swapper Service`;
* статус:
* `🟢 Healthy` / `🟡 Degraded` / `🔴 Down` (на основі `healthy` + `models_loaded`);
* коротке резюме:
* `Моделі: 3/5 завантажено`;
* кнопка/кнопка-розкривалка `Переглянути моделі`:
* список моделей (name, type, loaded).
### 5.3. Інтеграція в Node Cabinet
На сторінці `/nodes/[nodeId]`:
* додати `NodeSwapperCard` поруч із `NodeMetricsCard` / `DAGIRouterCard` у секцію Service Agents.
---
## 6. Tests
### 6.1. Backend
* Тест міграції `node_cache` (наявність нових колонок).
* Тест `POST /internal/node/{id}/metrics/update`:
* при передачі полів Swapper правильно оновлює `node_cache`.
* Тест `GET /internal/node/{id}/swapper`:
* при коректній відповіді Swapper API повертає очікувану структуру;
* якщо Swapper лежить — `healthy=false`, моделі порожні.
### 6.2. Frontend
* Snapshot-тест `NodeSwapperCard` для випадків:
* healthy + моделі є;
* unhealthy + немає моделей;
* loading/error state.
---
## 7. Acceptance Criteria
1. node-guardian-loop регулярно збирає Swapper-метрики та відправляє їх у city-service.
2. `node_cache` містить свіжі поля:
* `swapper_healthy`
* `swapper_models_loaded`
* `swapper_models_total`
* `swapper_state` (JSONB)
3. `GET /internal/node/{id}/metrics/current` відображає Swapper-метрики для обох нод.
4. `GET /internal/node/{id}/swapper` повертає детальну інформацію про Swapper (мінімум: healthy + models).
5. У Кабінеті Ноди (`/nodes/[nodeId]`) є блок Swapper Service:
* показує статус,
* показує кількість завантажених моделей,
* дозволяє переглянути список моделей.
6. `scripts/check-deploy-post.py` оновлено для перевірки Swapper.
---
## 8. Deliverables
* `migrations/039_node_cache_swapper_metrics.sql`
* Оновлений `node-guardian-loop.py` (з Swapper-метриками)
* Оновлені endpoints:
* `POST /internal/node/{id}/metrics/update`
* `GET /internal/node/{id}/swapper`
* Frontend:
* `useNodeSwapper(nodeId)`
* `NodeSwapperCard`
* Інтеграція в Node Cabinet
* Тести (backend + frontend)

View File

@@ -0,0 +1,13 @@
-- Migration 039: Add Swapper metrics to node_cache
-- Додаємо поля для метрик Swapper Service в node_cache
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_healthy boolean DEFAULT false;
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_loaded integer DEFAULT 0;
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_total integer DEFAULT 0;
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_state jsonb DEFAULT '{}'::jsonb;
COMMENT ON COLUMN node_cache.swapper_healthy IS 'Статус здоров`я Swapper Service';
COMMENT ON COLUMN node_cache.swapper_models_loaded IS 'Кількість завантажених моделей';
COMMENT ON COLUMN node_cache.swapper_models_total IS 'Загальна кількість відомих моделей';
COMMENT ON COLUMN node_cache.swapper_state IS 'Повний стан моделей Swapper (JSON)';

View File

@@ -181,10 +181,49 @@ class NodeGuardian:
return {"error": str(e)}
async def collect_metrics(self) -> Dict[str, Any]:
"""Зібрати метрики ноди (базова реалізація)"""
# TODO: Implement real metrics collection
# For now, return empty metrics
return {}
"""Зібрати метрики ноди та Swapper"""
metrics = {
"cpu_usage": 0.0, # TODO: Implement real metrics
"gpu_vram_used": 0,
"ram_used": 0,
"disk_used": 0,
"agent_count_router": 0,
"agent_count_system": 0,
"dagi_router_url": "http://dagi-router:9102",
# Swapper defaults
"swapper_healthy": False,
"swapper_models_loaded": 0,
"swapper_models_total": 0,
"swapper_state": {}
}
# Collect Swapper Metrics
swapper_url = os.getenv("SWAPPER_URL", "http://swapper-service:8890")
try:
# Check healthz
try:
r = await self.client.get(f"{swapper_url}/healthz", timeout=3.0)
metrics["swapper_healthy"] = (r.status_code == 200)
except Exception:
metrics["swapper_healthy"] = False
# Check models
try:
r = await self.client.get(f"{swapper_url}/v1/models", timeout=5.0)
if r.status_code == 200:
data = r.json()
models = data.get("models", [])
metrics["swapper_models_total"] = len(models)
metrics["swapper_models_loaded"] = sum(1 for m in models if m.get("loaded") is True)
metrics["swapper_state"] = data
except Exception as e:
logger.warning(f"Failed to fetch Swapper models: {e}")
pass
except Exception as e:
logger.warning(f"Swapper metrics collection failed: {e}")
return metrics
async def run_health_check(self) -> Dict[str, Any]:
"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
# TASK 038: Dynamic discovery of Node Guardian / Steward if cache is empty
if not data.get("guardian_agent") or not data.get("steward_agent"):
dynamic_agents = await pool.fetch("""
SELECT id, display_name, kind, public_slug
FROM agents
WHERE node_id = $1
AND (kind IN ('node_guardian', 'node_steward') OR kind IN ('infra_monitor', 'infra_ops'))
AND COALESCE(is_archived, false) = false
""", node_id)
if not data.get("guardian_agent"):
# Prefer 'node_guardian', fallback to 'infra_monitor'
guardian_candidates = [a for a in dynamic_agents if a['kind'] == 'node_guardian']
monitor_candidates = [a for a in dynamic_agents if a['kind'] == 'infra_monitor']
guardian = guardian_candidates[0] if guardian_candidates else (monitor_candidates[0] if monitor_candidates else None)
if guardian:
data["guardian_agent"] = {
"id": guardian["id"],
"name": guardian["display_name"],
"kind": guardian["kind"],
"slug": guardian["public_slug"],
}
if not data.get("steward_agent"):
# Prefer 'node_steward', fallback to 'infra_ops'
steward_candidates = [a for a in dynamic_agents if a['kind'] == 'node_steward']
ops_candidates = [a for a in dynamic_agents if a['kind'] == 'infra_ops']
steward = steward_candidates[0] if steward_candidates else (ops_candidates[0] if ops_candidates else None)
if steward:
data["steward_agent"] = {
"id": steward["id"],
"name": steward["display_name"],
"kind": steward["kind"],
"slug": steward["public_slug"],
}

View File

@@ -0,0 +1,16 @@
# Fetch MicroDAOs where orchestrator is on this node
print(f"DEBUG: Fetching microdaos for node {node_id}")
try:
microdaos = await pool.fetch("""
SELECT m.id, m.slug, m.name, COUNT(cr.id) as rooms_count
FROM microdaos m
JOIN agents a ON m.orchestrator_agent_id = a.id
LEFT JOIN city_rooms cr ON cr.microdao_id::text = m.id
WHERE a.node_id = $1
GROUP BY m.id, m.slug, m.name
ORDER BY m.name
""", node_id)
print(f"DEBUG: Microdaos fetched: {len(microdaos)}")
except Exception as e:
print(f"DEBUG: Error fetching microdaos: {e}")
raise e

View File

@@ -4057,6 +4057,52 @@ async def get_node_self_healing_status(node_id: str):
)
@router.get("/internal/node/{node_id}/swapper", response_model=NodeSwapperDetail)
async def get_node_swapper_detail(node_id: str):
"""
Get detailed Swapper Service status for a node.
Used by Node Cabinet to show loaded models and health.
"""
try:
# Fetch from node_cache
metrics = await repo_city.get_node_metrics(node_id)
if not metrics:
raise HTTPException(status_code=404, detail="Node not found")
# Parse swapper state (stored as JSONB)
state = metrics.get("swapper_state") or {}
models_data = state.get("models", [])
models = [
SwapperModel(
name=m.get("name", "unknown"),
loaded=m.get("loaded", False),
type=m.get("type"),
vram_gb=m.get("vram_gb")
)
for m in models_data
]
return NodeSwapperDetail(
node_id=node_id,
healthy=metrics.get("swapper_healthy", False),
models_loaded=metrics.get("swapper_models_loaded", 0),
models_total=metrics.get("swapper_models_total", 0),
models=models
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get swapper detail for {node_id}: {e}")
return NodeSwapperDetail(
node_id=node_id,
healthy=False,
models_loaded=0,
models_total=0,
models=[]
)
@router.get("/internal/node/{node_id}/directory-check")
async def check_node_in_directory(node_id: str):
"""