feat: implement swapper metrics and node cabinet ui

This commit is contained in:
Apple
2025-11-30 15:40:41 -08:00
parent cb9efaf656
commit 281c79f916
5 changed files with 223 additions and 100 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useNodeSwapper, SwapperModel } from '@/hooks/useNodeSwapper';
import { useState } from 'react';
import { useNodeSwapper } from '@/hooks/useNodeSwapper';
interface NodeSwapperCardProps {
nodeId: string;
@@ -9,95 +9,111 @@ interface NodeSwapperCardProps {
export function NodeSwapperCard({ nodeId }: NodeSwapperCardProps) {
const { swapper, isLoading, error, mutate } = useNodeSwapper(nodeId);
const [expanded, setExpanded] = useState(false);
const [isExpanded, setIsExpanded] = 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 className="h-6 w-32 bg-white/10 rounded mb-4" />
<div className="h-4 w-full bg-white/5 rounded" />
</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 className="bg-red-500/10 backdrop-blur-md rounded-2xl border border-red-500/20 p-6">
<h3 className="text-red-400 font-medium mb-2">Swapper Service Unavailable</h3>
<p className="text-white/50 text-sm">Failed to load swapper details.</p>
<button
onClick={() => mutate()}
className="mt-4 text-xs text-white/40 hover:text-white hover:underline"
>
Retry
</button>
</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="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<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 className="flex items-center gap-2">
<button
onClick={() => mutate()}
className="p-1.5 rounded-lg hover:bg-white/5 text-white/30 hover:text-white transition-colors"
title="Refresh"
>
</button>
</div>
</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 className="space-y-4">
{/* Status */}
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<div className="flex items-center justify-between mb-2">
<span className="text-white/50 text-sm">Status</span>
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${
swapper.healthy ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
swapper.healthy ? 'bg-green-400' : 'bg-red-400'
}`} />
{swapper.healthy ? 'Healthy' : 'Unhealthy'}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-white/50 text-sm">Models Loaded</span>
<span className="text-white font-mono">
{swapper.models_loaded} <span className="text-white/30">/ {swapper.models_total}</span>
</span>
</div>
</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 */}
{/* Models List 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"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-sm text-white/70"
>
{expanded ? 'Hide Models' : 'View Models'}
<span>{expanded ? '' : ''}</span>
<span>Available Models</span>
<span className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`}></span>
</button>
{/* Models List */}
{expanded && (
<div className="mt-3 space-y-2 border-t border-white/10 pt-3">
{isExpanded && (
<div className="space-y-2 mt-2">
{swapper.models.length === 0 ? (
<p className="text-white/30 text-xs text-center py-2">No models found</p>
<p className="text-center text-white/30 text-sm 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
key={`${model.name}-${idx}`}
className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5"
>
<div className="flex flex-col">
<span className="text-white text-sm font-medium">{model.name}</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-white/40 uppercase px-1.5 py-0.5 rounded bg-white/5">
{model.type || 'unknown'}
</span>
{model.vram_gb && (
<span className="text-xs text-white/30">
{model.vram_gb.toFixed(1)} GB VRAM
</span>
)}
</div>
</div>
<div className={`w-2 h-2 rounded-full ${
model.loaded ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-white/10'
}`} title={model.loaded ? 'Loaded in VRAM' : 'Not loaded'} />
</div>
))
)}
@@ -107,4 +123,3 @@ export function NodeSwapperCard({ nodeId }: NodeSwapperCardProps) {
</div>
);
}

View File

@@ -1,12 +1,23 @@
export { StatusBadge } from './StatusBadge';
export { ProgressBar } from './ProgressBar';
export { NodeSummaryCard } from './NodeSummaryCard';
export { InfraCard } from './InfraCard';
export { AIServicesCard } from './AIServicesCard';
export { AgentsCard } from './AgentsCard';
export { MatrixCard } from './MatrixCard';
export { ModulesCard } from './ModulesCard';
export { NodeStandardComplianceCard } from './NodeStandardComplianceCard';
export { DAGIRouterCard } from './DAGIRouterCard';
export { NodeMetricsCard } from './NodeMetricsCard';
import { NodeSummaryCard } from './NodeSummaryCard';
import { InfraCard } from './InfraCard';
import { AIServicesCard } from './AIServicesCard';
import { AgentsCard } from './AgentsCard';
import { MatrixCard } from './MatrixCard';
import { ModulesCard } from './ModulesCard';
import { NodeStandardComplianceCard } from './NodeStandardComplianceCard';
import { DAGIRouterCard } from './DAGIRouterCard';
import { NodeMetricsCard } from './NodeMetricsCard';
import { NodeSwapperCard } from './NodeSwapperCard';
export {
NodeSummaryCard,
InfraCard,
AIServicesCard,
AgentsCard,
MatrixCard,
ModulesCard,
NodeStandardComplianceCard,
DAGIRouterCard,
NodeMetricsCard,
NodeSwapperCard
};

View File

@@ -1,9 +1,4 @@
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();
});
import useSWR from 'swr';
export interface SwapperModel {
name: string;
@@ -20,7 +15,9 @@ export interface NodeSwapperDetail {
models: SwapperModel[];
}
export function useNodeSwapper(nodeId?: string) {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function useNodeSwapper(nodeId: string) {
const { data, error, isLoading, mutate } = useSWR<NodeSwapperDetail>(
nodeId ? `/api/v1/internal/node/${nodeId}/swapper` : null,
fetcher
@@ -33,4 +30,3 @@ export function useNodeSwapper(nodeId?: string) {
mutate,
};
}

View File

@@ -73,8 +73,7 @@
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; -- Для повного списку моделей
add column swapper_models_total integer;
```
Міграція: `migrations/039_node_cache_swapper_metrics.sql`.
@@ -91,9 +90,8 @@ 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": {}
"swapper_models_loaded": None,
"swapper_models_total": None,
}
try:
# healthz
@@ -111,8 +109,8 @@ def collect_swapper_metrics(swapper_base_url: str) -> dict:
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:
# залишаємо None → UI покаже "невідомо"
pass
return result
@@ -133,7 +131,6 @@ payload = {
"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)
```
@@ -145,6 +142,16 @@ requests.post(f"{CITY_URL}/internal/node/{node_id}/metrics/update", json=payload
* приймати нові поля;
* оновлювати відповідні колонки в `node_cache`.
Приклад (ескіз):
```python
swapper_healthy = body.get("swapper_healthy")
swapper_models_loaded = body.get("swapper_models_loaded")
swapper_models_total = body.get("swapper_models_total")
# update node_cache set ... where node_id = ...
```
---
## 4. Backend: Swapper detail endpoint
@@ -170,7 +177,11 @@ requests.post(f"{CITY_URL}/internal/node/{node_id}/metrics/update", json=payload
```
Джерело:
* `node_cache` (нові колонки + jsonb `swapper_state`).
* по можливості прямо через Swapper API;
* частину агрегованих значень (loaded/total) можна брати з `node_cache`.
Цей endpoint буде використовуватись Node Cabinet для розгорнутої таблиці моделей.
---
@@ -194,18 +205,42 @@ export function useNodeSwapper(nodeId: string) {
* заголовок: `Swapper Service`;
* статус:
* `🟢 Healthy` / `🟡 Degraded` / `🔴 Down` (на основі `healthy` + `models_loaded`);
* коротке резюме:
* `Моделі: 3/5 завантажено`;
* кнопка/кнопка-розкривалка `Переглянути моделі`:
* список моделей (name, type, loaded).
Макет (ескіз):
```txt
┌─────────────────────────────────────────────┐
│ 🧠 Swapper Service [↻] │
├─────────────────────────────────────────────┤
│ Статус: 🟢 Healthy │
│ Моделі: 3 / 5 завантажено │
├─────────────────────────────────────────────┤
│ d-model-3b | llm | 🟢 loaded │
│ d-code-7b | code | 🟢 loaded │
│ vision-8b | vlm | 🔴 not loaded│
└─────────────────────────────────────────────┘
```
### 5.3. Інтеграція в Node Cabinet
На сторінці `/nodes/[nodeId]`:
* додати `NodeSwapperCard` поруч із `NodeMetricsCard` / `DAGIRouterCard` у секцію Service Agents.
Наприклад:
* **Node Core**: Guardian/Steward
* **Service Agents**: DAGI Router, Swapper, Multimodal (Swapper Card тут)
* **Control & Intelligence**: Tools & Planner, Security, Archivist.
---
## 6. Tests
@@ -214,14 +249,17 @@ export function useNodeSwapper(nodeId: string) {
* Тест міграції `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.
@@ -232,17 +270,21 @@ export function useNodeSwapper(nodeId: string) {
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.
6. `scripts/check-deploy-post.py` можна оновити, щоб:
* додати 12 перевірки Swapper-статусу (healthy/models_loaded),
* і вони проходили при нормальному стані.
---
@@ -251,11 +293,12 @@ export function useNodeSwapper(nodeId: string) {
* `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

@@ -1,13 +1,71 @@
-- 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)';
-- Function to handle atomic node heartbeat and metrics update
-- Updated to include Swapper metrics
CREATE OR REPLACE FUNCTION fn_node_heartbeat(
p_node_id text,
p_metrics jsonb
) RETURNS void AS $$
BEGIN
INSERT INTO node_cache (
node_id, node_name, hostname, roles, environment, status, gpu, last_sync,
cpu_model, cpu_cores, cpu_usage, gpu_model, gpu_vram_total, gpu_vram_used,
ram_total, ram_used, disk_total, disk_used, agent_count_router, agent_count_system,
last_heartbeat, dagi_router_url, self_healing_status,
swapper_healthy, swapper_models_loaded, swapper_models_total
)
VALUES (
p_node_id,
p_metrics->>'node_name',
p_metrics->>'hostname',
(SELECT array_agg(elem) FROM jsonb_array_elements_text(p_metrics->'roles') AS elem),
p_metrics->>'environment',
p_metrics->>'status',
p_metrics->>'gpu',
NOW(),
p_metrics->>'cpu_model',
(p_metrics->>'cpu_cores')::integer,
(p_metrics->>'cpu_usage')::numeric,
p_metrics->>'gpu_model',
(p_metrics->>'gpu_vram_total')::integer,
(p_metrics->>'gpu_vram_used')::integer,
(p_metrics->>'ram_total')::integer,
(p_metrics->>'ram_used')::integer,
(p_metrics->>'disk_total')::integer,
(p_metrics->>'disk_used')::integer,
(p_metrics->>'agent_count_router')::integer,
(p_metrics->>'agent_count_system')::integer,
NOW(),
p_metrics->>'dagi_router_url',
p_metrics->>'self_healing_status',
(p_metrics->>'swapper_healthy')::boolean,
(p_metrics->>'swapper_models_loaded')::integer,
(p_metrics->>'swapper_models_total')::integer
)
ON CONFLICT (node_id) DO UPDATE SET
node_name = COALESCE(EXCLUDED.node_name, node_cache.node_name),
hostname = COALESCE(EXCLUDED.hostname, node_cache.hostname),
roles = COALESCE(EXCLUDED.roles, node_cache.roles),
environment = COALESCE(EXCLUDED.environment, node_cache.environment),
status = COALESCE(EXCLUDED.status, node_cache.status),
gpu = COALESCE(EXCLUDED.gpu, node_cache.gpu),
last_sync = NOW(),
cpu_model = COALESCE(EXCLUDED.cpu_model, node_cache.cpu_model),
cpu_cores = COALESCE(EXCLUDED.cpu_cores, node_cache.cpu_cores),
cpu_usage = COALESCE(EXCLUDED.cpu_usage, node_cache.cpu_usage),
gpu_model = COALESCE(EXCLUDED.gpu_model, node_cache.gpu_model),
gpu_vram_total = COALESCE(EXCLUDED.gpu_vram_total, node_cache.gpu_vram_total),
gpu_vram_used = COALESCE(EXCLUDED.gpu_vram_used, node_cache.gpu_vram_used),
ram_total = COALESCE(EXCLUDED.ram_total, node_cache.ram_total),
ram_used = COALESCE(EXCLUDED.ram_used, node_cache.ram_used),
disk_total = COALESCE(EXCLUDED.disk_total, node_cache.disk_total),
disk_used = COALESCE(EXCLUDED.disk_used, node_cache.disk_used),
agent_count_router = COALESCE(EXCLUDED.agent_count_router, node_cache.agent_count_router),
agent_count_system = COALESCE(EXCLUDED.agent_count_system, node_cache.agent_count_system),
last_heartbeat = NOW(),
dagi_router_url = COALESCE(EXCLUDED.dagi_router_url, node_cache.dagi_router_url),
self_healing_status = COALESCE(EXCLUDED.self_healing_status, node_cache.self_healing_status),
swapper_healthy = COALESCE(EXCLUDED.swapper_healthy, node_cache.swapper_healthy),
swapper_models_loaded = COALESCE(EXCLUDED.swapper_models_loaded, node_cache.swapper_models_loaded),
swapper_models_total = COALESCE(EXCLUDED.swapper_models_total, node_cache.swapper_models_total),
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;