feat: add 'Додати ноду' button to Node Directory, create /nodes/register page, add node discovery script

This commit is contained in:
Apple
2025-12-01 06:47:27 -08:00
parent d5aae67b50
commit f5c58358a0
5 changed files with 744 additions and 8 deletions

View File

@@ -1,8 +1,10 @@
'use client';
import Link from 'next/link';
import { Server, Cpu, Users, Activity, ExternalLink, Zap, HardDrive, MemoryStick } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Server, Cpu, Users, Activity, ExternalLink, Zap, HardDrive, MemoryStick, Plus } from 'lucide-react';
import { useNodeList } from '@/hooks/useNodes';
import { useAuth } from '@/context/AuthContext';
import { NodeProfile } from '@/lib/types/nodes';
function getNodeLabel(nodeId: string): string {
@@ -170,18 +172,39 @@ function NodeCard({ node }: { node: NodeProfile }) {
}
export default function NodesPage() {
const router = useRouter();
const { user } = useAuth();
const { nodes, total, isLoading, error } = useNodeList();
// Check if user can add nodes (admin or orchestrator)
const canAddNode = user && (
user.roles?.includes('admin') ||
user.roles?.includes('orchestrator') ||
user.roles?.includes('is_admin') ||
user.roles?.includes('is_orchestrator')
);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-900 to-slate-950">
<div className="max-w-7xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Server className="w-8 h-8 text-purple-400" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Node Directory
</h1>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Server className="w-8 h-8 text-purple-400" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Node Directory
</h1>
</div>
{canAddNode && (
<button
onClick={() => router.push('/nodes/register')}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 rounded-xl text-white font-medium transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
>
<Plus className="w-5 h-5" />
Додати ноду
</button>
)}
</div>
<p className="text-white/60 text-lg">
Всі ноди мережі DAARION
@@ -237,9 +260,18 @@ export default function NodesPage() {
<h2 className="text-xl font-semibold text-white mb-2">
Ноди не знайдені
</h2>
<p className="text-white/50">
Наразі немає зареєстрованих нод.
<p className="text-white/50 mb-6">
Наразі немає жодної зареєстрованої ноди.
</p>
{canAddNode && (
<button
onClick={() => router.push('/nodes/register')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 rounded-xl text-white font-medium transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
>
<Plus className="w-5 h-5" />
Додати першу ноду
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">

View File

@@ -0,0 +1,159 @@
'use client';
import { useRouter } from 'next/navigation';
import { Server, ChevronLeft, Terminal, CheckCircle2, Copy, ExternalLink } from 'lucide-react';
import { useState } from 'react';
export default function NodeRegisterPage() {
const router = useRouter();
const [copiedStep, setCopiedStep] = useState<number | null>(null);
const copyToClipboard = (text: string, step: number) => {
navigator.clipboard.writeText(text);
setCopiedStep(step);
setTimeout(() => setCopiedStep(null), 2000);
};
const steps = [
{
title: 'Клонуйте репозиторій',
code: 'git clone https://github.com/IvanTytar/microdao-daarion.git',
description: 'Завантажте код проєкту на вашу машину'
},
{
title: 'Налаштуйте змінні оточення',
code: `export NODE_ID="your-node-id"
export CITY_API_URL="https://daarion.space/api"
export SWAPPER_URL="http://localhost:8890"`,
description: 'Встановіть унікальний ID для вашої ноди'
},
{
title: 'Запустіть Node Guardian',
code: 'python3 scripts/node-guardian-loop.py --node-id=$NODE_ID --city-url=$CITY_API_URL',
description: 'Скрипт почне надсилати heartbeat до DAARION.city'
}
];
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-900 to-slate-950">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Back button */}
<button
onClick={() => router.push('/nodes')}
className="flex items-center gap-2 text-white/60 hover:text-white mb-8 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
Назад до Node Directory
</button>
{/* Header */}
<div className="mb-12">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center">
<Server className="w-8 h-8 text-purple-400" />
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Додати ноду
</h1>
<p className="text-white/60 mt-1">
Підключіть свій сервер або комп'ютер до мережі DAARION
</p>
</div>
</div>
</div>
{/* Info Card */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Terminal className="w-5 h-5 text-cyan-400" />
Як це працює
</h2>
<p className="text-white/70 leading-relaxed">
Щоб нода з'явилась у каталозі, потрібно встановити <span className="text-cyan-400 font-medium">Node Guardian</span> на
вашому сервері або ноутбуці. Скрипт буде автоматично надсилати heartbeat до DAARION.city,
і ваша нода з'явиться в каталозі.
</p>
</div>
{/* Steps */}
<div className="space-y-6 mb-12">
{steps.map((step, index) => (
<div
key={index}
className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6"
>
<div className="flex items-start gap-4">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center shrink-0">
<span className="text-purple-400 font-semibold">{index + 1}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold mb-1">{step.title}</h3>
<p className="text-white/50 text-sm mb-3">{step.description}</p>
<div className="relative">
<pre className="bg-slate-800/50 rounded-xl p-4 text-sm text-cyan-300 font-mono overflow-x-auto">
{step.code}
</pre>
<button
onClick={() => copyToClipboard(step.code, index)}
className="absolute top-2 right-2 p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors"
title="Копіювати"
>
{copiedStep === index ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
) : (
<Copy className="w-4 h-4 text-white/60" />
)}
</button>
</div>
</div>
</div>
</div>
))}
</div>
{/* Requirements */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-6 mb-8">
<h3 className="text-amber-400 font-semibold mb-3">Вимоги</h3>
<ul className="space-y-2 text-white/70">
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-amber-400" />
Python 3.9+ з встановленим httpx
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-amber-400" />
Доступ до інтернету для надсилання heartbeat
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-amber-400" />
(Опціонально) Swapper service для AI моделей
</li>
</ul>
</div>
{/* Links */}
<div className="flex flex-wrap gap-4">
<a
href="https://github.com/IvanTytar/microdao-daarion/blob/main/docs/NODE_SETUP.md"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 rounded-lg text-purple-400 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Повна документація
</a>
<a
href="https://github.com/IvanTytar/microdao-daarion/tree/main/scripts"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-lg text-cyan-400 transition-colors"
>
<Terminal className="w-4 h-4" />
Скрипти на GitHub
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
# TASK_PHASE_NODE2_ROUTER_SWAPPER_ISOLATION_AND_AGENT_DISCOVERY_v1
Мета:
1) гарантувати, що НОДА2 використовує свій локальний Swapper та DAGI Router для метрик;
2) зібрати повний перелік моделей і агентів на обох нодах, розділити «бойових» і тестових;
3) зробити так, щоб у MVP показувались тільки реальні метрики та реальні агенти.
---
## 0. Симптоми / проблеми
- Swapper на NODE2 показує ті ж моделі, що на NODE1 (дані «змішані»).
- На NODE2 є реальні моделі (більші, ніж на NODE1) і створені агенти core-команди, але:
- частина з них періодично зникає з UI;
- зʼявляються старі тестові агенти.
- Очевидно, що:
- або `node-guardian-loop` на NODE2 стукає в Swapper/Router NODE1;
- або `fn_node_heartbeat` / `node_cache` перезаписують дані між нодами;
- або маршрути `/api/node-internal/{nodeId}/...` не ізолюють nodeId.
---
## 1. Інфра: повна ізоляція NODE2
### 1.1. Перевірити ENV та конфіг
На NODE2 знайти, чим стартує `node-guardian-loop.py` і DAGI Router / Swapper:
- Перевірити змінні оточення:
```bash
echo $NODE_ID
echo $SWAPPER_BASE_URL
echo $DAGI_ROUTER_URL
echo $CITY_API_BASE_URL
```
- Встановити для NODE2:
```env
NODE_ID=node-2-macbook-m4max
SWAPPER_BASE_URL=http://127.0.0.1:8890 # локальний Swapper NODE2
DAGI_ROUTER_URL=http://127.0.0.1:9102 # локальний DAGI Router NODE2
CITY_API_BASE_URL=https://daarion.space/api/node-internal
```
### 1.2. Оновити node-guardian-loop.py
Переконатись, що скрипт використовує **тільки** ENV, а не захардкожені URL:
```python
NODE_ID = os.environ["NODE_ID"]
SWAPPER_BASE_URL = os.environ["SWAPPER_BASE_URL"]
DAGI_ROUTER_URL = os.environ.get("DAGI_ROUTER_URL")
CITY_API_BASE_URL = os.environ["CITY_API_BASE_URL"]
```
- Для Swapper: використовувати `SWAPPER_BASE_URL` (не `http://swapper-service:8890` в коді).
- Для Router health (якщо є): використовувати `DAGI_ROUTER_URL`.
### 1.3. fn_node_heartbeat / node_cache
Перевірити SQL-функцію `fn_node_heartbeat`:
- Переконатися, що `update node_cache set ... where node_id = _node_id` (або аналог) і **не** перезаписує рядок іншої ноди.
- Додати короткий тест або скрипт, який:
- викликає `fn_node_heartbeat('node-1-...', ...)`;
- викликає `fn_node_heartbeat('node-2-...', ...)`;
- показує, що в `node_cache` два різні рядки з різними `swapper_state`.
---
## 2. Discovery: повний список моделей і агентів по нодах
### 2.1. Скрипт `scripts/discover_node_state.py`
Створити Python-скрипт, який:
- Приймає параметр `--node node-1-...` або `--node node-2-...` або `--all`.
- Для кожної ноди:
1. Читає `node_cache`:
```sql
select * from node_cache where node_id = :node_id;
```
2. Викликає internal API:
- `/internal/node/{node_id}/swapper` → реальні моделі.
- `/internal/node/{node_id}/dagi-router/agents` → агенти Router vs DB.
3. Читає таблицю `agents`:
```sql
select id, public_slug, kind, is_test, node_id, created_at, updated_at
from agents
where node_id = :node_id
order by created_at;
```
- Результат записує у:
- `docs/users/nodes/NODE_STATE_{node_id}.md`
Формат:
```md
# Node {node_id} — State
## Swapper
- healthy: true
- models_loaded: X / Y
| name | type | loaded | source |
|------|------|--------|--------|
| qwen2.5-7b-instruct | llm | true | swapper |
## DAGI Router Agents
| id | status | has_db_record | is_test | last_seen_at |
|----|--------|---------------|---------|--------------|
| daarwizz | active | true | false | ... |
## DB Agents (by node_id)
| id | kind | is_test | public_slug |
|----|------|---------|-------------|
| ... |
```
Це стає «правдою» по реальному стану нод.
---
## 3. Прибирання тестових агентів і відфільтровування метрик
### 3.1. Маркування тестових агентів
- У таблиці `agents` вже є або додати поле `is_test boolean default false`.
- Міграція: позначити всі старі тестові агенти:
- за pattern'ами в `id` / `name` (`test_`, `demo_`, `sandbox_` і т.д.);
- або за списком, який уже є в документах (знайти в `docs/`).
### 3.2. Фільтрація на рівні API
- Для всіх endpointів, які використовуються в MVP (Node Cabinet, Agent Directory, DAGI Router Card):
- повертати **тільки** `is_test = false` за замовчуванням;
- додати параметр `include_test=true` тільки для внутрішніх / debug API.
Наприклад, у `get_dagi_router_agents_for_node`:
- при формуванні результату **не включати** `is_test=true` у таблицю для MVP.
### 3.3. Очищення «зомбі»-агентів
- Окрема міграція/скрипт, який:
- або архівує тестових агентів (`archived_at`),
- або видаляє їх,
- або переносить у окрему microDAO «Sandbox».
Головне — щоб вони не потрапляли в `router_total`/`system_total` метрик MVP.
---
## 4. Перевірка після ізоляції
Після оновлення:
1. Запустити `scripts/discover_node_state.py --all` і переглянути два файли:
- `NODE_STATE_node-1-hetzner-gex44.md`
- `NODE_STATE_node-2-macbook-m4max.md`
2. Переконатися, що:
- Swapper-моделі для NODE1 і NODE2 **різні**, відповідають реальним встановленим моделям на кожній ноді.
- Список агентів DAGI Router для NODE2 показує саме тих core-агентів, що були створені для NODE2.
3. UI:
- `/nodes/node-1-hetzner-gex44` → Swapper + DAGI Router + агенти відповідають файлу NODE_STATE_NODE1.
- `/nodes/node-2-macbook-m4max` → Swapper + DAGI Router + агенти відповідають файлу NODE_STATE_NODE2.
- Тестові агенти в UI не відображаються (тільки бойові).
---
## 5. Результат
- НОДА1 і НОДА2 повністю ізольовані з точки зору Swapper/DAGI Router.
- У нас є документована «інвентаризація» моделей і агентів по нодах.
- MVP показує тільки реальні метрики й реальних агентів, тестові не потрапляють у кабінети.

View File

@@ -0,0 +1,106 @@
# TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1
Мета: повернути та зафіксувати кнопку «Додати ноду» у Node Directory, щоб можна було запускати процес реєстрації нової ноди з інтерфейсу.
---
## 0. Поточний стан
- Сторінка `/nodes` (Node Directory) відображає:
- Production / Development фільтри.
- Список нод або empty state «Наразі немає зареєстрованих нод».
- Раніше була кнопка «Додати ноду», але після рефакторингів зникла.
---
## 1. UX / UI вимоги
### 1.1. Розміщення
- Сторінка `/nodes`:
- У верхній частині (праворуч від заголовка «Node Directory» або під фільтрами) додати кнопку:
- Текст: **«Додати ноду»**
- Іконка: server / plus (та сама стилістика, що й у кнопки «Створити MicroDAO»).
### 1.2. Поведінка
- Натискання → `router.push("/nodes/register")`.
- Якщо `/nodes/register` ще не існує:
- Створити просту заглушку-сторінку з текстом:
- «Реєстрація нової ноди DAARION. На цьому етапі нода реєструється через встановлення Node Agent і heartbeat. Перегляньте інструкцію в docs/NODE_SETUP.md».
- Кнопка показується тільки для авторизованих користувачів із ролями:
- `is_admin` або `is_orchestrator` (перевірити існуючу систему ролей і використати наявний хук, наприклад `useCurrentUser()`).
### 1.3. Empty state
- Якщо `nodes.length === 0`:
- Замість просто «Ноди не знайдені» показати:
- Текст: «Наразі немає жодної зареєстрованої ноди»;
- Під текстом — кнопку **«Додати першу ноду»** з таким самим handler'ом (`/nodes/register`).
---
## 2. Технічна реалізація
### 2.1. Node Directory page
Файл (ймовірно): `apps/web/src/app/nodes/page.tsx`.
Зробити:
- Імпортувати кнопку (Button component) та `useRouter`.
- Додати кнопку в header:
```tsx
const router = useRouter();
<Button
variant="primary"
onClick={() => router.push("/nodes/register")}
>
Додати ноду
</Button>
```
- Обгорнути рендер у перевірку ролі користувача.
### 2.2. Сторінка `/nodes/register`
Файл: `apps/web/src/app/nodes/register/page.tsx`.
Мінімум:
```tsx
export default function NodeRegisterPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Додати ноду</h1>
<p className="text-sm text-muted-foreground">
Щоб нода зʼявилась у каталозі, встановіть Node Agent на сервері / ноутбуці
і переконайтесь, що heartbeat надсилається до DAARION.city.
</p>
<a
href="https://daarion.space/docs/NODE_SETUP"
className="text-primary underline"
>
Інструкція з встановлення ноди
</a>
</div>
);
}
```
(Якщо є реальний шлях до docs — використати його.)
---
## 3. Acceptance Criteria
1. На `/nodes`:
- У header видно кнопку «Додати ноду».
- При кліку відкривається `/nodes/register`.
2. У випадку 0 нод:
- Empty state містить кнопку «Додати першу ноду».
3. Кнопка **не** показується гостям та користувачам без прав (тільки admin/orchestrator).
4. Після деплою кнопка не зникає при наступних оновленнях (перевірити в `check-deploy-post.py`, за бажанням, що `/nodes` повертає 200).

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
DAARION Node State Discovery Script
This script collects and documents the current state of nodes in the DAARION network,
including Swapper models, DAGI Router agents, and database agents.
Usage:
python scripts/discover_node_state.py --node node-1-hetzner-gex44
python scripts/discover_node_state.py --node node-2-macbook-m4max
python scripts/discover_node_state.py --all
Output:
Creates markdown files in docs/users/nodes/NODE_STATE_{node_id}.md
"""
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
try:
import httpx
except ImportError:
print("❌ httpx not installed. Run: pip install httpx")
sys.exit(1)
# Configuration
CITY_API_URL = os.environ.get("CITY_API_URL", "https://daarion.space/api")
DB_CONNECTION = os.environ.get("DATABASE_URL", None)
KNOWN_NODES = [
"node-1-hetzner-gex44",
"node-2-macbook-m4max",
]
OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "users" / "nodes"
def fetch_swapper_state(node_id: str) -> dict:
"""Fetch Swapper state from city-service API."""
url = f"{CITY_API_URL}/node-internal/{node_id}/swapper"
try:
with httpx.Client(timeout=10.0) as client:
resp = client.get(url)
if resp.status_code == 200:
return resp.json()
else:
return {"error": f"HTTP {resp.status_code}", "healthy": False, "models": []}
except Exception as e:
return {"error": str(e), "healthy": False, "models": []}
def fetch_dagi_router_agents(node_id: str) -> dict:
"""Fetch DAGI Router agents from city-service API."""
url = f"{CITY_API_URL}/node-internal/{node_id}/dagi-router/agents"
try:
with httpx.Client(timeout=10.0) as client:
resp = client.get(url)
if resp.status_code == 200:
return resp.json()
else:
return {"error": f"HTTP {resp.status_code}", "agents": []}
except Exception as e:
return {"error": str(e), "agents": []}
def fetch_node_info(node_id: str) -> dict:
"""Fetch node info from city-service API."""
url = f"{CITY_API_URL}/nodes/{node_id}"
try:
with httpx.Client(timeout=10.0) as client:
resp = client.get(url)
if resp.status_code == 200:
return resp.json()
else:
return {"error": f"HTTP {resp.status_code}"}
except Exception as e:
return {"error": str(e)}
def generate_markdown(node_id: str, swapper: dict, router: dict, node_info: dict) -> str:
"""Generate markdown report for a node."""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
md = f"""# Node {node_id} — State
> Generated: {now}
## Node Info
| Field | Value |
|-------|-------|
| Node ID | `{node_id}` |
| Name | {node_info.get('name', 'N/A')} |
| Status | {node_info.get('status', 'N/A')} |
| Environment | {node_info.get('environment', 'N/A')} |
| Hostname | {node_info.get('hostname', 'N/A')} |
## Swapper
"""
if swapper.get("error"):
md += f"**Error:** {swapper['error']}\n\n"
else:
healthy = "✅ Healthy" if swapper.get("healthy") else "❌ Unhealthy"
models_loaded = swapper.get("models_loaded", 0)
models_total = swapper.get("models_total", 0)
md += f"""- **Status:** {healthy}
- **Models Loaded:** {models_loaded} / {models_total}
### Models
| Name | Type | Loaded | VRAM (GB) |
|------|------|--------|-----------|
"""
models = swapper.get("models", [])
if models:
for m in models:
loaded = "" if m.get("loaded") else ""
vram = m.get("vram_gb", "N/A")
md += f"| {m.get('name', 'N/A')} | {m.get('type', 'N/A')} | {loaded} | {vram} |\n"
else:
md += "| (no models) | | | |\n"
md += "\n## DAGI Router Agents\n\n"
if router.get("error"):
md += f"**Error:** {router['error']}\n\n"
else:
total = router.get("total", 0)
active = router.get("active", 0)
phantom = router.get("phantom", 0)
stale = router.get("stale", 0)
md += f"""- **Total Agents:** {total}
- **Active:** {active}
- **Phantom:** {phantom}
- **Stale:** {stale}
### Agent List
| ID | Name | Kind | Status | Runtime | Has DB Record |
|----|------|------|--------|---------|---------------|
"""
agents = router.get("agents", [])
if agents:
for a in agents:
md += f"| {a.get('id', 'N/A')} | {a.get('name', 'N/A')} | {a.get('kind', 'N/A')} | {a.get('status', 'N/A')} | {a.get('runtime', 'N/A')} | {a.get('has_db_record', 'N/A')} |\n"
else:
md += "| (no agents) | | | | | |\n"
md += f"""
---
## Raw Data
<details>
<summary>Swapper JSON</summary>
```json
{json.dumps(swapper, indent=2, default=str)}
```
</details>
<details>
<summary>DAGI Router JSON</summary>
```json
{json.dumps(router, indent=2, default=str)}
```
</details>
"""
return md
def discover_node(node_id: str, output_dir: Path) -> None:
"""Discover and document state for a single node."""
print(f"\n🔍 Discovering node: {node_id}")
# Fetch data
print(f" 📡 Fetching Swapper state...")
swapper = fetch_swapper_state(node_id)
print(f" 📡 Fetching DAGI Router agents...")
router = fetch_dagi_router_agents(node_id)
print(f" 📡 Fetching node info...")
node_info = fetch_node_info(node_id)
# Generate markdown
md = generate_markdown(node_id, swapper, router, node_info)
# Write to file
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / f"NODE_STATE_{node_id}.md"
output_file.write_text(md)
print(f" ✅ Saved to: {output_file}")
# Print summary
print(f"\n 📊 Summary:")
print(f" Swapper: {'healthy' if swapper.get('healthy') else 'unhealthy'}, {swapper.get('models_total', 0)} models")
print(f" Agents: {router.get('total', 0)} total, {router.get('active', 0)} active")
def main():
parser = argparse.ArgumentParser(description="Discover DAARION node state")
parser.add_argument("--node", type=str, help="Specific node ID to discover")
parser.add_argument("--all", action="store_true", help="Discover all known nodes")
parser.add_argument("--output", type=str, default=str(OUTPUT_DIR), help="Output directory")
parser.add_argument("--api-url", type=str, default=CITY_API_URL, help="City API URL")
args = parser.parse_args()
if not args.node and not args.all:
print("❌ Please specify --node <node_id> or --all")
parser.print_help()
sys.exit(1)
global CITY_API_URL
CITY_API_URL = args.api_url
output_dir = Path(args.output)
print(f"🚀 DAARION Node State Discovery")
print(f" API URL: {CITY_API_URL}")
print(f" Output: {output_dir}")
nodes_to_discover = KNOWN_NODES if args.all else [args.node]
for node_id in nodes_to_discover:
discover_node(node_id, output_dir)
print(f"\n✨ Discovery complete!")
if __name__ == "__main__":
main()