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

View File

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

View File

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

View File

@@ -73,8 +73,7 @@
alter table node_cache alter table node_cache
add column swapper_healthy boolean, add column swapper_healthy boolean,
add column swapper_models_loaded integer, add column swapper_models_loaded integer,
add column swapper_models_total integer, add column swapper_models_total integer;
add column swapper_state jsonb; -- Для повного списку моделей
``` ```
Міграція: `migrations/039_node_cache_swapper_metrics.sql`. Міграція: `migrations/039_node_cache_swapper_metrics.sql`.
@@ -91,9 +90,8 @@ import requests
def collect_swapper_metrics(swapper_base_url: str) -> dict: def collect_swapper_metrics(swapper_base_url: str) -> dict:
result = { result = {
"swapper_healthy": False, "swapper_healthy": False,
"swapper_models_loaded": 0, "swapper_models_loaded": None,
"swapper_models_total": 0, "swapper_models_total": None,
"swapper_state": {}
} }
try: try:
# healthz # 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) loaded = sum(1 for m in models if m.get("loaded") is True)
result["swapper_models_total"] = total result["swapper_models_total"] = total
result["swapper_models_loaded"] = loaded result["swapper_models_loaded"] = loaded
result["swapper_state"] = data # Зберігаємо весь стан
except Exception: except Exception:
# залишаємо None → UI покаже "невідомо"
pass pass
return result return result
@@ -133,7 +131,6 @@ payload = {
"swapper_healthy": swapper_metrics["swapper_healthy"], "swapper_healthy": swapper_metrics["swapper_healthy"],
"swapper_models_loaded": swapper_metrics["swapper_models_loaded"], "swapper_models_loaded": swapper_metrics["swapper_models_loaded"],
"swapper_models_total": swapper_metrics["swapper_models_total"], "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) 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`. * оновлювати відповідні колонки в `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 ## 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`; * заголовок: `Swapper Service`;
* статус: * статус:
* `🟢 Healthy` / `🟡 Degraded` / `🔴 Down` (на основі `healthy` + `models_loaded`); * `🟢 Healthy` / `🟡 Degraded` / `🔴 Down` (на основі `healthy` + `models_loaded`);
* коротке резюме: * коротке резюме:
* `Моделі: 3/5 завантажено`; * `Моделі: 3/5 завантажено`;
* кнопка/кнопка-розкривалка `Переглянути моделі`: * кнопка/кнопка-розкривалка `Переглянути моделі`:
* список моделей (name, type, loaded). * список моделей (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 ### 5.3. Інтеграція в Node Cabinet
На сторінці `/nodes/[nodeId]`: На сторінці `/nodes/[nodeId]`:
* додати `NodeSwapperCard` поруч із `NodeMetricsCard` / `DAGIRouterCard` у секцію Service Agents. * додати `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 ## 6. Tests
@@ -214,14 +249,17 @@ export function useNodeSwapper(nodeId: string) {
* Тест міграції `node_cache` (наявність нових колонок). * Тест міграції `node_cache` (наявність нових колонок).
* Тест `POST /internal/node/{id}/metrics/update`: * Тест `POST /internal/node/{id}/metrics/update`:
* при передачі полів Swapper правильно оновлює `node_cache`. * при передачі полів Swapper правильно оновлює `node_cache`.
* Тест `GET /internal/node/{id}/swapper`: * Тест `GET /internal/node/{id}/swapper`:
* при коректній відповіді Swapper API повертає очікувану структуру; * при коректній відповіді Swapper API повертає очікувану структуру;
* якщо Swapper лежить — `healthy=false`, моделі порожні. * якщо Swapper лежить — `healthy=false`, моделі порожні.
### 6.2. Frontend ### 6.2. Frontend
* Snapshot-тест `NodeSwapperCard` для випадків: * Snapshot-тест `NodeSwapperCard` для випадків:
* healthy + моделі є; * healthy + моделі є;
* unhealthy + немає моделей; * unhealthy + немає моделей;
* loading/error state. * loading/error state.
@@ -232,17 +270,21 @@ export function useNodeSwapper(nodeId: string) {
1. node-guardian-loop регулярно збирає Swapper-метрики та відправляє їх у city-service. 1. node-guardian-loop регулярно збирає Swapper-метрики та відправляє їх у city-service.
2. `node_cache` містить свіжі поля: 2. `node_cache` містить свіжі поля:
* `swapper_healthy` * `swapper_healthy`
* `swapper_models_loaded` * `swapper_models_loaded`
* `swapper_models_total` * `swapper_models_total`
* `swapper_state` (JSONB)
3. `GET /internal/node/{id}/metrics/current` відображає Swapper-метрики для обох нод. 3. `GET /internal/node/{id}/metrics/current` відображає Swapper-метрики для обох нод.
4. `GET /internal/node/{id}/swapper` повертає детальну інформацію про Swapper (мінімум: healthy + models). 4. `GET /internal/node/{id}/swapper` повертає детальну інформацію про Swapper (мінімум: healthy + models).
5. У Кабінеті Ноди (`/nodes/[nodeId]`) є блок Swapper Service: 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` * `migrations/039_node_cache_swapper_metrics.sql`
* Оновлений `node-guardian-loop.py` (з Swapper-метриками) * Оновлений `node-guardian-loop.py` (з Swapper-метриками)
* Оновлені endpoints: * Оновлені endpoints:
* `POST /internal/node/{id}/metrics/update` * `POST /internal/node/{id}/metrics/update`
* `GET /internal/node/{id}/swapper` * `GET /internal/node/{id}/swapper`
* Frontend: * Frontend:
* `useNodeSwapper(nodeId)` * `useNodeSwapper(nodeId)`
* `NodeSwapperCard` * `NodeSwapperCard`
* Інтеграція в Node Cabinet * Інтеграція в Node Cabinet
* Тести (backend + frontend) * Тести (backend + frontend)

View File

@@ -1,13 +1,71 @@
-- Migration 039: Add Swapper metrics to node_cache -- Function to handle atomic node heartbeat and metrics update
-- Додаємо поля для метрик Swapper Service в node_cache -- Updated to include Swapper metrics
CREATE OR REPLACE FUNCTION fn_node_heartbeat(
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_healthy boolean DEFAULT false; p_node_id text,
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_loaded integer DEFAULT 0; p_metrics jsonb
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_total integer DEFAULT 0; ) RETURNS void AS $$
ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_state jsonb DEFAULT '{}'::jsonb; BEGIN
INSERT INTO node_cache (
COMMENT ON COLUMN node_cache.swapper_healthy IS 'Статус здоров`я Swapper Service'; node_id, node_name, hostname, roles, environment, status, gpu, last_sync,
COMMENT ON COLUMN node_cache.swapper_models_loaded IS 'Кількість завантажених моделей'; cpu_model, cpu_cores, cpu_usage, gpu_model, gpu_vram_total, gpu_vram_used,
COMMENT ON COLUMN node_cache.swapper_models_total IS 'Загальна кількість відомих моделей'; ram_total, ram_used, disk_total, disk_used, agent_count_router, agent_count_system,
COMMENT ON COLUMN node_cache.swapper_state IS 'Повний стан моделей Swapper (JSON)'; 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;