feat: add 'Додати ноду' button to Node Directory, create /nodes/register page, add node discovery script
This commit is contained in:
@@ -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">
|
||||
|
||||
159
apps/web/src/app/nodes/register/page.tsx
Normal file
159
apps/web/src/app/nodes/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 показує тільки реальні метрики й реальних агентів, тестові не потрапляють у кабінети.
|
||||
|
||||
106
docs/tasks/TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1.md
Normal file
106
docs/tasks/TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1.md
Normal 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).
|
||||
|
||||
247
scripts/discover_node_state.py
Normal file
247
scripts/discover_node_state.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user