diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f470d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.venv/ +.env +*.db +*.db-wal +*.db-shm +data/ +node_modules/ +dist/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2297e97 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# GHard Monitor + +**Gnexus Hardware Monitor** — сбор мониторинга всех серверов в одном месте. + +``` +┌───────────────┐ POST /api/v1/ingest ┌────────────────────┐ +│ hard-monitor │ ─────────────────────► │ hard-panel │ +│ (агент, │ метрики + ключ │ FastAPI + SQLite │ +│ stateless) │ │ + Vue UI │ +└───────────────┘ └────────────────────┘ +``` + +- **panel/** — панель: приём метрик, SQLite, ивенты, алерты, веб-интерфейс + (Vue 3 + [gnexus-ui-kit](https://git.gnexus.space/root/gnexus-ui-kit) + chart.js) +- **monitor/** — агент на серверах: psutil-сбор фактов, POST на панель. + Агент stateless, вся логика (пороги, ивенты, скорость сети по дельте + счётчиков) — на панели. + +## Стек + +| Часть | Технологии | +|---|---| +| panel backend | Python 3.12, FastAPI, aiosqlite (SQLite, WAL) | +| panel frontend | Vue 3, gnexus-ui-kit, chart.js | +| monitor | Python 3 (psutil, requests), systemd | +| Деплой | Docker / docker-compose | + +## Быстрый старт (panel) + +```bash +cp panel/backend/.env.example .env # задай GHARD_ADMIN_TOKEN +docker compose up -d --build +``` + +API: `http://localhost:8000/docs` (OpenAPI) + +## API (кратко) + +- `POST /api/v1/servers` — создать сервер, получить ключ агента (показывается один раз) +- `POST /api/v1/ingest` — пакет метрик агента, заголовок `X-Server-Key` +- `GET /api/v1/servers` — сводка для дашборда +- `GET /api/v1/servers/{id}/metrics?since=…&until=…` — история для графиков +- `PATCH /api/v1/servers/{id}` — имя / заметка + +## Статус + +- [x] Этап 1 — скелет: FastAPI, SQLite-схема, ingest, servers API, docker +- [ ] Этап 2 — hard-monitor агент + install.sh +- [ ] Этап 3 — веб-интерфейс (Vue + ui-kit + chart.js) +- [ ] Этап 4 — ивенты + алерты (Telegram, webhook) +- [ ] Этап 5 — downsample истории (7 дней), финальный деплой \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e75813a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + panel: + build: panel/backend + container_name: ghard-panel + ports: + - "${GHARD_PANEL_PORT:-8000}:8000" + environment: + # задай свой токен: `cp panel/backend/.env.example .env` и поправь + GHARD_ADMIN_TOKEN: ${GHARD_ADMIN_TOKEN:-change-me} + volumes: + - panel-data:/data + restart: unless-stopped + +volumes: + panel-data: \ No newline at end of file diff --git a/monitor/README.md b/monitor/README.md new file mode 100644 index 0000000..030d09c --- /dev/null +++ b/monitor/README.md @@ -0,0 +1,7 @@ +# hard-monitor + +Агент GHard Monitor для серверов. Stateless: только собирает факты +(CPU, RAM, диски, сеть, топ процессов, docker) и POST-ит их на панель +по `PANEL_URL` с ключом `SERVER_KEY` из `.env`. + +Реализация — этап 2 (см. корневой README). \ No newline at end of file diff --git a/panel/backend/.env.example b/panel/backend/.env.example new file mode 100644 index 0000000..17bfdad --- /dev/null +++ b/panel/backend/.env.example @@ -0,0 +1,10 @@ +# GHard Monitor panel — настройки (env-префикс GHARD_) + +# Токен админ-доступа к API панели (сгенерируй: openssl rand -hex 32) +GHARD_ADMIN_TOKEN=change-me + +# Путь к SQLite-файлу (в docker — volume /data) +GHARD_DATABASE_PATH=/data/hard-panel.db + +# offline = нет пакетов дольше GHARD_OFFLINE_MULTIPLIER * interval агента +GHARD_OFFLINE_MULTIPLIER=3 \ No newline at end of file diff --git a/panel/backend/Dockerfile b/panel/backend/Dockerfile new file mode 100644 index 0000000..b9ad953 --- /dev/null +++ b/panel/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir . + +COPY app ./app + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/panel/backend/app/__init__.py b/panel/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/panel/backend/app/__init__.py diff --git a/panel/backend/app/api/__init__.py b/panel/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/panel/backend/app/api/__init__.py diff --git a/panel/backend/app/api/ingest.py b/panel/backend/app/api/ingest.py new file mode 100644 index 0000000..98b4ed9 --- /dev/null +++ b/panel/backend/app/api/ingest.py @@ -0,0 +1,145 @@ +"""POST /api/v1/ingest — приём пакета метрик от hard-monitor. + +Панель — мозг: здесь считается скорость сети (дельта сырых счётчиков), +обновляется карточка сервера и пишется точка метрик. Ивенты (пороги, +diff дисков/docker, offline) появятся на этапе 4 — тоже здесь. +""" + +import json +from datetime import datetime, timezone + +from fastapi import APIRouter, Header, HTTPException, Request + +from app.db import get_db, j +from app.models import IngestPayload +from app.security import hash_key + +router = APIRouter(prefix="/api/v1") + +MIB = 1024 * 1024 +# «Онлайн», если последний пакет был не позже чем interval * множитель + запас +OFFLINE_GRACE_SEC = 60 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _client_ip(request: Request, x_forwarded_for: str) -> str: + if x_forwarded_for: + return x_forwarded_for.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _calc_net_rates( + payload: IngestPayload, prev_ts: datetime | None, prev_counters: dict[str, tuple[int, int]], now: datetime +) -> tuple[float, float]: + """МБ/с по дельте счётчиков между пакетами. + + Счётчики psutil живут с загрузки ОС и не сбрасываются рестартом агента, + поэтому дельта на стороне panel всегда валидна. Отрицательная дельта + (обнуление после ребута) → 0. + """ + dt = (now - prev_ts).total_seconds() if prev_ts else 0 + if dt <= 0 or not prev_counters: + return 0.0, 0.0 + + in_total = 0.0 + out_total = 0.0 + for iface in payload.net: + if iface.iface == "lo" or iface.iface not in prev_counters: + continue + prev_sent, prev_recv = prev_counters[iface.iface] + if iface.bytes_recv >= prev_recv: + in_total += (iface.bytes_recv - prev_recv) / dt / MIB + if iface.bytes_sent >= prev_sent: + out_total += (iface.bytes_sent - prev_sent) / dt / MIB + return round(in_total, 3), round(out_total, 3) + + +@router.post("/ingest") +async def ingest( + payload: IngestPayload, + request: Request, + x_server_key: str = Header(alias="X-Server-Key"), + x_forwarded_for: str = Header(default="", alias="X-Forwarded-For"), +) -> dict: + db = get_db() + cursor = await db.execute( + "SELECT id FROM servers WHERE key_hash = ?", (hash_key(x_server_key),) + ) + server = await cursor.fetchone() + if server is None: + raise HTTPException(status_code=401, detail="unknown server key") + server_id = server["id"] + + now = _now() + packet_ts = _iso(payload.ts) if payload.ts else _iso(now) + + # предыдущая точка — нужны сырые счётчики для дельты сети + cursor = await db.execute( + "SELECT ts, net_json FROM metrics WHERE server_id = ? ORDER BY id DESC LIMIT 1", + (server_id,), + ) + prev = await cursor.fetchone() + prev_ts = None + prev_counters: dict[str, tuple[int, int]] = {} + if prev is not None: + try: + prev_ts = datetime.fromisoformat(prev["ts"]) + for iface in json.loads(prev["net_json"]): + prev_counters[iface["iface"]] = (iface["bytes_sent"], iface["bytes_recv"]) + except (ValueError, KeyError, TypeError, json.JSONDecodeError): + prev_ts = None + prev_counters = {} + + net_in_mbs, net_out_mbs = _calc_net_rates(payload, prev_ts, prev_counters, now) + + # обновить карточку сервера (факты из пакета) + await db.execute( + """UPDATE servers SET hostname = ?, os = ?, kernel = ?, ips_json = ?, + source_ip = ?, interval = ?, last_seen = ? WHERE id = ?""", + ( + payload.hostname, + payload.os, + payload.kernel, + j(payload.ips), + _client_ip(request, x_forwarded_for), + payload.interval, + _iso(now), + server_id, + ), + ) + + # точка метрик; net_json — сырые счётчики, из них будет считаться дельта + await db.execute( + """INSERT INTO metrics (server_id, ts, cpu, load1, load5, load15, + ram_used, ram_total, swap_used, swap_total, uptime, + net_in_mbs, net_out_mbs, disks_json, net_json, processes_json, + docker_json, extra_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + server_id, + packet_ts, + payload.cpu.percent, + *(payload.cpu.load + [0.0] * (3 - len(payload.cpu.load))), + payload.memory.ram.used, + payload.memory.ram.total, + payload.memory.swap.used, + payload.memory.swap.total, + payload.uptime, + net_in_mbs, + net_out_mbs, + j([d.model_dump() for d in payload.disks]), + j([n.model_dump() for n in payload.net]), + j([p.model_dump() for p in payload.processes]), + j([c.model_dump() for c in payload.docker]), + j(payload.extra), + ), + ) + await db.commit() + return {"status": "ok"} \ No newline at end of file diff --git a/panel/backend/app/api/servers.py b/panel/backend/app/api/servers.py new file mode 100644 index 0000000..32463aa --- /dev/null +++ b/panel/backend/app/api/servers.py @@ -0,0 +1,208 @@ +"""Servers API: ключи агентов, карточки серверов, история метрик. + +Этап 1: доступ по Bearer GHARD_ADMIN_TOKEN. На этапе 3 (веб-интерфейс) +заменяем на JWT-сессию. +""" + +import json +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.config import get_settings +from app.db import get_db, j +from app.models import ServerCreate, ServerUpdate +from app.security import generate_server_key, hash_key, require_admin + +router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)]) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _server_status(server, interval: int) -> str: + """online/offline/pending — по возрасту last_seen против interval агента.""" + if not server["last_seen"]: + return "pending" + threshold = max(server["interval"] * get_settings().offline_multiplier, 120) + age = datetime.now(timezone.utc) - datetime.fromisoformat(server["last_seen"]) + return "online" if age.total_seconds() <= threshold + 60 else "offline" + + +def _server_dict(server) -> dict: + return { + "id": server["id"], + "name": server["name"], + "hostname": server["hostname"], + "os": server["os"], + "kernel": server["kernel"], + "ips": json.loads(server["ips_json"]), + "source_ip": server["source_ip"], + "note": server["note"], + "interval": server["interval"], + "created_at": server["created_at"], + "last_seen": server["last_seen"], + "status": _server_status(server, server["interval"]), + } + + +@router.post("/servers", status_code=201) +async def create_server(body: ServerCreate) -> dict: + """Создать сервер. Plaintext-ключ возвращается ТОЛЬКО здесь, один раз.""" + key = generate_server_key() + db = get_db() + cursor = await db.execute( + """INSERT INTO servers (name, hostname, key_hash, interval, created_at) + VALUES (?, ?, ?, ?, ?)""", + (body.name, body.hostname, hash_key(key), body.interval, _now_iso()), + ) + await db.commit() + return {"id": cursor.lastrowid, "name": body.name, "key": key} + + +@router.get("/servers") +async def list_servers() -> list[dict]: + """Сводка по всем серверам + последняя точка метрик (для дашборда).""" + db = get_db() + cursor = await db.execute( + """SELECT s.*, m.cpu, m.load1, m.load5, m.load15, m.ram_used, m.ram_total, + m.swap_used, m.swap_total, m.uptime, m.net_in_mbs, m.net_out_mbs, + m.disks_json + FROM servers s + LEFT JOIN metrics m ON m.id = ( + SELECT MAX(id) FROM metrics WHERE server_id = s.id + ) + ORDER BY s.name""" + ) + rows = await cursor.fetchall() + result = [] + for server in rows: + item = _server_dict(server) + item.update( + { + "metrics": None + if server["cpu"] is None + else { + "cpu": server["cpu"], + "load": [server["load1"], server["load5"], server["load15"]], + "ram": { + "used": server["ram_used"], + "total": server["ram_total"], + }, + "swap": { + "used": server["swap_used"], + "total": server["swap_total"], + }, + "uptime": server["uptime"], + "net_in_mbs": server["net_in_mbs"], + "net_out_mbs": server["net_out_mbs"], + "disks": json.loads(server["disks_json"]), + } + } + ) + result.append(item) + return result + + +@router.get("/servers/{server_id}") +async def get_server(server_id: int) -> dict: + db = get_db() + cursor = await db.execute("SELECT * FROM servers WHERE id = ?", (server_id,)) + server = await cursor.fetchone() + if server is None: + raise HTTPException(status_code=404, detail="server not found") + item = _server_dict(server) + cursor = await db.execute( + "SELECT * FROM metrics WHERE server_id = ? ORDER BY id DESC LIMIT 1", + (server_id,), + ) + metrics = await cursor.fetchone() + if metrics is not None: + item["metrics"] = { + "ts": metrics["ts"], + "cpu": metrics["cpu"], + "load": [metrics["load1"], metrics["load5"], metrics["load15"]], + "ram": {"used": metrics["ram_used"], "total": metrics["ram_total"]}, + "swap": {"used": metrics["swap_used"], "total": metrics["swap_total"]}, + "uptime": metrics["uptime"], + "net_in_mbs": metrics["net_in_mbs"], + "net_out_mbs": metrics["net_out_mbs"], + "disks": json.loads(metrics["disks_json"]), + "processes": json.loads(metrics["processes_json"]), + "docker": json.loads(metrics["docker_json"]), + "extra": json.loads(metrics["extra_json"]), + } + else: + item["metrics"] = None + return item + + +@router.patch("/servers/{server_id}") +async def update_server(server_id: int, body: ServerUpdate) -> dict: + """Пока меняем только то, что редактируется в UI: имя и заметку.""" + if body.name is None and body.note is None: + raise HTTPException(status_code=422, detail="nothing to update") + db = get_db() + if body.name is not None: + await db.execute("UPDATE servers SET name = ? WHERE id = ?", (body.name, server_id)) + if body.note is not None: + await db.execute("UPDATE servers SET note = ? WHERE id = ?", (body.note, server_id)) + cursor = await db.execute("SELECT changes()") + (changed,) = await cursor.fetchone() + if not changed: + raise HTTPException(status_code=404, detail="server not found") + await db.commit() + cursor = await db.execute("SELECT * FROM servers WHERE id = ?", (server_id,)) + return _server_dict(await cursor.fetchone()) + + +@router.delete("/servers/{server_id}", status_code=204) +async def delete_server(server_id: int) -> None: + db = get_db() + cursor = await db.execute("DELETE FROM servers WHERE id = ?", (server_id,)) + await db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="server not found") + + +@router.get("/servers/{server_id}/metrics") +async def server_metrics( + server_id: int, + since: datetime | None = Query(default=None), + until: datetime | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=5000), +) -> list[dict]: + """История метрик (графики): время, cpu, ram, сеть. По возрастанию ts.""" + db = get_db() + conditions = ["server_id = ?"] + params: list = [server_id] + if since is not None: + conditions.append("ts >= ?") + params.append(since.astimezone(timezone.utc).isoformat()) + if until is not None: + conditions.append("ts <= ?") + params.append(until.astimezone(timezone.utc).isoformat()) + params.append(limit) + cursor = await db.execute( + f"""SELECT ts, cpu, load1, load5, load15, ram_used, ram_total, + swap_used, swap_total, uptime, net_in_mbs, net_out_mbs, disks_json + FROM metrics WHERE {' AND '.join(conditions)} + ORDER BY ts DESC LIMIT ?""", + params, + ) + rows = await cursor.fetchall() + return [ + { + "ts": row["ts"], + "cpu": row["cpu"], + "load": [row["load1"], row["load5"], row["load15"]], + "ram": {"used": row["ram_used"], "total": row["ram_total"]}, + "swap": {"used": row["swap_used"], "total": row["swap_total"]}, + "uptime": row["uptime"], + "net_in_mbs": row["net_in_mbs"], + "net_out_mbs": row["net_out_mbs"], + "disks": json.loads(row["disks_json"]), + } + 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 new file mode 100644 index 0000000..662650a --- /dev/null +++ b/panel/backend/app/config.py @@ -0,0 +1,20 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Настройки panel, всё через env с префиксом GHARD_ (см. .env.example).""" + + model_config = SettingsConfigDict(env_prefix="GHARD_", env_file=".env", extra="ignore") + + database_path: Path = Path("/data/hard-panel.db") + admin_token: str = "change-me" + # offline = нет данных дольше offline_multiplier * interval агента (сек) + offline_multiplier: int = 3 + + +@lru_cache +def get_settings() -> Settings: + return Settings() \ No newline at end of file diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py new file mode 100644 index 0000000..e53401c --- /dev/null +++ b/panel/backend/app/db.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path + +import aiosqlite + +from app.config import get_settings + +_conn: aiosqlite.Connection | None = None + + +async def init_db() -> None: + """Открыть БД, включить WAL, применить schema.sql.""" + global _conn + settings = get_settings() + settings.database_path.parent.mkdir(parents=True, exist_ok=True) + _conn = await aiosqlite.connect(settings.database_path) + _conn.row_factory = aiosqlite.Row + await _conn.execute("PRAGMA journal_mode=WAL") + await _conn.execute("PRAGMA foreign_keys=ON") + schema = (Path(__file__).with_name("schema.sql")).read_text(encoding="utf-8") + await _conn.executescript(schema) + await _conn.commit() + + +async def close_db() -> None: + global _conn + if _conn is not None: + await _conn.close() + _conn = None + + +def get_db() -> aiosqlite.Connection: + assert _conn is not None, "database not initialized" + return _conn + + +def j(value) -> str: + """Сериализация JSON-колонок.""" + return json.dumps(value, ensure_ascii=False) \ No newline at end of file diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py new file mode 100644 index 0000000..ac523f2 --- /dev/null +++ b/panel/backend/app/main.py @@ -0,0 +1,33 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import ingest, servers +from app.db import close_db, init_db + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await init_db() + yield + await close_db() + + +app = FastAPI(title="GHard Monitor API", version="0.1.0", lifespan=lifespan) + +# В проде фронт раздаётся тем же origin; CORS нужен для vite dev-сервера (этап 3) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ingest.router) +app.include_router(servers.router) + + +@app.get("/api/v1/health") +async def health() -> dict: + return {"status": "ok"} \ No newline at end of file diff --git a/panel/backend/app/models.py b/panel/backend/app/models.py new file mode 100644 index 0000000..5ba4889 --- /dev/null +++ b/panel/backend/app/models.py @@ -0,0 +1,85 @@ +"""Pydantic-модели: ingest-пакет агента и запросы к servers API.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +# --- Пакет hard-monitor ------------------------------------------------------ + +class CpuInfo(BaseModel): + percent: float = 0.0 + count: int = 0 + load: list[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) + + +class RamInfo(BaseModel): + total: int = 0 + used: int = 0 + available: int = 0 + + +class SwapInfo(BaseModel): + total: int = 0 + used: int = 0 + + +class MemoryInfo(BaseModel): + ram: RamInfo = Field(default_factory=RamInfo) + swap: SwapInfo = Field(default_factory=SwapInfo) + + +class DiskInfo(BaseModel): + mount: str + total: int = 0 + used: int = 0 + percent: float = 0.0 + + +class NetIfaceInfo(BaseModel): + iface: str + bytes_sent: int = 0 # сырой счётчик с момента загрузки ОС + bytes_recv: int = 0 + + +class ProcessInfo(BaseModel): + name: str = "" + pid: int = 0 + cpu: float = 0.0 + mem: float = 0.0 # % RAM + + +class DockerContainerInfo(BaseModel): + name: str + image: str = "" + status: str = "" + + +class IngestPayload(BaseModel): + hostname: str + os: str = "" + kernel: str = "" + ips: list[str] = Field(default_factory=list) + interval: int = 30 + ts: datetime | None = None # если нет — panel ставит своё время + cpu: CpuInfo = Field(default_factory=CpuInfo) + memory: MemoryInfo = Field(default_factory=MemoryInfo) + disks: list[DiskInfo] = Field(default_factory=list) + uptime: int = 0 + net: list[NetIfaceInfo] = Field(default_factory=list) + processes: list[ProcessInfo] = Field(default_factory=list) + docker: list[DockerContainerInfo] = Field(default_factory=list) + extra: dict = Field(default_factory=dict) + + +# --- Servers API ------------------------------------------------------------ + +class ServerCreate(BaseModel): + name: str + hostname: str = "" + interval: int = 30 + + +class ServerUpdate(BaseModel): + name: str | None = None + note: str | None = None \ No newline at end of file diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql new file mode 100644 index 0000000..b936ebb --- /dev/null +++ b/panel/backend/app/schema.sql @@ -0,0 +1,54 @@ +-- GHard Monitor: схема SQLite. +-- Время — UTC ISO-8601 (строки, лексикографически сортируются корректно). + +CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, -- отображаемое имя, задаётся в UI + hostname TEXT NOT NULL DEFAULT '', -- как назвался агент + os TEXT NOT NULL DEFAULT '', + kernel TEXT NOT NULL DEFAULT '', + ips_json TEXT NOT NULL DEFAULT '[]', -- адреса интерфейсов из пакета агента + source_ip TEXT NOT NULL DEFAULT '', -- адрес входящего соединения + note TEXT NOT NULL DEFAULT '', -- заметка: одна на сервер, edit в UI + key_hash TEXT NOT NULL UNIQUE, -- sha256 ключа агента (plaintext нигде не храним) + interval INTEGER NOT NULL DEFAULT 30, -- интервал агента, сек (для детекта offline) + created_at TEXT NOT NULL, + last_seen TEXT -- время последнего ingest (по часам panel) +); + +CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + ts TEXT NOT NULL, -- timestamp пакета (агент или panel) + cpu REAL NOT NULL DEFAULT 0, -- cpu, % + load1 REAL NOT NULL DEFAULT 0, + load5 REAL NOT NULL DEFAULT 0, + load15 REAL NOT NULL DEFAULT 0, + ram_used INTEGER NOT NULL DEFAULT 0, + ram_total INTEGER NOT NULL DEFAULT 0, + swap_used INTEGER NOT NULL DEFAULT 0, + swap_total INTEGER NOT NULL DEFAULT 0, + uptime INTEGER NOT NULL DEFAULT 0, -- сек + net_in_mbs REAL NOT NULL DEFAULT 0, -- суммарный вход, МБ/с (panel считает по дельте) + net_out_mbs REAL NOT NULL DEFAULT 0, -- суммарный выход, МБ/с + disks_json TEXT NOT NULL DEFAULT '[]', + net_json TEXT NOT NULL DEFAULT '[]', -- сырые счётчики интерфейсов — для следующей дельты + processes_json TEXT NOT NULL DEFAULT '[]', + docker_json TEXT NOT NULL DEFAULT '[]', + extra_json TEXT NOT NULL DEFAULT '{}' -- temps, smart и прочее, что panel пока не разбирает +); + +CREATE INDEX IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, ts); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE, + type TEXT NOT NULL, -- cpu_high, server_offline, disk_added, ... + severity TEXT NOT NULL DEFAULT 'info', -- info | warning | critical + message TEXT NOT NULL, + data_json TEXT NOT NULL DEFAULT '{}', + ts TEXT NOT NULL, + acknowledged INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_events_server_ts ON events(server_id, ts); \ No newline at end of file diff --git a/panel/backend/app/security.py b/panel/backend/app/security.py new file mode 100644 index 0000000..edf6c0a --- /dev/null +++ b/panel/backend/app/security.py @@ -0,0 +1,33 @@ +import hashlib +import secrets + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.config import get_settings + +_bearer = HTTPBearer(auto_error=False) + + +def generate_server_key() -> str: + """Plaintext-ключ агента. Показывается один раз при создании сервера.""" + return "ghm_" + secrets.token_urlsafe(24) + + +def hash_key(key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest() + + +async def require_admin( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> None: + """Админ-доступ к API панели (Bearer GHARD_ADMIN_TOKEN). + + TODO (этап 3): заменить на JWT-логин из веб-интерфейса. + """ + token = get_settings().admin_token + if creds is None or creds.scheme.lower() != "bearer" or creds.credentials != token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid admin token", + ) \ No newline at end of file diff --git a/panel/backend/pyproject.toml b/panel/backend/pyproject.toml new file mode 100644 index 0000000..8e385ca --- /dev/null +++ b/panel/backend/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "ghard-monitor-panel" +version = "0.1.0" +description = "GHard Monitor (Gnexus Hardware Monitor) — backend панели мониторинга серверов" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "aiosqlite>=0.20", + "pydantic>=2.7", + "pydantic-settings>=2.3", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] \ No newline at end of file