diff --git a/apps/web/src/app/nodes/page.tsx b/apps/web/src/app/nodes/page.tsx index 98008d66..53b7863d 100644 --- a/apps/web/src/app/nodes/page.tsx +++ b/apps/web/src/app/nodes/page.tsx @@ -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 (
{/* Header */}
-
- -

- Node Directory -

+
+
+ +

+ Node Directory +

+
+ {canAddNode && ( + + )}

Всі ноди мережі DAARION @@ -237,9 +260,18 @@ export default function NodesPage() {

Ноди не знайдені

-

- Наразі немає зареєстрованих нод. +

+ Наразі немає жодної зареєстрованої ноди.

+ {canAddNode && ( + + )}
) : (
diff --git a/apps/web/src/app/nodes/register/page.tsx b/apps/web/src/app/nodes/register/page.tsx new file mode 100644 index 00000000..74542577 --- /dev/null +++ b/apps/web/src/app/nodes/register/page.tsx @@ -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(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 ( +
+
+ {/* Back button */} + + + {/* Header */} +
+
+
+ +
+
+

+ Додати ноду +

+

+ Підключіть свій сервер або комп'ютер до мережі DAARION +

+
+
+
+ + {/* Info Card */} +
+

+ + Як це працює +

+

+ Щоб нода з'явилась у каталозі, потрібно встановити Node Guardian на + вашому сервері або ноутбуці. Скрипт буде автоматично надсилати heartbeat до DAARION.city, + і ваша нода з'явиться в каталозі. +

+
+ + {/* Steps */} +
+ {steps.map((step, index) => ( +
+
+
+ {index + 1} +
+
+

{step.title}

+

{step.description}

+
+
+                      {step.code}
+                    
+ +
+
+
+
+ ))} +
+ + {/* Requirements */} +
+

Вимоги

+
    +
  • + + Python 3.9+ з встановленим httpx +
  • +
  • + + Доступ до інтернету для надсилання heartbeat +
  • +
  • + + (Опціонально) Swapper service для AI моделей +
  • +
+
+ + {/* Links */} + +
+
+ ); +} + diff --git a/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_ISOLATION_AND_AGENT_DISCOVERY_v1.md b/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_ISOLATION_AND_AGENT_DISCOVERY_v1.md new file mode 100644 index 00000000..c2663bf7 --- /dev/null +++ b/docs/tasks/TASK_PHASE_NODE2_ROUTER_SWAPPER_ISOLATION_AND_AGENT_DISCOVERY_v1.md @@ -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 показує тільки реальні метрики й реальних агентів, тестові не потрапляють у кабінети. + diff --git a/docs/tasks/TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1.md b/docs/tasks/TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1.md new file mode 100644 index 00000000..c8df6871 --- /dev/null +++ b/docs/tasks/TASK_PHASE_NODE_DIRECTORY_ADD_NODE_CTA_v1.md @@ -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(); + + +``` + +- Обгорнути рендер у перевірку ролі користувача. + +### 2.2. Сторінка `/nodes/register` + +Файл: `apps/web/src/app/nodes/register/page.tsx`. + +Мінімум: + +```tsx +export default function NodeRegisterPage() { + return ( +
+

Додати ноду

+

+ Щоб нода зʼявилась у каталозі, встановіть Node Agent на сервері / ноутбуці + і переконайтесь, що heartbeat надсилається до DAARION.city. +

+ + Інструкція з встановлення ноди + +
+ ); +} +``` + +(Якщо є реальний шлях до docs — використати його.) + +--- + +## 3. Acceptance Criteria + +1. На `/nodes`: + - У header видно кнопку «Додати ноду». + - При кліку відкривається `/nodes/register`. +2. У випадку 0 нод: + - Empty state містить кнопку «Додати першу ноду». +3. Кнопка **не** показується гостям та користувачам без прав (тільки admin/orchestrator). +4. Після деплою кнопка не зникає при наступних оновленнях (перевірити в `check-deploy-post.py`, за бажанням, що `/nodes` повертає 200). + diff --git a/scripts/discover_node_state.py b/scripts/discover_node_state.py new file mode 100644 index 00000000..5cd76f93 --- /dev/null +++ b/scripts/discover_node_state.py @@ -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 + +
+Swapper JSON + +```json +{json.dumps(swapper, indent=2, default=str)} +``` + +
+ +
+DAGI Router JSON + +```json +{json.dumps(router, indent=2, default=str)} +``` + +
+""" + + 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 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() +