diff --git a/README.md b/README.md index 9a57e9d..b5a6400 100644 --- a/README.md +++ b/README.md @@ -397,9 +397,18 @@ | Tool | Что отдаёт | | --- | --- | -| `panel_overview` | все серверы одной сводкой: статус, CPU/RAM/диск, сеть, последний пакет; плюс сетевые хранилища с заполнением | +| `panel_overview` | всё одной сводкой: серверы (статус, CPU/RAM/диск, сеть, последний пакет), сервисы (health), сетевые хранилища | | `server_details` | полное состояние сервера: метрики, диски, процессы, docker, заметка | -| `server_history` | история метрик за N часов, прореженная до N точек | +| `server_history` | история метрик за N часов, прореженная до N точек (история хранится 7 дней) | +| `services_overview` | все health-чеки: состояние, latency, код, self-report (версия/окружение/режим) | +| `service_incidents` | журнал моментов недоступности сервиса (периоды down+degraded) | +| `events_recent` | хвост единого журнала событий с фильтром по severity | + +Все тулы read-only — агент наблюдатель: отвечает «как мои серверы?», +«что тормозит на web-01?», «растёт ли диск на db-01 за сутки?», «сервис +упал ночью?» без доступа к веб-интерфейсу. Инструкции агенту панели +отдаются при подключении (`instructions`) и как MCP-prompt `agent_guide` — +агент сам выбирает подходящий инструмент по вопросу. Подключение (Claude Code): @@ -408,14 +417,12 @@ ``` Для любого другого MCP-клиента — тот же URL, транспорт streamable HTTP. -После подключения агент может отвечать на вопросы «как мои серверы?», -«что тормозит на web-01?», «растёт ли диск на db-01 за сутки?» без -доступа к веб-интерфейсу. **Заметка про токен**: `/mcp` сейчас открыт так же, как REST API при пустом `GHARD_ADMIN_TOKEN`. Если панель торчит в интернет и ты задаёшь -токен — закрой `/mcp` на reverse proxy или оставь доступ только из -доверенной сети, иначе любой сможет читать метрики. +токен — закрыть `/mcp` на reverse proxy нельзя (агенты подключаются с +`Authorization: Bearer ` в заголовке); оставь доступ +только из доверенной сети, иначе любой сможет читать метрики. ## Статус diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 8197f7b..2df53cd 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -13,7 +13,7 @@ from app.api import auth_routes, events, ingest, servers, services, shares from app.db import close_db, init_db from app.events import offline_loop -from app.mcp import mcp, mcp_asgi +from app.mcp import mcp, mcp_endpoint from app.services_probe import loop as services_probe_loop from app.shares_probe import loop as shares_probe_loop @@ -80,7 +80,8 @@ # Доступ как у REST API (require_admin): Bearer-токен, если задан; # браузерная cookie gnexus-auth тоже проходит (для MCP-клиента-агента). app.router.routes.append( - APIRoute("/mcp", mcp_asgi, methods=["GET", "POST", "DELETE"], dependencies=[Depends(require_admin)]) + APIRoute("/mcp", mcp_endpoint, methods=["GET", "POST", "DELETE"], + dependencies=[Depends(require_admin)]) ) diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index 30faec3..3e9f2ff 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -4,30 +4,121 @@ монтируется в FastAPI в app.main на /mcp. Клиент Claude Code: claude mcp add --transport http ghard https://panel.example.com/mcp + +Инструкции агенту (AGENT_INSTRUCTIONS) отдаются и как instructions +инициализации, и как prompt agent_guide — паттерн reference-реализации +gnexus-creds (data_api). Все тулы read-only: агент — наблюдатель. """ import json from datetime import datetime, timedelta, timezone +from fastapi import Request +from starlette.responses import Response from mcp.server.fastmcp import FastMCP +from app.api.events import list_events as _list_events from app.api.servers import get_server, list_servers -from app.api.shares import list_shares +from app.api.services import list_services as _list_services +from app.api.services import service_incidents as _service_incidents +from app.api.shares import list_shares as _list_shares from app.db import get_db +AGENT_INSTRUCTIONS = """\ +# GHard Monitor — инструкция для ИИ-агента + +## Назначение +Этот сервер — MCP-интерфейс панели мониторинга GHard Monitor +(Gnexus Hardware Monitor). Ты — наблюдатель-диагност: отвечаешь +пользователю о состоянии его серверов и сервисов. Все инструменты — +**только чтение**: менять настройки, удалять и перезапускать ты не можешь +(и это хорошо — панельные изменения делает человек через веб-интерфейс). + +## Данные +- **Серверы** — машины с установленным агентом hard-monitor: CPU/RAM/swap, + load, диски по маунтам, сеть (счёт с агента), процессы, docker-контейнеры, + апптайм, заметка пользователя. +- **Сервисы** — health-чеки внешних HTTP-эндпоинтов (gnexus-сервисы: + /health и похожие). Панель пробует их по расписанию и хранит историю + откликов + журнал моментов недоступности. +- **Сетевые хранилища** — примонтированные NAS-шары и их заполнение. +- **Журнал событий** — всё, что панель зафиксировала: offline/пороги/ + контейнеры/сервисы (хранение 90 дней). + +## Язык +Отвечай пользователю на его языке. Вывод тулов технический (статусы +и события — английские слаги), переводи их в человеческую речь сам. + +## Карта инструментов — что вызывать +| Вопрос пользователя | Инструмент | +|---|---| +| «как мои серверы?» / «что вообще происходит?» | `panel_overview` — одним вызовом: все серверы + сервисы + хранилища | +| «что с web-01?» / «что жрёт память?» / «почему тормозит?» | `server_details` (id — из `panel_overview`) | +| «растёт ли диск на db-01 за сутки?» / тренд | `server_history` | +| «сервис падал?», «с какими сервисами проблемы?» | `services_overview`, потом `service_incidents` (id — из overview) | +| «что происходило ночью / за последние часы?» | `events_recent` | + +Порядок для составных вопросов: обзор → выбранная детализация. `panel_overview` +дешёвый — начинай с него, если не уверен. + +## Словарь статусов +- Сервер: `online` (пакеты идут) / `offline` (панель не получает метрики — + агент умер, сеть или машина выключена) / данных нет. +- Сервис: `up` (здоров) / `degraded` (работает, но сообщает о деградации) / + `down` (не отвечает) / `pending` (данных ещё нет). Поле `mode` + (maintenance / readonly / draining) — это **режим работы**, а не падение; + не пугай пользователя режимом. +- Журнал: severity `info` / `warning` / `critical`. + +## Словарь событий (поле type в журнале) +- `server_offline` / `server_online` — сервер перестал/возобновил слать метрики. +- `cpu_high` / `ram_high` / `swap_high` / `disk_high` / `load_high` — ресурс + превысил порог (порог и значение — в payload); `*_recovered` — вернулся + в норму (гистерезис: открытие и закрытие — разные пороги). +- `container_added` / `container_removed` / `container_started` / + `container_exited` — docker-контейнер появился/исчез/запустился/завершился + (в payload: name, image, exit_code если есть). +- `service_down` / `service_degraded` / `service_recovered` — сервис + перестал отвечать / деградировал / восстановился. +- Пороги сжимаются в человеческую речь, например: + `cpu 95% (≥90%)`, `disk / 98% (≥90%)`, `load1 2.1×cores (≥2.0×)`. + +## Правила +- ID серверов и сервисов бери из обзорных тулов — угадывание запрещено. +- `server_history(hours)`: разумно 1 (детали), 6, 24 (сутки) или 168 (неделя; + глубже недели данных нет — история метрик хранится 7 дней). +- Числа передавай как есть из тулов (проценты, МБ/с, ms) — не пересчитывай + единицы самостоятельно. +- Если сервер offline — не паникуй и не повторяй вопросы тулами; скажи + последнее известное состояние (в `server_details`) и время последнего пакета. +- Пользователь просит «что-нибудь сделать» (перезапустить, удалить) — + объясни, что доступ только на чтение, изменения — через веб-интерфейс. + +## Подключение (для администратора панели) +``` +claude mcp add --transport http ghard https://panel.example.com/mcp +``` +Transport — streamable HTTP. Авторизация: если у панели задан +`GHARD_ADMIN_TOKEN`, добавь заголовок +`Authorization: Bearer ` в конфигурацию MCP-клиента. +""" + + mcp = FastMCP( "GHard Monitor", - instructions=( - "Мониторинг серверов GHard Monitor. Начни с panel_overview, чтобы " - "увидеть все серверы и их статус; server_details даёт текущие метрики " - "и процессы конкретной машины, server_history — историю для графиков/трендов." - ), + instructions=AGENT_INSTRUCTIONS, stateless_http=True, # без сессий — работает за reverse proxy json_response=True, streamable_http_path="/", ) +@mcp.prompt() +def agent_guide() -> str: + """📘 Гайд: как отвечать на вопросы о серверах и сервисах через GHard Monitor.""" + return AGENT_INSTRUCTIONS + + def _dumps(data) -> str: return json.dumps(data, ensure_ascii=False, indent=1) @@ -40,10 +131,9 @@ @mcp.tool() async def panel_overview() -> str: - """Обзор всех серверов: статус, CPU/RAM/диски, сеть, последний пакет. - - Самый быстрый способ ответить на «как мои серверы?» / «что с ним сейчас?». - """ + """Обзор всего: серверы (статус, CPU/RAM/диски, сеть, последний пакет), + сервисы (health), сетевые хранилища. Самый быстрый способ ответить на + «как мои серверы?» / «что вообще происходит?».""" servers = await list_servers() online = sum(1 for s in servers if s["status"] == "online") lines = [f"Серверов: {len(servers)}, онлайн: {online}, не в сети: {len(servers) - online}"] @@ -61,7 +151,21 @@ else: status = s['status'] lines.append(f"#{s['id']} {s['name']} [{status}] — данных ещё нет") - shares = await list_shares() + services = await _list_services() + if services: + bad = sum(1 for x in services if x["status"] in ("down", "degraded")) + lines.append(f"Сервисов: {len(services)}, проблемных (down/degraded): {bad}") + for svc in services: + report = svc.get("report") or {} + identity = "" + if report.get("version"): + identity = f", {report.get('service') or '?'} {report['version']}" + mode = f", режим {report['mode']}" if report.get("mode") else "" + msg = f" ({svc['message']})" if svc.get("message") else "" + lines.append( + f"#{svc['id']} {svc['name']} [{svc['status']}] {svc['latency_ms'] or '—'} ms{identity}{mode}{msg}" + ) + shares = await _list_shares() if shares: lines.append(f"Сетевые хранилища: {len(shares)}") for sh in shares: @@ -92,6 +196,7 @@ """История метрик сервера за `hours` часов, прореженная до `max_points`. Возвращает точки: ts, cpu, ram%, swap, load, сеть MB/s, диски. + История метрик хранится 7 дней — глубже точки удаляются. """ since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() db = get_db() @@ -127,10 +232,103 @@ return _dumps({"server_id": server_id, "hours": hours, "points": points}) +# --- Сервисы (health-чеки) ----------------------------------------------------- + + +@mcp.tool() +async def services_overview() -> str: + """Все наблюдаемые сервисы (health-чеки): state, latency, HTTP-код, + self-report (версия/окружение/режим). id нужен для service_incidents.""" + services = await _list_services() + lines = [f"Сервисов: {len(services)}"] + for svc in services: + report = svc.get("report") or {} + identity = [] + for key in ("service", "version", "environment"): + if report.get(key): + identity.append(str(report[key])) + id_str = f" — {' '.join(identity)}" if identity else "" + mode = f", режим {report['mode']}" if report.get("mode") else "" + code = f" код {svc['code']}" if svc.get("code") is not None else "" + msg = f" ({svc['message']})" if svc.get("message") else "" + lines.append( + f"#{svc['id']} {svc['name']} [{svc['status']}] " + f"{svc['latency_ms'] or '—'} ms{code}{mode}{msg}{id_str}" + ) + return "\n".join(lines) + + +@mcp.tool() +async def service_incidents(service_id: int, limit: int = 20) -> str: + """Журнал моментов недоступности сервиса: периоды down+degraded. + + Возвращает: start, end (null = идёт сейчас), duration_s, + worst (худшее состояние в периоде), code, message (причина из первой точки). + """ + try: + incidents = await _service_incidents(service_id, limit=limit) + except Exception: + return f"Сервис #{service_id} не найден" + return _dumps(incidents) + + +# --- Журнал событий ------------------------------------------------------------ + + +@mcp.tool() +async def events_recent(limit: int = 30, severity: str = "") -> str: + """Хвост единого журнала событий (90 дней хранения). + + severity — необязательный фильтр: info | warning | critical. + Возвращает: id, ts, type, severity, message, data (payload события). + """ + if limit < 1 or limit > 1000: + return "limit: 1..1000" + events = await _list_events(limit=min(limit, 1000)) + if severity: + events = [e for e in events if e.get("severity") == severity] + return _dumps(events) + + mcp_app = mcp.streamable_http_app() # ASGI-эндпоинт MCP: регистрируется прямо в FastAPI на /mcp (см. app.main), # потому что Mount() не совпадает с путём без хвостового слэша, а lifespan # смонтированного sub-app не запускается сам — менеджер сессий стартует # в lifespan app.main (mcp.session_manager.run()). -mcp_asgi = mcp_app.router.routes[0].app \ No newline at end of file +mcp_asgi = mcp_app.router.routes[0].app + + +async def mcp_endpoint(request: Request) -> "Response": + """Мост FastAPI → ASGI streamable-http-app. + + FastAPI валидирует сигнатуру endpoint'а: голый ASGI-объект (scope, + receive, send) был бы прочитан как query-параметры → 422. Поэтому мост: + забираем тело, вручную докручиваем ASGI-диалог, ответ собираем из + сообщений send (stateless + json_response=True — ответ всегда один + JSON, SSE-стримов тут нет). + """ + body = await request.body() + received = False + start: dict = {} + chunks: list[bytes] = [] + + async def receive(): + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message) -> None: + if message["type"] == "http.response.start": + start.update(message) + else: + chunks.append(message.get("body", b"")) + + await mcp_asgi(request.scope, receive, send) + response = Response( + content=b"".join(chunks) if chunks else b"", status_code=start["status"] + ) + response.raw_headers.extend(start.get("headers") or []) + return response \ No newline at end of file