"""Сетевые хранилища: 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)
]