fix(nodes): Normalize Router/Swapper endpoints and fix NODE2 display

Major changes:
- Normalize get_node_endpoints to use ENV vars (ROUTER_BASE_URL, SWAPPER_BASE_URL)
- Remove node_id-based URL selection logic
- Add fallback direct API call in get_node_swapper_detail
- Fix Swapper API endpoint (/models instead of /api/v1/models)
- Add router_healthy and router_version to node_heartbeat fallback
- Add ENV vars to docker-compose for Router/Swapper URLs

Documentation:
- Add TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX.md with full task description
- Add NODE2_GUARDIAN_SETUP.md with setup instructions

This fixes:
- Swapper models not showing for NODE1 and NODE2
- DAGI Router agents not showing for NODE2
- Router/Swapper showing as Down/Degraded when they're actually up
This commit is contained in:
Apple
2025-12-02 03:13:01 -08:00
parent 5f07a6b3ae
commit f95810e8a7
5 changed files with 509 additions and 45 deletions

View File

@@ -25,6 +25,9 @@ services:
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
- ASSETS_BUCKET=${ASSETS_BUCKET:-daarion-assets}
- ASSETS_PUBLIC_BASE_URL=${ASSETS_PUBLIC_BASE_URL:-https://assets.daarion.space/daarion-assets}
# DAGI Router & Swapper configuration (from .env)
- ROUTER_BASE_URL=${ROUTER_BASE_URL:-http://dagi-router:9102}
- SWAPPER_BASE_URL=${SWAPPER_BASE_URL:-http://swapper-service:8890}
depends_on:
- dagi-postgres
- dagi-nats

View File

@@ -0,0 +1,222 @@
# Налаштування Node Guardian для НОДА2
## Контекст
Node Guardian — це сервіс, який періодично збирає метрики з DAGI Router та Swapper Service та оновлює їх в `node_cache` таблиці БД. Це дозволяє UI показувати актуальний стан нод.
## Налаштування для НОДА2 (MacBook)
### 1. Environment Variables
Створіть `.env` файл або додайте до існуючого:
```bash
# Node Identity
NODE_ID=node-2-macbook-m4max
NODE_NAME=НОДА2
NODE_ENVIRONMENT=development
NODE_ROLES=gpu,ai_runtime
NODE_HOSTNAME=$(hostname)
# City Service URL (HTTPS для проді)
CITY_SERVICE_URL=https://daarion.space/api/city
# Node-specific service URLs (для НОДА2 - localhost)
NODE_SWAPPER_URL=http://localhost:8890
NODE_ROUTER_URL=http://localhost:9102
# Guardian interval (секунди)
GUARDIAN_INTERVAL=60
```
### 2. Запуск Node Guardian
#### Як фонове завдання (рекомендовано):
```bash
# Створити systemd service
sudo nano /etc/systemd/system/node-guardian.service
```
Вміст файлу:
```ini
[Unit]
Description=DAARION Node Guardian
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=/path/to/microdao-daarion
EnvironmentFile=/path/to/.env
ExecStart=/usr/bin/python3 /path/to/microdao-daarion/scripts/node-guardian-loop.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Активувати:
```bash
sudo systemctl daemon-reload
sudo systemctl enable node-guardian
sudo systemctl start node-guardian
sudo systemctl status node-guardian
```
#### Або вручну:
```bash
cd /path/to/microdao-daarion
python3 scripts/node-guardian-loop.py
```
### 3. Перевірка роботи
#### Перевірити логи:
```bash
# Якщо systemd service
sudo journalctl -u node-guardian -f
# Або якщо запущено вручну - дивитись stdout
```
#### Перевірити в БД:
```sql
-- Перевірити чи оновлюються метрики для НОДА2
SELECT
node_id,
swapper_healthy,
swapper_models_loaded,
swapper_models_total,
router_healthy,
router_version,
last_heartbeat,
updated_at
FROM node_cache
WHERE node_id = 'node-2-macbook-m4max'
ORDER BY updated_at DESC
LIMIT 1;
```
#### Перевірити Swapper State:
```sql
SELECT
node_id,
swapper_state->'models' as models
FROM node_cache
WHERE node_id = 'node-2-macbook-m4max'
AND swapper_state IS NOT NULL;
```
### 4. Troubleshooting
#### Guardian не оновлює метрики
1. Перевірити з'єднання з city-service:
```bash
curl -v https://daarion.space/api/city/health
```
2. Перевірити чи Swapper доступний:
```bash
curl http://localhost:8890/health
curl http://localhost:8890/models
```
3. Перевірити чи Router доступний:
```bash
curl http://localhost:9102/health
```
4. Перевірити логи guardian на помилки:
```bash
sudo journalctl -u node-guardian --since "10 minutes ago" | grep -i error
```
#### Помилки авторизації (401/403)
- Перевірити що `CITY_SERVICE_URL` правильний
- Перевірити що немає старого токена в конфігурації
- Перевірити що city-service доступний з MacBook
#### Swapper показує 0 моделей
1. Перевірити чи Swapper реально має моделі:
```bash
curl http://localhost:8890/models | jq
```
2. Перевірити чи guardian правильно парсить відповідь:
- Дивитись логи guardian на повідомлення про Swapper metrics
3. Перевірити чи `swapper_state` зберігається в БД:
```sql
SELECT swapper_state FROM node_cache WHERE node_id = 'node-2-macbook-m4max';
```
---
## Налаштування для НОДА1 (Production Server)
На НОДА1 guardian зазвичай запускається автоматично через docker-compose або systemd.
### ENV змінні для НОДА1:
```bash
NODE_ID=node-1-hetzner-gex44
NODE_NAME=НОДА1
NODE_ENVIRONMENT=production
NODE_SWAPPER_URL=http://swapper-service:8890 # Docker service name
NODE_ROUTER_URL=http://dagi-router:9102 # Docker service name
CITY_SERVICE_URL=https://daarion.space/api/city
```
---
## Архітектура
```
┌─────────────────┐
│ Node Guardian │ (на кожній ноді)
│ (loop script) │
└────────┬────────┘
├─→ Swapper API (/health, /models)
├─→ Router API (/health)
┌─────────────────┐
│ City Service │
│ /heartbeat │
└────────┬────────┘
┌─────────────────┐
│ PostgreSQL │
│ node_cache │
└─────────────────┘
┌─────────────────┐
│ UI (Frontend) │
│ Node Cabinet │
└─────────────────┘
```
---
## Важливі моменти
1. **Один Router/Swapper для всіх нод у проді**: На НОДА1 є один DAGI Router та один Swapper Service, які обслуговують всі ноди. Guardian на кожній ноді просто збирає метрики та пушить їх в БД з правильним `node_id`.
2. **ENV змінні мають пріоритет**: Guardian використовує `NODE_SWAPPER_URL` та `NODE_ROUTER_URL` з ENV, якщо вони встановлені. Це дозволяє для НОДА2 використовувати `localhost`, а для НОДА1 - Docker service names.
3. **Heartbeat оновлює node_cache**: Кожен heartbeat оновлює метрики в `node_cache` для конкретного `node_id`, тому дані не перетираються між нодами.

View File

@@ -0,0 +1,183 @@
# TASK_PHASE_NODE2_ROUTER_SWAPPER_FIX — Router / Swapper / Node Guardian
## Контекст
- У DAARION.city є дві ноди:
- NODE1 — основний сервер (docker-compose, daji-router, swapper-service тощо).
- NODE2 — MacBook Pro M4 Max (`node-2-macbook-m4max`, IP: `192.168.1.33`).
- UI `https://daarion.space/nodes/node/...` показує:
- Для NODE1:
- Swapper Service: `Degraded`, моделей 0/0, `No models found`.
- DAGI Router: `Up`, 9 агентів.
- Для NODE2:
- Swapper Service: `Degraded`, моделей 0/0, `No models found`.
- DAGI Router: `Down`, `Router недоступний`.
- Cursor раніше змінював `get_node_endpoints` так, що:
- Для NODE1 використовувалися docker URLs (`http://dagi-router:9102`, `http://swapper-service:8890`).
- Для NODE2 — `http://localhost:9102`, `http://localhost:8890`, з визначенням по `node_id`.
- Це працює локально на Mac, але в прод-оточенні `city-service` крутиться в docker на NODE1, і `localhost` для нього — це контейнер, а не MacBook або DAGI Router.
- Стани в UI беруться не напряму з Router/Swapper, а з таблиці `node_cache` (метрики, які пушить `node-guardian`).
## Ціль
1. Стандартизувати визначення endpoint'ів для DAGI Router і Swapper так, щоб:
- У PROD усе працювало через один базовий URL (docker-hostи `dagi-router` / `swapper-service`).
- Не було прив'язки до `node_id` для вибору URL.
- У DEV/локально на Mac можна було використовувати `localhost` через ENV.
2. Переконатися, що `node-guardian` для NODE2 коректно оновлює `node_cache`:
- Є записи з `node_id = 'node-2-macbook-m4max'` для `router_healthy` і `swapper_state`.
- Помилки логуються явно, а не тихо ковтаються.
3. Виправити відображення моделей у Swapper Service:
- Якщо в Swapper реально є моделі, UI має показувати їх (назву, статус, тип, тощо).
- Якщо моделей немає/немає зв'язку — показувати чесний `Degraded` з fallback, але не плутати це з "0/0 при наявних моделях".
4. Мінімізувати магію та дублювання логіки: конфіг через ENV, один контракт між `city-service`, `node-guardian` і `DAGI Router / Swapper`.
---
## Архітектурні принципи
- **Один DAGI Router + один Swapper в проді** (на NODE1) обслуговує всі ноди.
- `city-service` завжди ходить до Router/Swapper через **базовий URL з ENV**, без умов по `node_id`.
- `node-guardian`:
- Викликає Router/Swapper.
- Перетворює результати в метрики `node_cache` (`router_healthy`, `swapper_state`, можливо інші).
- Маркує ці записи конкретним `node_id`, що відповідає ноді, де стоїть guardian.
---
## Завдання
### 1. Нормалізувати `get_node_endpoints` у `services/city-service/repo_city.py`
1. Знайти реалізацію `get_node_endpoints`.
2. Прибрати логіку, яка підміняє URL на `localhost` на основі `node_id` (наприклад, `if "node-2" in node_id` тощо).
3. Замість цього:
- Винести базові URL у ENV, наприклад:
- `ROUTER_BASE_URL` (наприклад, `http://dagi-router:9102` у проді).
- `SWAPPER_BASE_URL` (наприклад, `http://swapper-service:8890` у проді).
- Для DEV (локальний запуск на Mac без Docker) дозволити дефолт:
- `ROUTER_BASE_URL=http://localhost:9102`
- `SWAPPER_BASE_URL=http://localhost:8890`
4. `get_node_endpoints(node)` має повертати структуру типу:
```python
return NodeEndpoints(
router_base=f"{ROUTER_BASE_URL}",
swapper_base=f"{SWAPPER_BASE_URL}",
# за потреби — окремі health / metrics / models endpoints
)
```
5. Не прив'язувати URL до `node_id`. Вся різниця між нодами має відображатись у:
* `node_cache` (метрики),
* БД агентів (який агент до якої ноди прив'язаний).
### 2. Виправити `get_node_swapper_detail` у `services/city-service/routes_city.py`
1. Переконатися, що endpoint `GET /api/v1/nodes/{node_id}/swapper`:
* Використовує `get_node_endpoints` для звернення до Swapper.
* Коректно обробляє:
* HTTP 200 з валідною відповіддю `/api/v1/models`.
* HTTP 5xx / timeout / connection error.
2. Логіка:
* Якщо відповідь успішна — парсити список моделей:
* Назва моделі.
* Статус (loaded/failed/loading).
* Кількість інстансів, GPU/CPU тощо (як дозволяє API Swapper).
* Оновлювати / читати кеш (`node_cache`) так, щоб UI міг показувати:
* Загальну кількість моделей.
* Loaded / Failed / Pending.
* Якщо помилка або моделі не повертаються:
* Повернути `status: "degraded"` та `models: []`, а НЕ 404.
3. Гарантувати, що UI завжди отримує валідний JSON:
* навіть якщо Swapper мертвий,
* без сирих трас і HTML помилок.
### 3. Перевірити та поправити `node-guardian` (особливо для NODE2)
1. Знайти код `node-guardian` (швидше за все окремий сервіс / скрипт).
2. Переконатися, що він:
* Читає ENV:
* `NODE_ID` (для NODE2: `node-2-macbook-m4max`).
* `CITY_API_URL` (HTTPS URL до city-service).
* Периодично:
* Викликає Router health endpoint (через `ROUTER_BASE_URL` або відповідний URL з ENV).
* Викликає Swapper `/api/v1/models` або health endpoint.
* Пушить у `node_cache` записи:
* `router_healthy` з payload (`{"ok": true/false, "latency_ms": ...}`).
* `swapper_state` з payload (`{"models_total": X, "models_loaded": Y, "models_failed": Z, "raw": ...}`).
3. Додати нормальні лог-меседжі:
* На успішне оновлення.
* На помилки (HTTP статус, текст помилки).
4. Перевірити, що в БД (таблиця `node_cache`):
* Після запуску guardian на NODE2 з'являються рядки з `node_id = 'node-2-macbook-m4max'` для `router_healthy` і `swapper_state`.
### 4. Сумісність з наявними фільтрами по `node_id`
1. Знайти всі місця, де читається `node_cache` для вузла:
* Наприклад, `get_node_status`, `get_node_swapper_detail`, `get_node_router_detail` тощо.
2. Переконатися, що фільтрація відбувається по `node_id` + `kind`:
* `WHERE node_id = :node_id AND kind = :kind`
3. Не використовувати глобальний `swapper_state` без `node_id`, якщо вже перейшли на модель "по нодах".
4. Якщо історично був один глобальний запис без `node_id`:
* Міграція (якщо потрібно) — або прибрати цей запис, або задовольнитися тим, що UI читає тільки записи з конкретним `node_id`.
### 5. Swapper models → UI
1. Забезпечити, щоб бекенд повертав у UI-модель (DTO) для Swapper дані:
* `status` (`"ok" | "degraded" | "down"`).
* `models_total`.
* `models_loaded`.
* `models_failed`.
* `models` (масив з короткою інформацією по кожній моделі).
2. Перевірити, що фронт (Node detail page) читає ці поля й не падає, якщо масив `models` порожній.
3. Якщо потрібен stub/fallback — він має відрізнятись від реального "0 моделей при піднятому Swapper'і".
---
## Acceptance Criteria
1. **Endpoint конфіг**:
* У `.env` / docker-compose є:
* `ROUTER_BASE_URL` (у проді → `http://dagi-router:9102`).
* `SWAPPER_BASE_URL` (у проді → `http://swapper-service:8890`).
* `get_node_endpoints` не використовує `node_id` для визначення URL.
* У DEV-режимі локальний запуск на Mac використовує `localhost:9102/8890`.
2. **NODE2 в UI**:
* На сторінці НОДА2:
* DAGI Router:
* Показує реальний статус (`Up/Down`) на основі `router_healthy` з `node_cache`.
* При живому Router статус `Up` без "Router недоступний".
* Swapper Service:
* Показує реальну кількість моделей (якщо вони є у Swapper).
* При проблемах — `Degraded`, але без 404/порожніх екранiв.
3. **Node Guardian**:
* Guardian на НОДА2 працює, логі показують регулярні оновлення.
* У Postgres (таблиця `node_cache`) є останні записи:
* `node_id = 'node-2-macbook-m4max'`, `kind = 'router_healthy'`.
* `node_id = 'node-2-macbook-m4max'`, `kind = 'swapper_state'`.
4. **Swapper models**:
* `curl <SWAPPER_BASE_URL>/api/v1/models` повертає список моделей.
* UI Swapper Service для NODE1 показує ці моделі в таблиці (а не лише `0/0`).
* При зупиненому Swapper:
* UI показує `Degraded` або `Down`, але бекенд повертає валідний JSON з fallback.
5. **Без регресій**:
* NODE1 продовжує показувати 9 агентів у DAGI Router.
* Інші частини `nodes` UI працюють як раніше (агенти, статуси, Node Guardian & Steward секція).
---
## Пріоритет
* Високий. Це критична частина UX для нод та діагностики стану DAGI в DAARION.city.

View File

@@ -3345,36 +3345,41 @@ async def get_node_metrics_current(node_id: str) -> Optional[Dict[str, Any]]:
async def get_node_endpoints(node_id: str) -> Dict[str, str]:
"""
Отримати URL endpoints для конкретної ноди.
Якщо в БД немає значень — підставляє дефолти на основі node_id.
Отримати URL endpoints для DAGI Router та Swapper Service.
Використовує ENV змінні для базових URL (один для всіх нод у проді).
Якщо в БД є специфічні URL для ноди - використовує їх, інакше - ENV дефолти.
ENV змінні:
- ROUTER_BASE_URL (default: http://dagi-router:9102 для проді)
- SWAPPER_BASE_URL (default: http://swapper-service:8890 для проді)
Для DEV (локальний запуск на Mac):
- ROUTER_BASE_URL=http://localhost:9102
- SWAPPER_BASE_URL=http://localhost:8890
"""
pool = await get_pool()
# Get node-specific URLs from DB if exist
row = await pool.fetchrow("""
SELECT router_url, swapper_url
FROM node_cache
WHERE node_id = $1
""", node_id)
# Determine defaults based on node_id
is_node2 = "node-2" in node_id.lower() or "macbook" in node_id.lower()
# Get base URLs from ENV (one for all nodes in production)
router_base = os.getenv("ROUTER_BASE_URL", "http://dagi-router:9102")
swapper_base = os.getenv("SWAPPER_BASE_URL", "http://swapper-service:8890")
if is_node2:
# NODE2 defaults (localhost or IP-based)
defaults = {
"router_url": "http://localhost:9102",
"swapper_url": "http://localhost:8890"
}
else:
# NODE1 defaults (Docker-based)
defaults = {
"router_url": "http://dagi-router:9102",
"swapper_url": "http://swapper-service:8890"
}
defaults = {
"router_url": router_base,
"swapper_url": swapper_base
}
if not row:
return defaults
# Use DB values if present, otherwise fallback to ENV defaults
return {
"router_url": row["router_url"] or defaults["router_url"],
"swapper_url": row["swapper_url"] or defaults["swapper_url"]
@@ -3863,7 +3868,11 @@ async def node_heartbeat(
swapper_state = CASE
WHEN $12::jsonb IS NOT NULL THEN $12::jsonb
ELSE swapper_state
END
END,
router_healthy = COALESCE($13::boolean, router_healthy),
router_version = COALESCE($14, router_version),
router_url = COALESCE($15, router_url),
swapper_url = COALESCE($16, swapper_url)
WHERE node_id = $1
""",
node_id,
@@ -3877,7 +3886,11 @@ async def node_heartbeat(
metrics.get("swapper_healthy"),
metrics.get("swapper_models_loaded"),
metrics.get("swapper_models_total"),
swapper_state_json
swapper_state_json,
metrics.get("router_healthy"),
metrics.get("router_version"),
metrics.get("router_url"),
metrics.get("swapper_url")
)
return {

View File

@@ -4388,43 +4388,86 @@ 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.
Returns fallback data if metrics not found (instead of 404).
First tries to get data from node_cache (populated by node-guardian).
If not found, attempts direct call to Swapper API as fallback.
Returns fallback data if both fail (instead of 404).
"""
import httpx
try:
# Fetch from node_cache
# First, try to fetch from node_cache (preferred - populated by node-guardian)
metrics = await repo_city.get_node_metrics(node_id)
if not metrics:
# Return fallback instead of 404 - allows UI to show pending state
logger.info(f"Swapper metrics not found for {node_id}, returning fallback")
if metrics:
# Parse swapper state (stored as JSONB)
state = metrics.get("swapper_state") or {}
models_data = state.get("models", [])
models = [
SwapperModel(
name=m.get("name", "unknown"),
# Swapper uses "status": "loaded" not "loaded": true
loaded=m.get("status") == "loaded" or m.get("loaded", False),
type=m.get("type"),
vram_gb=m.get("size_gb") or m.get("vram_gb")
)
for m in models_data
]
return NodeSwapperDetail(
node_id=node_id,
healthy=False,
models_loaded=0,
models_total=0,
models=[]
healthy=metrics.get("swapper_healthy", False),
models_loaded=metrics.get("swapper_models_loaded", 0),
models_total=metrics.get("swapper_models_total", 0),
models=models
)
# Parse swapper state (stored as JSONB)
state = metrics.get("swapper_state") or {}
models_data = state.get("models", [])
models = [
SwapperModel(
name=m.get("name", "unknown"),
# Swapper uses "status": "loaded" not "loaded": true
loaded=m.get("status") == "loaded" or m.get("loaded", False),
type=m.get("type"),
vram_gb=m.get("size_gb") or m.get("vram_gb")
)
for m in models_data
]
# Fallback: try direct call to Swapper API
logger.info(f"Swapper metrics not found in cache for {node_id}, trying direct API call")
endpoints = await repo_city.get_node_endpoints(node_id)
swapper_url = endpoints.get("swapper_url")
if swapper_url:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Try to get models from Swapper (endpoint: /models, not /api/v1/models)
resp = await client.get(f"{swapper_url}/models")
if resp.status_code == 200:
data = resp.json()
models_list = data.get("models", []) if isinstance(data, dict) else data
models = [
SwapperModel(
name=m.get("name", "unknown"),
loaded=m.get("status") == "loaded" or m.get("loaded", False),
type=m.get("type"),
vram_gb=m.get("size_gb") or m.get("vram_gb")
)
for m in models_list
]
loaded_count = sum(1 for m in models if m.loaded)
logger.info(f"✅ Direct Swapper API call successful: {loaded_count}/{len(models)} models loaded")
return NodeSwapperDetail(
node_id=node_id,
healthy=True,
models_loaded=loaded_count,
models_total=len(models),
models=models
)
except Exception as api_error:
logger.warning(f"Direct Swapper API call failed for {node_id} at {swapper_url}: {api_error}")
# Final fallback: return empty state
logger.info(f"Swapper data unavailable for {node_id}, returning fallback")
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
healthy=False,
models_loaded=0,
models_total=0,
models=[]
)
except HTTPException:
raise