feat: implement Swapper metrics collection and UI
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
NodeMetricsCard
|
NodeMetricsCard
|
||||||
} from '@/components/node-dashboard';
|
} from '@/components/node-dashboard';
|
||||||
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
|
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
|
||||||
|
import { NodeSwapperCard } from '@/components/node-dashboard/NodeSwapperCard';
|
||||||
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
|
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
|
||||||
|
|
||||||
function getNodeLabel(nodeId: string): string {
|
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}
|
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Swapper Service */}
|
||||||
|
<NodeSwapperCard nodeId={nodeId} />
|
||||||
|
|
||||||
{/* MicroDAO Presence */}
|
{/* MicroDAO Presence */}
|
||||||
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
|
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
|
||||||
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Swapper Service (if production or dev with services) */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<NodeSwapperCard nodeId={nodeId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
|
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<NodeMetricsCard nodeId={nodeId} />
|
<NodeMetricsCard nodeId={nodeId} />
|
||||||
|
|||||||
110
apps/web/src/components/node-dashboard/NodeSwapperCard.tsx
Normal file
110
apps/web/src/components/node-dashboard/NodeSwapperCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
36
apps/web/src/hooks/useNodeSwapper.ts
Normal file
36
apps/web/src/hooks/useNodeSwapper.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
261
docs/tasks/TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1.md
Normal file
261
docs/tasks/TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1.md
Normal 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)
|
||||||
|
|
||||||
13
migrations/039_node_cache_swapper_metrics.sql
Normal file
13
migrations/039_node_cache_swapper_metrics.sql
Normal 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)';
|
||||||
|
|
||||||
@@ -181,10 +181,49 @@ class NodeGuardian:
|
|||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def collect_metrics(self) -> Dict[str, Any]:
|
async def collect_metrics(self) -> Dict[str, Any]:
|
||||||
"""Зібрати метрики ноди (базова реалізація)"""
|
"""Зібрати метрики ноди та Swapper"""
|
||||||
# TODO: Implement real metrics collection
|
metrics = {
|
||||||
# For now, return empty metrics
|
"cpu_usage": 0.0, # TODO: Implement real metrics
|
||||||
return {}
|
"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]:
|
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
39
services/city-service/repo_city.py_fragment.py
Normal file
39
services/city-service/repo_city.py_fragment.py
Normal 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"],
|
||||||
|
}
|
||||||
16
services/city-service/repo_city.py_fragment_debug.py
Normal file
16
services/city-service/repo_city.py_fragment_debug.py
Normal 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
|
||||||
@@ -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")
|
@router.get("/internal/node/{node_id}/directory-check")
|
||||||
async def check_node_in_directory(node_id: str):
|
async def check_node_in_directory(node_id: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user