diff --git a/README.md b/README.md index 9220b43..0f288cf 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,24 @@ - `DELETE /api/v1/servers/{id}` — удалить сервер (с каскадом метрик) - `GET/POST/PATCH/DELETE /api/v1/shares[/{id}]` — сетевые хранилища (CRUD) - `GET /api/v1/shares/{id}/samples?since=…` — история заполнения хранилища +- `GET/POST/PATCH/DELETE /api/v1/services[/{id}]` — health-чеки сервисов (CRUD) +- `GET /api/v1/services/{id}/samples?since=…` — история проб - `GET/PATCH /api/v1/me` — профиль + язык (GET), override языка (PATCH; только cookie-сессия — у Bearer-админа нет личности) +## Сервисы (health-чеки) + +Раздел «Сервисы» следит за здоровьем внешних gnexus-сервисов: добавляете +health-URL (например `http://gnexus-creds.local/api/v1/health`) — панель сама +опрашивает его каждые `GHARD_HEALTH_INTERVAL` сек (все сервисы параллельно, +таймаут `GHARD_HEALTH_TIMEOUT` сек) и хранит историю 7 дней (state, HTTP-код, +latency_ms). + +Формат health-эндпоинтов gnexus ещё не договорён — панель работает по +прогрессивному минимуму: 2xx = ok; тело-JSON со строкой `status` учитывается +(ok-набор → ok, degraded-набор → деградация, всё прочее → вниз); не-2xx/таймаут += вниз. Полный контракт и рекомендации для сервисов: `docs/health-endpoint-spec.md`. + ## Язык интерфейса Три языка: en, uk, ru. По умолчанию панель следует языку аккаунта gnexus-auth diff --git a/docs/health-endpoint-spec.md b/docs/health-endpoint-spec.md new file mode 100644 index 0000000..12ad672 --- /dev/null +++ b/docs/health-endpoint-spec.md @@ -0,0 +1,113 @@ +# Спека: health-эндпоинт gnexus-сервисов + +Контракт, по которому hard-panel (и любой другой монитор gnexus) опрашивает +`/health` сервисов и интерпретирует отклик. Эндпоинты уже есть у всех сервисов, +но формата нет — эта спека фиксирует **минимальный** контракт, работающий «из +коробки» для любого уже существующего эндпоинта, и **рекомендуемое расширение**, +к которому можно двигаться постепенно. + +## 1. Минимальный контракт + +Монитор опрашивает эндпоинт методом **GET**. Достаточно: + +| Отклик | Интерпретация монитором | +|---|---| +| HTTP 2xx | сервис **в порядке (up)** | +| HTTP 2xx с телом-JSON, где `status` — из набора ok (см. §2) | **в порядке (up)** | +| HTTP 2xx с телом-JSON, где `status` — из набора degraded (см. §2) | **деградация (degraded)** | +| HTTP 2xx с телом-JSON, где `status` — любая другая строка | **вниз (down)** | +| HTTP 3xx–5xx | **вниз (down)** | +| таймаут, DNS, отказ соединения | **вниз (down)**, причина — текст ошибки | + +Тело **может вообще отсутствовать или быть любым** (текст, HTML, JSON без +поля `status`) — чистый 2xx в порядке. Монитор никогда не угадывает глубже: +неизвестный формат не превращается во «вниз» (кроме незнакомой строки `status`, +см. §2 — раз сервис явно ответил статусом, монитору его не угадать). + +## 2. Поле `status` + +Если тело — JSON-объект со строковым полем `status`, монитор опирается на него. +Значение сравнивается без учёта регистра. + +| Значение `status` | Монитор показывает | +|---|---| +| `ok`, `healthy`, `pass`, `passing`, `up` | в порядке | +| `degraded`, `warn`, `warning`, `partial`, `busy` | деградация | +| `down`, `error`, `fail`, `critical`, незнакомая строка | вниз | + +Незнакомая строка трактуется консервативно (**вниз**): сервис взялся ответить +статусом — монитор не может считать его здоровым. Если нужно только «в порядке / +не в порядке», используйте просто `ok`; остальное оставьте по минимуму. + +## 3. Рекомендуемое расширение + +То, к чему стоит прийти gnexus-сервисам со временем — одинаковая, машина-читаемая +детализация: + +```json +{ + "status": "ok", + "service": "gnexus-creds", + "version": "1.4.2", + "checks": { + "db": { "status": "ok", "ms": 3 }, + "redis": { "status": "degraded", "detail": "replica lag 15s" } + }, + "timestamp": "2026-10-04T12:00:00Z" +} +``` + +- `status` — агрегат по всем подчинённым: **худший** из `checks.*.status` + (порядок тяжести: down > degraded > ok → down). +- `checks.{name}.status` — те же значения, что в §2 (`ok`/`degraded`/`down`). +- `checks.{name}.ms` — необязательная длительность подчинённой проверки, ms. +- `checks.{name}.detail` — необязательная причина degraded/down (одной строкой). +- `service`, `version`, `timestamp` — необязательные, для журналов и + самодиагностики. + +Мониторы, написанные по §1–2, продолжают работать с таким телом без изменений: +агрегатный `status` уже говорит всё. + +## 4. Требования к эндпоинту + +- **Без авторизации.** `/health` не раскрывает данные; auth на нём превращает + мониторинг в «всегда вниз». +- **Дёшево.** Монитор опрашивается каждые ~30 с постоянно. Никаких тяжёлых + запросов на пинг: проверяйте только доступность (ping БД — `SELECT 1`, + не миграции). Кэшируйте результат, если проверка дороже ~100 ms, максимум + на 10 с. +- **Быстро.** Отвечайте за **< 500 ms**; таймаут монитора — 5 s. +- **Идемпотентно, без побочных эффектов.** Никаких счётчиков метрик, писем, + записей в БД. +- **Не логируйте каждый пинг.** Сотни записей в минуту — шум, заслоняющий + настоящие ошибки. +- **Ставить в /health или /healthz — на выбор сервиса**; монитор берёт + полный URL из настроек, редиректы следуются. + +## 5. Примеры + +```bash +# минимум — текстовый 200 +$ curl -i http://gnexus-creds.local/health +HTTP/1.1 200 OK +OK + +# со статусом деградации (монитор покажет «деградация», HTTP 200) +$ curl -i http://gnexus-auth.local/health +HTTP/1.1 200 OK +{"status": "degraded"} + +# рекомендуемое расширение +$ curl http://gnsynapse.local/health +{"status": "ok", "service": "gnsynapse", "version": "0.8.0", + "checks": {"db": {"status": "ok", "ms": 2}, "queue": {"status": "ok", "ms": 1}}} +``` + +Смена ответа монитору — не breaking change: добавление полей в тело безопасно, +монитор игнорирует неизвестное; удаление `status` (возвращаясь к минимуму) +— тоже. + +## Changelog + +- 2026-10-04 — первая версия: минимум (2xx + строка `status`), рекомендуемое + расширение `checks`, требования к эндпоинту. \ No newline at end of file diff --git a/panel/backend/.env.example b/panel/backend/.env.example index cec043c..2fc98ac 100644 --- a/panel/backend/.env.example +++ b/panel/backend/.env.example @@ -14,6 +14,11 @@ # период опроса сетевых хранилищ (раздел Storage), сек GHARD_SHARE_INTERVAL=60 +# период опроса health-эндпоинтов сервисов (раздел Сервисы), сек +GHARD_HEALTH_INTERVAL=30 +# таймаут одного health-запроса, сек +GHARD_HEALTH_TIMEOUT=5 + # --- gnexus-auth (OAuth PKCE) ------------------------------------------------ # Задай GHARD_AUTH_CLIENT_ID — и веб-интерфейс закроется входом через # gnexus-auth (клиент регистрируется в админке gnexus-auth: /admin/clients). diff --git a/panel/backend/app/api/services.py b/panel/backend/app/api/services.py new file mode 100644 index 0000000..2ba826c --- /dev/null +++ b/panel/backend/app/api/services.py @@ -0,0 +1,152 @@ +"""Health-чеки сервисов: CRUD + история откликов. + +URL опрашивает services_probe (lifespan, каждые GHARD_HEALTH_INTERVAL сек, +параллельно по сервисам); точки лежат в service_samples. Статус: up/degraded +(свежая точка этого state), down (свежая down-точка), pending (точек нет или +пробер молчит — нет свежих данных). Классификация отклика — прогрессивный +минимум из services_probe (docs/health-endpoint-spec.md). +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.config import get_settings +from app.db import get_db +from app.security import require_admin +from app.services_probe import probe_service_once + +router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)]) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class ServiceCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + url: str = Field(min_length=1, max_length=500) + + +class ServiceUpdate(BaseModel): + name: str | None = None + url: str | None = None + + +@router.get("/services") +async def list_services() -> list[dict]: + """Все сервисы + последняя проба (статус как у шар).""" + db = get_db() + cursor = await db.execute( + """SELECT sv.*, p.ts AS p_ts, p.state AS p_state, p.code AS p_code, + p.latency_ms AS p_latency, p.message AS p_message + FROM services sv + LEFT JOIN service_samples p ON p.id = ( + SELECT MAX(id) FROM service_samples WHERE service_id = sv.id + ) + ORDER BY sv.name""" + ) + rows = await cursor.fetchall() + stale_after = max(get_settings().health_interval * 3, 180) + result = [] + for row in rows: + status = "pending" + if row["p_ts"] is not None: + age = (datetime.now(timezone.utc) - datetime.fromisoformat(row["p_ts"])).total_seconds() + if age <= stale_after: + status = row["p_state"] # up | degraded | down + result.append( + { + "id": row["id"], + "name": row["name"], + "url": row["url"], + "created_at": row["created_at"], + "status": status, + "last_sample": row["p_ts"], + "latency_ms": row["p_latency"] if status != "pending" else None, + "code": row["p_code"] if status != "pending" else None, + "message": row["p_message"] if status != "pending" else "", + } + ) + return result + + +@router.post("/services", status_code=201) +async def create_service(body: ServiceCreate) -> dict: + db = get_db() + cursor = await db.execute( + "INSERT INTO services (name, url, created_at) VALUES (?, ?, ?)", + (body.name, body.url, _now_iso()), + ) + await db.commit() + # первая проба сразу — не ждём ближайшей волны пробера + try: + await probe_service_once(cursor.lastrowid, body.url) + except Exception as exc: # замер доберёт фоновый цикл + print(f"services probe error (on create): {exc}", flush=True) + return {"id": cursor.lastrowid, "name": body.name, "url": body.url} + + +@router.patch("/services/{service_id}") +async def update_service(service_id: int, body: ServiceUpdate) -> dict: + if body.name is None and body.url is None: + raise HTTPException(status_code=422, detail="nothing to update") + db = get_db() + if body.name is not None: + await db.execute("UPDATE services SET name = ? WHERE id = ?", (body.name, service_id)) + if body.url is not None: + await db.execute("UPDATE services SET url = ? WHERE id = ?", (body.url, service_id)) + cursor = await db.execute("SELECT changes()") + (changed,) = await cursor.fetchone() + if not changed: + raise HTTPException(status_code=404, detail="service not found") + await db.commit() + probe = None + if body.url is not None: # URL сменился — пробуем не дожидаясь волны + try: + probe = await probe_service_once(service_id, body.url) + except Exception as exc: + print(f"services probe error (on update): {exc}", flush=True) + cursor = await db.execute("SELECT id, name, url, created_at FROM services WHERE id = ?", (service_id,)) + return dict(await cursor.fetchone()) | ({"probe": probe} if probe else {}) + + +@router.delete("/services/{service_id}", status_code=204) +async def delete_service(service_id: int) -> None: + db = get_db() + cursor = await db.execute("DELETE FROM services WHERE id = ?", (service_id,)) + await db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="service not found") + + +@router.get("/services/{service_id}/samples") +async def service_samples( + service_id: int, + since: datetime | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=5000), +) -> list[dict]: + """История проб (для графика латентности/статусов). По возрастанию ts.""" + db = get_db() + conditions = ["service_id = ?"] + params: list = [service_id] + if since is not None: + conditions.append("ts >= ?") + params.append(since.astimezone(timezone.utc).isoformat()) + params.append(limit) + cursor = await db.execute( + f"SELECT ts, state, code, latency_ms FROM service_samples WHERE {' AND '.join(conditions)}" + " ORDER BY ts DESC LIMIT ?", + params, + ) + rows = await cursor.fetchall() + return [ + { + "ts": row["ts"], + "state": row["state"], + "code": row["code"], + "latency_ms": row["latency_ms"], + } + for row in reversed(rows) + ] \ No newline at end of file diff --git a/panel/backend/app/config.py b/panel/backend/app/config.py index cfca9bd..c11a55a 100644 --- a/panel/backend/app/config.py +++ b/panel/backend/app/config.py @@ -27,6 +27,10 @@ offline_multiplier: int = 3 # как часто панель опрашивает сетевые хранилища (сек) share_interval: int = 60 + # как часто панель опрашивает health-эндпоинты сервисов (сек) + health_interval: int = 30 + # таймаут одного health-запроса (сек) + health_timeout: float = 5.0 @lru_cache diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 475d740..643b8eb 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -10,9 +10,10 @@ from app.security import require_admin -from app.api import auth_routes, ingest, servers, shares +from app.api import auth_routes, ingest, servers, services, shares from app.db import close_db, init_db from app.mcp import mcp, mcp_asgi +from app.services_probe import loop as services_probe_loop from app.shares_probe import loop as shares_probe_loop # Собранный Vue-фронт (panel/frontend/dist) попадает сюда в docker-образе; @@ -25,6 +26,8 @@ await init_db() # фоновый пробер сетевых хранилищ (share_samples) probe_task = asyncio.create_task(shares_probe_loop()) + # фоновый пробер health-эндпоинтов сервисов (service_samples) + health_task = asyncio.create_task(services_probe_loop()) # подчистка expired-сессий и oauth state (каждые 10 минут) gc_task = asyncio.create_task(auth_gc_loop()) # lifespan смонтированных sub-app не вызывается — MCP session manager @@ -32,6 +35,7 @@ async with mcp.session_manager.run(): yield probe_task.cancel() + health_task.cancel() gc_task.cancel() await close_db() @@ -65,6 +69,7 @@ app.include_router(ingest.router) app.include_router(servers.router) app.include_router(shares.router) +app.include_router(services.router) # MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp. # Доступ как у REST API (require_admin): Bearer-токен, если задан; diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index 5245d10..f4d5df5 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -72,6 +72,30 @@ ); CREATE INDEX IF NOT EXISTS idx_share_samples_share_ts ON share_samples(share_id, ts); + +-- Health-чеки внешних сервисов (панель сама опрашивает их /health). +-- Формат отклика не договорён — прогрессивный минимум: +-- 2xx → up; тело-JSON с строковым status: ok-набор → up, +-- degraded-набор → degraded, всё прочее → down; +-- не-2xx/таймаут/отказ → down (точка всё равно пишется). +CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, -- полный health URL сервиса + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS service_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + ts TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'down', -- up | degraded | down + code INTEGER, -- HTTP-код; NULL = сеть/таймаут/DNS + latency_ms REAL NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '' -- status из тела или текст сетевой ошибки +); + +CREATE INDEX IF NOT EXISTS idx_service_samples_service_ts ON service_samples(service_id, ts); -- Незавершённые OAuth-потоки gnexus-auth (state → pkce_verifier, 10 минут) CREATE TABLE IF NOT EXISTS oauth_states ( state TEXT PRIMARY KEY, diff --git a/panel/backend/app/services_probe.py b/panel/backend/app/services_probe.py new file mode 100644 index 0000000..06e1999 --- /dev/null +++ b/panel/backend/app/services_probe.py @@ -0,0 +1,146 @@ +"""Фоновый пробер health-эндпоинтов сервисов. + +Панель сама опрашивает настроенные в разделе «Сервисы» URL (GET) каждые +GHARD_HEALTH_INTERVAL секунд, все сервисы — параллельно. Формат отклика +health-эндпоинтов у gnexus-сервисов пока не договорён — работает +прогрессивный минимум (см. docs/health-endpoint-spec.md): + + 2xx → up; тело-JSON со строковым status: ok-набор → up, + degraded-набор → degraded, всё прочее → down; + не-2xx / таймаут / отказ сети → down (точка пишется в любом случае, + latency замеряется). + +Старше 7 дней точки удаляются (политика хранения). +""" + +import asyncio +import json +import time +from datetime import datetime, timedelta, timezone + +import httpx + +from app.config import get_settings +from app.db import get_db + +RETENTION_DAYS = 7 + +# тело health в 64 KB не уместится — больше и читать не надо +MAX_BODY = 64 * 1024 + +# значение body.status (в lowercase) → состояние (docs/health-endpoint-spec) +OK_VALUES = {"ok", "healthy", "pass", "passing", "up"} +DEGRADED_VALUES = {"degraded", "warn", "warning", "partial", "busy"} + + +def classify(code: int, body: bytes | None) -> tuple[str, str]: + """(state, message) по коду и телу отклика. code задан, если дошёл до HTTP.""" + if code < 200 or code >= 300: + return "down", f"http {code}" + if body: + try: + payload = json.loads(body.decode("utf-8", errors="replace")) + except (ValueError, UnicodeError): + return "up", "" + if isinstance(payload, dict): + status = payload.get("status") + if isinstance(status, str): + value = status.strip().lower() + if value in OK_VALUES: + return "up", value + if value in DEGRADED_VALUES: + return "degraded", value + # не-словарный или незнакомый статус — счёт не в пользу сервиса + return "down", value + return "up", "" + + +async def probe_once(client: httpx.AsyncClient, url: str) -> dict: + """Один GET: {state, code, latency_ms, message}.""" + started = time.monotonic() + code: int | None = None + message = "" + state = "down" + try: + response = await client.send(client.build_request("GET", url), stream=True) + code = response.status_code + chunks: list[bytes] = [] + # health-тела маленькие: как только набрали MAX_BODY — дальше не читаем + async for chunk in response.aiter_bytes(4096): + chunks.append(chunk) + if sum(map(len, chunks)) >= MAX_BODY: + break + body = b"".join(chunks)[:MAX_BODY] + await response.aclose() + state, message = classify(code, body) + except httpx.HTTPError as exc: # таймаут, DNS, отказ соединения + text = str(exc) + message = (text.split(";")[0][:200] if text else type(exc).__name__) + except Exception as exc: # noqa: BLE001 — любая ошибка = вниз, цикл не роняем + message = str(exc)[:200] + latency_ms = round((time.monotonic() - started) * 1000, 1) + return {"state": state, "code": code, "latency_ms": latency_ms, "message": message} + + +async def sample_once() -> int: + """Одна волна проб по всем сервисам. Возвращает число точек.""" + db = get_db() + cursor = await db.execute("SELECT id, url FROM services") + rows = await cursor.fetchall() + if not rows: + return 0 + settings = get_settings() + async with httpx.AsyncClient( + timeout=settings.health_timeout, follow_redirects=True, verify=False + ) as client: + results = await asyncio.gather( + *(probe_once(client, row["url"]) for row in rows), return_exceptions=True + ) + now = datetime.now(timezone.utc).isoformat() + points = 0 + for row, result in zip(rows, results): + if isinstance(result, Exception): # проб по отдельному url не уронил волну + probe = {"state": "down", "code": None, "latency_ms": 0.0, "message": str(result)[:200]} + else: + probe = result + await db.execute( + "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message)" + " VALUES (?, ?, ?, ?, ?, ?)", + (row["id"], now, probe["state"], probe["code"], probe["latency_ms"], probe["message"]), + ) + points += 1 + cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat() + await db.execute("DELETE FROM service_samples WHERE ts < ?", (cutoff,)) + await db.commit() + return points + + +async def probe_service_once(service_id: int, url: str) -> dict: + """Одиночная проба одного сервиса (POST/PATCH в API вызывает сразу).""" + settings = get_settings() + async with httpx.AsyncClient( + timeout=settings.health_timeout, follow_redirects=True, verify=False + ) as client: + probe = await probe_once(client, url) + db = get_db() + await db.execute( + "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message)" + " VALUES (?, ?, ?, ?, ?, ?)", + ( + service_id, + datetime.now(timezone.utc).isoformat(), + probe["state"], probe["code"], probe["latency_ms"], probe["message"], + ), + ) + await db.commit() + return probe + + +async def loop() -> None: + interval = get_settings().health_interval + while True: + try: + await sample_once() + except Exception as exc: # не роняем цикл из-за одной ошибки + print(f"services probe error: {exc}", flush=True) + await asyncio.sleep(interval) \ No newline at end of file diff --git a/panel/frontend/src/App.vue b/panel/frontend/src/App.vue index feb98c1..91fe629 100644 --- a/panel/frontend/src/App.vue +++ b/panel/frontend/src/App.vue @@ -29,6 +29,11 @@ onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, }, { + label: t('nav.services'), icon: 'ph-heartbeat', href: '/services', + active: route.path.startsWith('/services'), + onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, + }, + { label: t('nav.settings'), icon: 'ph-gear', href: '/settings', active: route.path.startsWith('/settings'), onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, diff --git a/panel/frontend/src/api.js b/panel/frontend/src/api.js index 4f5cd77..a7f8902 100644 --- a/panel/frontend/src/api.js +++ b/panel/frontend/src/api.js @@ -68,6 +68,18 @@ params.set('limit', String(limit)) return request(`/shares/${id}/samples?${params}`) }, + services: () => request('/services'), + createService: (name, url) => + request('/services', { method: 'POST', body: { name, url } }), + updateService: (id, fields) => + request(`/services/${id}`, { method: 'PATCH', body: fields }), + deleteService: (id) => request(`/services/${id}`, { method: 'DELETE' }), + serviceSamples: (id, since, limit = 1000) => { + const params = new URLSearchParams() + if (since) params.set('since', since.toISOString()) + params.set('limit', String(limit)) + return request(`/services/${id}/samples?${params}`) + }, // OAuth не под /api/v1 — служебные пути панели logout: async () => { return fetch('/auth/logout', { method: 'POST' }) diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js index 6edf246..e9a1275 100644 --- a/panel/frontend/src/i18n/messages/en.js +++ b/panel/frontend/src/i18n/messages/en.js @@ -109,6 +109,30 @@ "storage.historyFailed": "Storage history failed to load", "storage.shareDeleted": "Share deleted", + // services (health checker) + "nav.services": "Services", + "services.title": "Services", + "services.subtitle": "Health of external services via their health endpoints", + "services.addService": "Add service", + "services.emptyTitle": "No services yet", + "services.emptyText": "Add a health URL of a service (for example gnexus-auth) — the panel will poll it itself", + "services.addTitle": "Add service", + "services.name": "Name", + "services.url": "Health URL", + "services.urlHint": "The address the panel polls (GET) to check health, for example http://gnexus-creds.local/api/v1/health", + "services.latency": "response {n} ms", + "services.stateUp": "up", + "services.stateDegraded": "degraded", + "services.stateDown": "down", + "services.statePending": "waiting", + "services.notResponding": "not responding — {message}", + "services.serviceAdded": "Service added", + "services.historyFailed": "Service history failed to load", + "services.serviceDeleted": "Service deleted", + "services.deleteTitle": "Delete service?", + "services.deleteMessage": "\"{name}\" will be removed from health monitoring. The service itself is not touched.", + "services.historyTitle": "Response time over 24 h, ms", + // settings "settings.title": "Settings", "settings.languageToolbar": "Interface language", diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js index 3aa9cfb..ef7f9e0 100644 --- a/panel/frontend/src/i18n/messages/ru.js +++ b/panel/frontend/src/i18n/messages/ru.js @@ -108,6 +108,30 @@ "storage.historyFailed": "Не удалось загрузить историю хранилищ", "storage.shareDeleted": "Шара удалена", + // services (health checker) + "nav.services": "Сервисы", + "services.title": "Сервисы", + "services.subtitle": "Здоровье внешних сервисов по их health-эндпоинтам", + "services.addService": "Добавить сервис", + "services.emptyTitle": "Сервисов пока нет", + "services.emptyText": "Добавьте health-URL сервиса (например gnexus-auth) — панель будет опрашивать его сама", + "services.addTitle": "Добавить сервис", + "services.name": "Название", + "services.url": "Health-URL", + "services.urlHint": "Адрес, который панель опрашивает (GET) для проверки здоровья, например http://gnexus-creds.local/api/v1/health", + "services.latency": "отклик {n} ms", + "services.stateUp": "в порядке", + "services.stateDegraded": "деградация", + "services.stateDown": "недоступен", + "services.statePending": "ожидание", + "services.notResponding": "не отвечает — {message}", + "services.serviceAdded": "Сервис добавлен", + "services.historyFailed": "Не удалось загрузить историю сервисов", + "services.serviceDeleted": "Сервис удалён", + "services.deleteTitle": "Удалить сервис?", + "services.deleteMessage": "«{name}» будет убран из health-мониторинга. Сам сервис не затрагивается.", + "services.historyTitle": "Время отклика за 24 ч, ms", + // settings "settings.title": "Настройки", "settings.languageToolbar": "Язык интерфейса", diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js index c5131bf..392dcde 100644 --- a/panel/frontend/src/i18n/messages/uk.js +++ b/panel/frontend/src/i18n/messages/uk.js @@ -108,6 +108,30 @@ "storage.historyFailed": "Не вдалося завантажити історію сховищ", "storage.shareDeleted": "Шару видалено", + // services (health checker) + "nav.services": "Сервіси", + "services.title": "Сервіси", + "services.subtitle": "Здоров'я зовнішніх сервісів за їх health-ендпоінтами", + "services.addService": "Додати сервіс", + "services.emptyTitle": "Сервісів ще немає", + "services.emptyText": "Додайте health-URL сервісу (наприклад gnexus-auth) — панель опитуватиме його сама", + "services.addTitle": "Додати сервіс", + "services.name": "Назва", + "services.url": "Health-URL", + "services.urlHint": "Адреса, яку панель опитує (GET) для перевірки здоров'я, наприклад http://gnexus-creds.local/api/v1/health", + "services.latency": "відгук {n} ms", + "services.stateUp": "у порядку", + "services.stateDegraded": "деградація", + "services.stateDown": "недоступний", + "services.statePending": "очікування", + "services.notResponding": "не відповідає — {message}", + "services.serviceAdded": "Сервіс додано", + "services.historyFailed": "Не вдалося завантажити історію сервісів", + "services.serviceDeleted": "Сервіс видалено", + "services.deleteTitle": "Видалити сервіс?", + "services.deleteMessage": "«{name}» буде прибрано з health-моніторингу. Сам сервіс не зачіпається.", + "services.historyTitle": "Час відгуку за 24 год, ms", + // settings "settings.title": "Налаштування", "settings.languageToolbar": "Мова інтерфейсу", diff --git a/panel/frontend/src/pages/ServicesPage.vue b/panel/frontend/src/pages/ServicesPage.vue new file mode 100644 index 0000000..3a69919 --- /dev/null +++ b/panel/frontend/src/pages/ServicesPage.vue @@ -0,0 +1,289 @@ + + + + + \ No newline at end of file diff --git a/panel/frontend/src/router.js b/panel/frontend/src/router.js index 7296a78..8b2cb4d 100644 --- a/panel/frontend/src/router.js +++ b/panel/frontend/src/router.js @@ -14,6 +14,11 @@ component: () => import('./pages/StoragePage.vue'), }, { + path: '/services', + name: 'services', + component: () => import('./pages/ServicesPage.vue'), + }, + { path: '/settings', name: 'settings', component: () => import('./pages/SettingsPage.vue'),