diff --git a/README.md b/README.md index 2c723cd..4915f91 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ | `GHARD_DATABASE_PATH` | `/data/hard-panel.db` | путь к SQLite | | `GHARD_PANEL_PORT` | `8000` | host-порт (docker-compose) | | `GHARD_OFFLINE_MULTIPLIER` | `3` | offline = нет пакетов дольше N × interval | +| `GHARD_SHARE_INTERVAL` | `60` | период опроса сетевых хранилищ, сек | ## API (кратко) @@ -212,6 +213,44 @@ - `GET /api/v1/servers/{id}/metrics?since=…&until=…` — история для графиков - `PATCH /api/v1/servers/{id}` — имя / заметка - `DELETE /api/v1/servers/{id}` — удалить сервер (с каскадом метрик) +- `GET/POST/PATCH/DELETE /api/v1/shares[/{id}]` — сетевые хранилища (CRUD) +- `GET /api/v1/shares/{id}/samples?since=…` — история заполнения хранилища + +## Сетевые хранилища (раздел Storage) + +Панель умеет следить за контентом на сетевых накопителях (NAS): хранилище +примонтируется к машине, где работает panel, а панель сама опрашивает его +через `os.statvfs` — отдельно раздел Storage в веб-интерфейсе, точки +заполнения каждые `GHARD_SHARE_INTERVAL` секунд, история за 7 дней. + +Примонтировать SMB/CIFS-шару (Debian/Ubuntu): + +```bash +sudo apt install cifs-utils +# /etc/nas-creds: username=..., password=..., domain=... +sudo mount -t cifs //192.168.1.50/media /mnt/media-nas -o credentials=/etc/nas-creds,uid=1000,iocharset=utf8 +``` + +NFS: + +```bash +sudo apt install nfs-common +sudo mount -t nfs nas.local:/export/media /mnt/media-nas +``` + +Постоянно — через `/etc/fstab`: + +``` +//192.168.1.50/media /mnt/media-nas cifs credentials=/etc/nas-creds,uid=1000,iocharset=utf8,_netdev,nofail 0 0 +nas.local:/export/media /mnt/media-nas nfs defaults,_netdev,nofail 0 0 +``` + +Затем в панели: **Storage → Add share**, имя и путь монтирования +(`/mnt/media-nas`). Статусы: **online** (замер удался), **offline** +(примонтированный путь недоступен — отвалилась сеть/NAS), **waiting** +(данных ещё нет или пробер молчит). Отвал NAS показывается как offline, +данные при этом панель не трогает. `_netdev,nofail` в fstab нужен, чтобы +загрузка панели не зависла, если NAS в момент старта выключен. ## MCP для ИИ-агентов @@ -222,7 +261,7 @@ | Tool | Что отдаёт | | --- | --- | -| `panel_overview` | все серверы одной сводкой: статус, CPU/RAM/диск, сеть, последний пакет | +| `panel_overview` | все серверы одной сводкой: статус, CPU/RAM/диск, сеть, последний пакет; плюс сетевые хранилища с заполнением | | `server_details` | полное состояние сервера: метрики, диски, процессы, docker, заметка | | `server_history` | история метрик за N часов, прореженная до N точек | diff --git a/panel/backend/.env.example b/panel/backend/.env.example index 09f4324..04d83da 100644 --- a/panel/backend/.env.example +++ b/panel/backend/.env.example @@ -8,4 +8,7 @@ GHARD_DATABASE_PATH=/data/hard-panel.db # offline = нет пакетов дольше GHARD_OFFLINE_MULTIPLIER * interval агента -GHARD_OFFLINE_MULTIPLIER=3 \ No newline at end of file +GHARD_OFFLINE_MULTIPLIER=3 + +# период опроса сетевых хранилищ (раздел Storage), сек +GHARD_SHARE_INTERVAL=60 \ No newline at end of file diff --git a/panel/backend/app/api/shares.py b/panel/backend/app/api/shares.py new file mode 100644 index 0000000..9235cd9 --- /dev/null +++ b/panel/backend/app/api/shares.py @@ -0,0 +1,150 @@ +"""Сетевые хранилища: CRUD шард-путей + история заполнения. + +Пути опрашивает shares_probe (lifespan, каждые GHARD_SHARE_INTERVAL сек); +точки лежат в share_samples. Статус: online (свежая ok=1 точка), +offline (свежая ok=0), pending (точек ещё нет). +""" + +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.shares_probe import sample_once + +router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)]) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class ShareCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + path: str = Field(min_length=1, max_length=500) + + +class ShareUpdate(BaseModel): + name: str | None = None + path: str | None = None + + +@router.get("/shares") +async def list_shares() -> list[dict]: + """Все шард-ы + последняя точка заполнения.""" + db = get_db() + cursor = await db.execute( + """SELECT sh.*, p.ts AS p_ts, p.total AS p_total, p.used AS p_used, + p.ok AS p_ok + FROM shares sh + LEFT JOIN share_samples p ON p.id = ( + SELECT MAX(id) FROM share_samples WHERE share_id = sh.id + ) + ORDER BY sh.name""" + ) + rows = await cursor.fetchall() + stale_after = max(get_settings().share_interval * 3, 180) + result = [] + for row in rows: + if row["p_ts"] is None: + status = "pending" + else: + age = (datetime.now(timezone.utc) - datetime.fromisoformat(row["p_ts"])).total_seconds() + if age > stale_after: + status = "pending" # пробер молчит — нет свежих данных + else: + status = "online" if row["p_ok"] else "offline" + result.append( + { + "id": row["id"], + "name": row["name"], + "path": row["path"], + "created_at": row["created_at"], + "status": status, + "last_sample": row["p_ts"], + "total": row["p_total"] if row["p_ok"] else None, + "used": row["p_used"] if row["p_ok"] else None, + } + ) + return result + + +@router.post("/shares", status_code=201) +async def create_share(body: ShareCreate) -> dict: + db = get_db() + cursor = await db.execute( + "INSERT INTO shares (name, path, created_at) VALUES (?, ?, ?)", + (body.name, body.path, _now_iso()), + ) + await db.commit() + # первый замер сразу — не ждём ближайшей волны пробера + try: + await sample_once() + except Exception as exc: # замер доберёт фоновый цикл + print(f"shares probe error (on create): {exc}", flush=True) + return {"id": cursor.lastrowid, "name": body.name, "path": body.path} + + +@router.patch("/shares/{share_id}") +async def update_share(share_id: int, body: ShareUpdate) -> dict: + if body.name is None and body.path is None: + raise HTTPException(status_code=422, detail="nothing to update") + db = get_db() + if body.name is not None: + await db.execute("UPDATE shares SET name = ? WHERE id = ?", (body.name, share_id)) + if body.path is not None: + await db.execute("UPDATE shares SET path = ? WHERE id = ?", (body.path, share_id)) + cursor = await db.execute("SELECT changes()") + (changed,) = await cursor.fetchone() + if not changed: + raise HTTPException(status_code=404, detail="share not found") + await db.commit() + if body.path is not None: # путь сменился — замеряем не дожидаясь волны + try: + await sample_once() + except Exception as exc: + print(f"shares probe error (on update): {exc}", flush=True) + cursor = await db.execute("SELECT id, name, path, created_at FROM shares WHERE id = ?", (share_id,)) + return dict(await cursor.fetchone()) + + +@router.delete("/shares/{share_id}", status_code=204) +async def delete_share(share_id: int) -> None: + db = get_db() + cursor = await db.execute("DELETE FROM shares WHERE id = ?", (share_id,)) + await db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="share not found") + + +@router.get("/shares/{share_id}/samples") +async def share_samples( + share_id: int, + since: datetime | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=5000), +) -> list[dict]: + """История заполнения (для графика). По возрастанию ts.""" + db = get_db() + conditions = ["share_id = ?"] + params: list = [share_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, total, used, ok FROM share_samples WHERE {' AND '.join(conditions)} ORDER BY ts DESC LIMIT ?", + params, + ) + rows = await cursor.fetchall() + return [ + { + "ts": row["ts"], + "total": row["total"], + "used": row["used"], + "ok": bool(row["ok"]), + } + 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 2ffc52f..5907fad 100644 --- a/panel/backend/app/config.py +++ b/panel/backend/app/config.py @@ -15,6 +15,8 @@ admin_token: str = "" # offline = нет данных дольше offline_multiplier * interval агента (сек) offline_multiplier: int = 3 + # как часто панель опрашивает сетевые хранилища (сек) + share_interval: int = 60 @lru_cache diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index f0907f7..d640a8b 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import asynccontextmanager from pathlib import Path @@ -7,9 +8,10 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from app.api import ingest, servers +from app.api import ingest, servers, shares from app.db import close_db, init_db from app.mcp import mcp, mcp_asgi +from app.shares_probe import loop as shares_probe_loop # Собранный Vue-фронт (panel/frontend/dist) попадает сюда в docker-образе; # в dev фронт крутится отдельно (vite, proxy /api → localhost:8000) @@ -19,10 +21,13 @@ @asynccontextmanager async def lifespan(_: FastAPI): await init_db() + # фоновый пробер сетевых хранилищ (share_samples) + probe_task = asyncio.create_task(shares_probe_loop()) # lifespan смонтированных sub-app не вызывается — MCP session manager # стартуем здесь, иначе /mcp отвечает 500 ("Task group is not initialized") async with mcp.session_manager.run(): yield + probe_task.cancel() await close_db() @@ -38,6 +43,7 @@ app.include_router(ingest.router) app.include_router(servers.router) +app.include_router(shares.router) # MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp app.router.routes.append( diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index 71318ad..30faec3 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -12,6 +12,7 @@ from mcp.server.fastmcp import FastMCP from app.api.servers import get_server, list_servers +from app.api.shares import list_shares from app.db import get_db mcp = FastMCP( @@ -60,6 +61,18 @@ else: status = s['status'] lines.append(f"#{s['id']} {s['name']} [{status}] — данных ещё нет") + shares = await list_shares() + if shares: + lines.append(f"Сетевые хранилища: {len(shares)}") + for sh in shares: + if sh["total"]: + percent = sh["used"] / sh["total"] * 100 + lines.append( + f"#{sh['id']} {sh['name']} [{sh['status']}] " + f"{sh['used'] / 2**30:.1f}/{sh['total'] / 2**30:.1f} GiB ({percent:.0f}%), {sh['path']}" + ) + else: + lines.append(f"#{sh['id']} {sh['name']} [{sh['status']}] — нет данных, {sh['path']}") return "\n".join(lines) diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index b936ebb..fbf2e81 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -51,4 +51,24 @@ 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 +CREATE INDEX IF NOT EXISTS idx_events_server_ts ON events(server_id, ts); + +-- Сетевые хранилища: шард-пути, примонтированные к машине с hard-panel. +-- Панель сама опрашивает их os.statvfs (share_samples.ok=0 — путь недоступен). +CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS share_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + ts TEXT NOT NULL, + total INTEGER NOT NULL DEFAULT 0, -- байты (statvfs f_blocks * f_frsize) + used INTEGER NOT NULL DEFAULT 0, -- байты (total - f_bfree) + ok INTEGER NOT NULL DEFAULT 1 -- 1 = путь примонтирован и читается +); + +CREATE INDEX IF NOT EXISTS idx_share_samples_share_ts ON share_samples(share_id, ts); \ No newline at end of file diff --git a/panel/backend/app/shares_probe.py b/panel/backend/app/shares_probe.py new file mode 100644 index 0000000..ddaa6c0 --- /dev/null +++ b/panel/backend/app/shares_probe.py @@ -0,0 +1,60 @@ +"""Фоновый пробер сетевых хранилищ. + +Панель сама опрашивает примонтированные к её машине шарды (os.statvfs): +раз в GHARD_SHARE_INTERVAL секунд пишет точку в share_samples. Путь не +читается (отвалился NFS/SMB, сеть лежит) — пишется ok=0, панель покажет +«offline». Старше 7 дней точки удаляются (политика хранения — этап 5). +""" + +import asyncio +import os +from datetime import datetime, timedelta, timezone + +from app.config import get_settings +from app.db import get_db + +RETENTION_DAYS = 7 + + +def _probe(path: str) -> tuple[int, int, int]: + """(total, used, ok) в байтах; недоступный путь → (0, 0, 0).""" + try: + st = os.statvfs(path) + except OSError: + return 0, 0, 0 + total = st.f_blocks * st.f_frsize + free = st.f_bfree * st.f_frsize + return total, total - free, 1 + + +async def sample_once() -> int: + """Одна волна проб по всем шардам. Возвращает число точек.""" + db = get_db() + cursor = await db.execute("SELECT id, path FROM shares") + rows = await cursor.fetchall() + if not rows: + return 0 + now = datetime.now(timezone.utc).isoformat() + points = 0 + for row in rows: + total, used, ok = _probe(row["path"]) + await db.execute( + "INSERT INTO share_samples (share_id, ts, total, used, ok) VALUES (?, ?, ?, ?, ?)", + (row["id"], now, total, used, ok), + ) + points += 1 + # политика хранения: точки шардов старше 7 дней не нужны + cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat() + await db.execute("DELETE FROM share_samples WHERE ts < ?", (cutoff,)) + await db.commit() + return points + + +async def loop() -> None: + interval = get_settings().share_interval + while True: + try: + await sample_once() + except Exception as exc: # не роняем цикл из-за одной ошибки + print(f"shares 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 5f53346..b83f973 100644 --- a/panel/frontend/src/App.vue +++ b/panel/frontend/src/App.vue @@ -3,6 +3,7 @@ const navItems = [ { label: 'Dashboard', icon: 'ph-squares-four', to: '/' }, + { label: 'Storage', icon: 'ph-hard-drives', to: '/storage' }, ] diff --git a/panel/frontend/src/api.js b/panel/frontend/src/api.js index 15d5f2c..362b79d 100644 --- a/panel/frontend/src/api.js +++ b/panel/frontend/src/api.js @@ -44,4 +44,16 @@ params.set('limit', String(limit)) return request(`/servers/${id}/metrics?${params}`) }, + shares: () => request('/shares'), + createShare: (name, path) => + request('/shares', { method: 'POST', body: { name, path } }), + updateShare: (id, fields) => + request(`/shares/${id}`, { method: 'PATCH', body: fields }), + deleteShare: (id) => request(`/shares/${id}`, { method: 'DELETE' }), + shareSamples: (id, since, limit = 1000) => { + const params = new URLSearchParams() + if (since) params.set('since', since.toISOString()) + params.set('limit', String(limit)) + return request(`/shares/${id}/samples?${params}`) + }, } \ No newline at end of file diff --git a/panel/frontend/src/components/ChartLine.vue b/panel/frontend/src/components/ChartLine.vue index 115bd66..4200482 100644 --- a/panel/frontend/src/components/ChartLine.vue +++ b/panel/frontend/src/components/ChartLine.vue @@ -31,7 +31,8 @@ borderColor: ds.color, backgroundColor: ds.color + '26', borderWidth: 2, - pointRadius: 0, + // мало точек — линии нет, показываем точку, чтобы график не был пустым + pointRadius: (ctx) => (ctx.dataset.data.length < 3 ? 3 : 0), pointHoverRadius: 5, fill: Boolean(ds.fill), tension: 0.25, diff --git a/panel/frontend/src/pages/StoragePage.vue b/panel/frontend/src/pages/StoragePage.vue new file mode 100644 index 0000000..9a6be21 --- /dev/null +++ b/panel/frontend/src/pages/StoragePage.vue @@ -0,0 +1,295 @@ + + + + + \ No newline at end of file diff --git a/panel/frontend/src/router.js b/panel/frontend/src/router.js index f52b09e..ecb08f7 100644 --- a/panel/frontend/src/router.js +++ b/panel/frontend/src/router.js @@ -9,6 +9,11 @@ component: () => import('./pages/DashboardPage.vue'), }, { + path: '/storage', + name: 'storage', + component: () => import('./pages/StoragePage.vue'), + }, + { path: '/servers/:id', name: 'server', component: () => import('./pages/ServerPage.vue'),