This commit is contained in:
2026-05-17 20:54:53 +05:00
parent 65a0babeab
commit 27eb4fd606
90 changed files with 12343 additions and 0 deletions
View File
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from ..core.db import get_db
from ..core.security import decode_token
from ..models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
try:
payload = decode_token(token)
except ValueError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
if payload.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong token type")
user_id = payload.get("sub")
user = db.query(User).filter(User.id == int(user_id)).first() if user_id else None
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
return user
def require_role(*roles: str):
def _checker(user: User = Depends(get_current_user)) -> User:
if roles and user.role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient permissions")
return user
return _checker
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from fastapi import APIRouter
from .v1 import alerts as alerts_router
from .v1 import auth as auth_router
from .v1 import backups as backups_router
from .v1 import cli as cli_router
from .v1 import controller_backup as controller_backup_router
from .v1 import devices as devices_router
from .v1 import firmware as firmware_router
from .v1 import health as health_router
from .v1 import metrics as metrics_router
from .v1 import settings as settings_router
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health_router.router, tags=["health"])
api_router.include_router(auth_router.router, prefix="/auth", tags=["auth"])
api_router.include_router(devices_router.router, prefix="/devices", tags=["devices"])
api_router.include_router(backups_router.router, tags=["backups"])
api_router.include_router(firmware_router.router, prefix="/firmware", tags=["firmware"])
api_router.include_router(alerts_router.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(metrics_router.router, tags=["metrics"])
api_router.include_router(cli_router.router, prefix="/cli", tags=["cli"])
api_router.include_router(controller_backup_router.router, prefix="/controller/backup", tags=["controller"])
api_router.include_router(settings_router.router, prefix="/settings", tags=["settings"])
View File
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...models.alert import Alert
from ...models.user import User
from ...schemas.alert import AlertOut
from ..deps import get_current_user, require_role
router = APIRouter()
@router.get("", response_model=list[AlertOut])
def list_alerts(
only_unack: bool = False,
limit: int = 200,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Alert]:
q = db.query(Alert)
if only_unack:
q = q.filter(Alert.acknowledged.is_(False))
return q.order_by(Alert.created_at.desc()).limit(limit).all()
@router.get("/unread-count")
def unread_count(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict[str, int]:
n = db.query(Alert).filter(Alert.acknowledged.is_(False)).count()
return {"count": n}
@router.post("/{alert_id}/ack", response_model=AlertOut)
def acknowledge(
alert_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Alert:
a = db.get(Alert, alert_id)
if not a:
raise HTTPException(status.HTTP_404_NOT_FOUND, "alert not found")
a.acknowledged = True
db.commit()
db.refresh(a)
return a
@router.post("/ack-all")
def acknowledge_all(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict[str, int]:
n = db.query(Alert).filter(Alert.acknowledged.is_(False)).update({"acknowledged": True})
db.commit()
return {"updated": n}
@router.delete("/{alert_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_alert(
alert_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> Response:
a = db.get(Alert, alert_id)
if not a:
raise HTTPException(status.HTTP_404_NOT_FOUND, "alert not found")
db.delete(a)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("")
def purge_alerts(
only_acked: bool = False,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict:
"""Очистить лог алертов. По умолчанию удаляет всё; only_acked=true — только прочитанные."""
q = db.query(Alert)
if only_acked:
q = q.filter(Alert.acknowledged == True) # noqa: E712
n = q.delete(synchronize_session=False)
db.commit()
return {"deleted": int(n or 0)}
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ...core.db import get_db
from pydantic import BaseModel
from ...core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from ...models.user import User
from ...schemas.auth import LoginIn, RefreshIn, TokenPair, UserOut
from ...services.events import add_audit
from ..deps import get_current_user
class ChangePasswordIn(BaseModel):
current: str
new: str
router = APIRouter()
def _issue(user: User) -> TokenPair:
return TokenPair(
access_token=create_access_token(user.id, extra={"role": user.role}),
refresh_token=create_refresh_token(user.id),
)
def _client_ip(req: Request) -> str | None:
fwd = req.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()
return req.client.host if req.client else None
@router.post("/login", response_model=TokenPair)
def login_json(payload: LoginIn, request: Request, db: Session = Depends(get_db)) -> TokenPair:
user = db.query(User).filter(User.email == payload.email).first()
ip = _client_ip(request)
if not user or not verify_password(payload.password, user.hashed_password):
add_audit(db, actor=payload.email, action="login.fail", ip=ip, detail="invalid credentials")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
if not user.is_active:
add_audit(db, actor=payload.email, action="login.fail", ip=ip, detail="user disabled")
raise HTTPException(status.HTTP_403_FORBIDDEN, "user disabled")
add_audit(db, actor=user.email, action="login.success", ip=ip)
return _issue(user)
@router.post("/login/form", response_model=TokenPair, include_in_schema=False)
def login_form(
request: Request,
form: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
) -> TokenPair:
"""Совместимость со Swagger «Authorize»."""
user = db.query(User).filter(User.email == form.username).first()
ip = _client_ip(request)
if not user or not verify_password(form.password, user.hashed_password):
add_audit(db, actor=form.username, action="login.fail", ip=ip, detail="invalid credentials")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
add_audit(db, actor=user.email, action="login.success", ip=ip)
return _issue(user)
@router.post("/refresh", response_model=TokenPair)
def refresh(payload: RefreshIn, db: Session = Depends(get_db)) -> TokenPair:
try:
data = decode_token(payload.refresh_token)
except ValueError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
if data.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong token type")
user = db.query(User).filter(User.id == int(data["sub"])).first()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return _issue(user)
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(get_current_user)) -> User:
return user
@router.post("/change-password")
def change_password(
payload: ChangePasswordIn,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, bool]:
if not verify_password(payload.current, user.hashed_password):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Текущий пароль неверный")
if len(payload.new) < 4:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Новый пароль слишком короткий")
user.hashed_password = hash_password(payload.new)
db.commit()
return {"ok": True}
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...core.security import decrypt_secret
from ...models.backup import DeviceBackup
from ...models.device import Device
from ...models.user import User
from ...schemas.backup import BackupOut
from ...services.routeros.backup import create_and_download_backup
from ...services.routeros.client import RouterOSCredentials, RouterOSError
from ...services.backup_ftp_server import detect_push_host
from ...core.config import get_settings
from ..deps import get_current_user, require_role
router = APIRouter()
MAX_BACKUPS_PER_DEVICE = 10
def _creds(d: Device) -> RouterOSCredentials:
return RouterOSCredentials(
host=d.host,
username=d.username,
password=decrypt_secret(d.password_enc),
port=d.port,
use_tls=d.use_tls,
timeout=15.0,
)
def _rotate(db: Session, device_id: int) -> None:
"""Удаляет старые записи, если их больше MAX_BACKUPS_PER_DEVICE.
Считаем по уникальному base_name (.backup и .rsc — одна пара)."""
rows = (
db.query(DeviceBackup)
.filter(DeviceBackup.device_id == device_id)
.order_by(DeviceBackup.created_at.desc())
.all()
)
seen: set[str] = set()
keep_ids: set[int] = set()
for r in rows:
base = r.filename.rsplit(".", 1)[0]
if base in seen or len(seen) < MAX_BACKUPS_PER_DEVICE:
seen.add(base)
keep_ids.add(r.id)
for r in rows:
if r.id not in keep_ids:
db.delete(r)
db.commit()
@router.get("/devices/{device_id}/backups", response_model=list[BackupOut])
def list_backups(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[DeviceBackup]:
if not db.get(Device, device_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
return (
db.query(DeviceBackup)
.filter(DeviceBackup.device_id == device_id)
.order_by(DeviceBackup.created_at.desc())
.all()
)
@router.post(
"/devices/{device_id}/backups",
response_model=list[BackupOut],
status_code=status.HTTP_201_CREATED,
)
def create_backup(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> list[DeviceBackup]:
"""Создать бэкап (binary + text), скачать через SFTP, сохранить в БД."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
from datetime import datetime, timezone
import re
# router id = identity устройства (как оно зовётся в RouterOS),
# fallback: name из БД, потом host. Чистим до [A-Za-z0-9_-].
raw_id = (d.identity or d.name or d.host or "device").strip()
safe_id = re.sub(r"[^A-Za-z0-9_-]+", "_", raw_id).strip("_") or "device"
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = f"{safe_id}-{ts}"
cfg = get_settings()
push_host = cfg.backup_push_host or detect_push_host()
push_port = cfg.backup_ftp_port
try:
files = create_and_download_backup(
_creds(d), base, push_host=push_host, push_port=push_port,
)
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
rec_bin = DeviceBackup(
device_id=d.id, filename=files.binary_name, fmt="binary",
size=len(files.binary_data), content=files.binary_data,
)
rec_txt = DeviceBackup(
device_id=d.id, filename=files.text_name, fmt="text",
size=len(files.text_data), content=files.text_data,
)
db.add(rec_bin)
db.add(rec_txt)
db.commit()
db.refresh(rec_bin)
db.refresh(rec_txt)
_rotate(db, d.id)
return (
db.query(DeviceBackup)
.filter(DeviceBackup.device_id == device_id)
.order_by(DeviceBackup.created_at.desc())
.all()
)
@router.get("/backups/{backup_id}/download")
def download_backup(
backup_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
rec = db.get(DeviceBackup, backup_id)
if not rec:
raise HTTPException(status.HTTP_404_NOT_FOUND, "backup not found")
media_type = "application/octet-stream" if rec.fmt == "binary" else "text/plain; charset=utf-8"
return Response(
content=rec.content,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{rec.filename}"'},
)
@router.delete("/backups/{backup_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_backup(
backup_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> Response:
rec = db.get(DeviceBackup, backup_id)
if not rec:
raise HTTPException(status.HTTP_404_NOT_FOUND, "backup not found")
db.delete(rec)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...core.security import decrypt_secret
from ...models.device import Device
from ...models.user import User
from ...services.events import add_audit
from ...services.routeros.client import (
RouterOSCredentials,
RouterOSError,
execute_cli,
)
from ..deps import require_role
router = APIRouter()
# Опасные команды требуют явного подтверждения через query ?confirm=1
DANGEROUS_PREFIXES = (
"/system/reboot",
"/system/shutdown",
"/system/reset-configuration",
"/system/routerboard/upgrade",
"/file/remove",
)
class CLIRunIn(BaseModel):
device_ids: list[int] = Field(default_factory=list)
command: str
confirm: bool = False
class CLIDeviceResult(BaseModel):
device_id: int
device_name: str | None = None
ok: bool
rows: list[dict[str, Any]] | None = None
error: str | None = None
class CLIRunOut(BaseModel):
command: str
results: list[CLIDeviceResult]
@router.post("/run", response_model=CLIRunOut)
def run_cli(
payload: CLIRunIn,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> CLIRunOut:
if not payload.device_ids:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "device_ids is empty")
cmd = payload.command.strip()
if not cmd:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "command is empty")
is_dangerous = any(cmd.startswith(p) for p in DANGEROUS_PREFIXES)
if is_dangerous and not payload.confirm:
raise HTTPException(
status.HTTP_409_CONFLICT,
"dangerous command requires confirmation (set confirm=true)",
)
results: list[CLIDeviceResult] = []
for did in payload.device_ids:
d = db.get(Device, did)
if not d:
results.append(CLIDeviceResult(device_id=did, ok=False, error="device not found"))
continue
try:
rows = execute_cli(
RouterOSCredentials(
host=d.host,
username=d.username,
password=decrypt_secret(d.password_enc),
port=d.port,
use_tls=d.use_tls,
timeout=10.0,
),
cmd,
)
results.append(
CLIDeviceResult(device_id=did, device_name=d.identity or d.name, ok=True, rows=rows)
)
except RouterOSError as exc:
results.append(
CLIDeviceResult(device_id=did, device_name=d.identity or d.name, ok=False, error=str(exc))
)
add_audit(
db,
actor=user.email,
action="cli.run",
target=f"device:{did}",
detail=cmd[:200],
)
return CLIRunOut(command=cmd, results=results)
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from ...models.user import User
from ...services.controller_backup import (
make_config_only_archive,
make_full_archive,
restore_full_archive,
)
from ..deps import require_role
router = APIRouter()
@router.get("/config")
def download_config_backup(
_: User = Depends(require_role("admin")),
) -> Response:
name, data = make_config_only_archive()
return Response(
content=data,
media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
@router.get("/full")
def download_full_backup(
_: User = Depends(require_role("admin")),
) -> Response:
try:
name, data = make_full_archive()
except RuntimeError as exc:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(exc)) from exc
return Response(
content=data,
media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
@router.post("/restore")
async def restore_backup(
file: UploadFile = File(...),
_: User = Depends(require_role("admin")),
) -> dict:
"""Развёртывание full-бэкапа (tar.gz с db.dump). Деструктивно: дропает текущую БД."""
if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ожидается файл .tar.gz")
data = await file.read()
if len(data) > 500 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Архив слишком большой (>500 MiB)")
try:
return restore_full_archive(data)
except RuntimeError as exc:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(exc)) from exc
+494
View File
@@ -0,0 +1,494 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...core.security import decrypt_secret, encrypt_secret
from ...models.device import Device
from ...models.metric import DeviceMetric
from ...models.user import User
from ...schemas.device import (
DeviceCreate,
DeviceOut,
DeviceResource,
DeviceUpdate,
)
from ...services.events import add_alert, add_audit
from ...services.routeros.client import (
RouterOSCredentials,
RouterOSError,
check_internet,
cmd_reboot,
cmd_safe_mode,
cmd_upgrade_check,
cmd_upgrade_install,
fetch_dhcp_leases,
fetch_identity,
fetch_interface_stats,
fetch_resource,
parse_uptime,
push_firmware_via_ftp,
)
from ..deps import get_current_user, require_role
router = APIRouter()
def _creds(d: Device) -> RouterOSCredentials:
return RouterOSCredentials(
host=d.host,
username=d.username,
password=decrypt_secret(d.password_enc),
port=d.port,
use_tls=d.use_tls,
)
@router.get("", response_model=list[DeviceOut])
def list_devices(
kind: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Device]:
q = db.query(Device)
if kind:
q = q.filter(Device.kind == kind)
return q.order_by(Device.id.desc()).all()
@router.post("", response_model=DeviceOut, status_code=status.HTTP_201_CREATED)
def create_device(
payload: DeviceCreate,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Device:
d = Device(
name=payload.name,
host=payload.host,
port=payload.port,
use_tls=payload.use_tls,
username=payload.username,
password_enc=encrypt_secret(payload.password),
kind=payload.kind or "router",
)
db.add(d)
db.commit()
db.refresh(d)
return d
@router.get("/{device_id}", response_model=DeviceOut)
def get_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Device:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
return d
@router.patch("/{device_id}", response_model=DeviceOut)
def update_device(
device_id: int,
payload: DeviceUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Device:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
data = payload.model_dump(exclude_unset=True)
if "password" in data:
d.password_enc = encrypt_secret(data.pop("password"))
for k, v in data.items():
setattr(d, k, v)
db.commit()
db.refresh(d)
return d
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> Response:
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
db.delete(d)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{device_id}/probe", response_model=DeviceResource)
def probe_device(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> DeviceResource:
"""Подключиться к устройству, прочитать `/system/resource` и обновить
метаданные (identity, model, serial, version, status)."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
res = fetch_resource(_creds(d))
identity = fetch_identity(_creds(d))
except RouterOSError as exc:
d.status = "down"
d.last_error = str(exc)
db.commit()
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
d.identity = identity or d.identity
d.model = res.get("board-name") or d.model
d.ros_version = res.get("version") or d.ros_version
d.architecture = res.get("architecture-name") or d.architecture
prev_status = d.status
d.status = "up"
d.last_error = None
d.last_seen = datetime.now(timezone.utc)
def _to_int(v):
try:
return int(v) if v is not None else None
except (TypeError, ValueError):
return None
cpu = _to_int(res.get("cpu-load"))
free_mem = _to_int(res.get("free-memory"))
total_mem = _to_int(res.get("total-memory"))
uptime_s = parse_uptime(res.get("uptime"))
# abnormal reboot detection: новый uptime < предыдущего и отличие > 60s
abnormal = False
if uptime_s is not None and d.last_uptime_seconds is not None:
if uptime_s < d.last_uptime_seconds - 60:
abnormal = True
d.abnormal_reboot = True
add_alert(
db,
severity="warning",
category="abnormal_reboot",
source=f"device:{d.id}",
title=f"Возможен аварийный перезапуск: {d.identity or d.name}",
message=(
f"Uptime упал с {d.last_uptime_seconds}s до {uptime_s}s "
f"без штатной команды reboot."
),
)
if not abnormal:
d.abnormal_reboot = False
d.last_uptime_seconds = uptime_s
# internet check
try:
ok = check_internet(_creds(d))
d.internet_ok = ok
if not ok:
add_alert(
db,
severity="warning",
category="internet",
source=f"device:{d.id}",
title=f"Нет интернета на {d.identity or d.name}",
message="Ping 8.8.8.8 не прошёл.",
)
except Exception:
d.internet_ok = None
# уведомление о возврате в строй
if prev_status == "down" and d.status == "up":
add_alert(
db,
severity="info",
category="device",
source=f"device:{d.id}",
title=f"Устройство снова онлайн: {d.identity or d.name}",
)
mem_used_pct = None
if free_mem is not None and total_mem and total_mem > 0:
mem_used_pct = round(100 - (free_mem / total_mem) * 100, 1)
metric = DeviceMetric(
device_id=d.id,
cpu_load=float(cpu) if cpu is not None else None,
mem_used_pct=mem_used_pct,
free_memory=free_mem,
total_memory=total_mem,
uptime_seconds=uptime_s,
internet_ok=d.internet_ok,
)
db.add(metric)
db.commit()
return DeviceResource(
cpu_load=cpu,
free_memory=free_mem,
total_memory=total_mem,
uptime=res.get("uptime"),
version=res.get("version"),
board_name=res.get("board-name"),
architecture_name=res.get("architecture-name"),
)
@router.post("/{device_id}/reboot", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def reboot_device(
device_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> Response:
"""Отправить команду перезагрузки устройству."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
cmd_reboot(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.reboot", target=f"device:{device_id}")
add_alert(db, severity="info", category="device", source=f"device:{device_id}",
title=f"Reboot отправлен: {d.identity or d.name}", message=f"by {user.email}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{device_id}/safe-mode", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def toggle_safe_mode(
device_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> Response:
"""Переключить safe mode на устройстве."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
cmd_safe_mode(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.safe_mode", target=f"device:{device_id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------- Sprint 09: интерфейсы / DHCP / upgrade ----------
@router.get("/{device_id}/interfaces")
def list_interfaces(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Список интерфейсов устройства со счётчиками rx/tx и running."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
return fetch_interface_stats(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.get("/{device_id}/interface-traffic")
def interface_traffic(
device_id: int,
names: str | None = None,
hours: float = 24.0,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict:
"""Серии bps по выбранным интерфейсам за окно `hours`.
`names` — CSV. Если пусто — берётся из `device.monitored_interfaces`.
Возвращает {"series": {name: [{ts, rx_bps, tx_bps, running}]}}.
"""
from ...models.interface_stat import InterfaceStat
from datetime import datetime, timedelta, timezone
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
if not names:
names = d.monitored_interfaces or ""
name_list = [x.strip() for x in names.split(",") if x.strip()]
if not name_list:
return {"series": {}, "hours": hours}
since = datetime.now(timezone.utc) - timedelta(hours=hours)
rows = (
db.query(InterfaceStat)
.filter(
InterfaceStat.device_id == device_id,
InterfaceStat.name.in_(name_list),
InterfaceStat.ts >= since,
)
.order_by(InterfaceStat.name.asc(), InterfaceStat.ts.asc())
.all()
)
by_name: dict[str, list] = {n: [] for n in name_list}
last: dict[str, tuple] = {}
for r in rows:
prev = last.get(r.name)
rx_bps = tx_bps = None
if prev is not None:
dt = (r.ts - prev[0]).total_seconds()
if dt > 0:
# счётчики могут сброситься после reboot — игнорируем отрицательные дельты
drx = r.rx_bytes - prev[1]
dtx = r.tx_bytes - prev[2]
if drx >= 0 and dtx >= 0:
rx_bps = round(drx * 8 / dt)
tx_bps = round(dtx * 8 / dt)
by_name[r.name].append({
"ts": r.ts.isoformat(),
"rx_bps": rx_bps,
"tx_bps": tx_bps,
"running": r.running,
})
last[r.name] = (r.ts, r.rx_bytes, r.tx_bytes)
return {"series": by_name, "hours": hours}
@router.get("/{device_id}/uplink-status")
def uplink_status(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Текущий статус выбранных аплинков (running) — по последней записи."""
from ...models.interface_stat import InterfaceStat
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
name_list = [x.strip() for x in (d.uplink_interfaces or "").split(",") if x.strip()]
out = []
for n in name_list:
last = (
db.query(InterfaceStat)
.filter(InterfaceStat.device_id == device_id, InterfaceStat.name == n)
.order_by(InterfaceStat.ts.desc()).first()
)
out.append({
"name": n,
"running": bool(last.running) if last else None,
"ts": last.ts.isoformat() if last else None,
})
return out
@router.get("/{device_id}/dhcp-leases")
def dhcp_leases(
device_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Список выданных DHCP-лизов по всем DHCP-серверам устройства."""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
return fetch_dhcp_leases(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.post("/{device_id}/upgrade/internet")
def upgrade_from_internet(
device_id: int,
channel: str = "stable",
install: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> dict:
"""Запросить у MikroTik проверку обновления и при `install=true` — установить.
Идёт через штатный `/system/package/update` (репозиторий MikroTik).
Установка перезагрузит устройство.
"""
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
try:
info = cmd_upgrade_check(_creds(d), channel=channel)
if install:
cmd_upgrade_install(_creds(d))
add_audit(db, actor=user.email, action="device.upgrade.internet",
target=f"device:{device_id}", detail=f"channel={channel}")
add_alert(db, severity="info", category="firmware",
source=f"device:{device_id}",
title=f"Обновление из интернета запущено: {d.identity or d.name}",
message=f"by {user.email}, channel={channel}")
db.commit()
return {"ok": True, "info": info, "installed": bool(install)}
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
@router.post("/{device_id}/upgrade/local")
def upgrade_from_local(
device_id: int,
firmware_id: int,
reboot: bool = True,
db: Session = Depends(get_db),
user: User = Depends(require_role("admin", "operator")),
) -> dict:
"""Установить прошивку из локального репозитория контроллера.
Файл прошивки временно публикуется во встроенный FTP, устройство сам
скачивает его командой `/tool/fetch`, затем (опц.) перезагружается —
RouterOS установит .npk при загрузке.
"""
from ...models.firmware import Firmware
from ...services.backup_ftp_server import get_server, detect_push_host
from ...core.config import get_settings as _cfg
import os
d = db.get(Device, device_id)
if not d:
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
fw = db.get(Firmware, firmware_id)
if not fw:
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
if not fw.content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "firmware has no payload")
srv = get_server()
if srv is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "backup ftp server not running")
cfg = _cfg()
push_host = cfg.backup_push_host or detect_push_host()
if not push_host:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "BACKUP_PUSH_HOST not configured")
sess = srv.open_session([fw.name])
try:
path = os.path.join(sess.home_dir, fw.name)
with open(path, "wb") as f:
f.write(fw.content)
try:
push_firmware_via_ftp(
_creds(d),
server=push_host, port=int(cfg.backup_ftp_port),
user=sess.username, password=sess.password,
src_path=fw.name, dst_filename=fw.name,
)
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
if reboot:
try:
cmd_reboot(_creds(d))
except RouterOSError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
add_audit(db, actor=user.email, action="device.upgrade.local",
target=f"device:{device_id}", detail=f"firmware={fw.name}")
add_alert(db, severity="info", category="firmware",
source=f"device:{device_id}",
title=f"Установлена локальная прошивка: {d.identity or d.name}",
message=f"{fw.name} by {user.email}")
db.commit()
return {"ok": True, "file": fw.name, "reboot": reboot}
finally:
srv.close_session(sess.session_id)
+359
View File
@@ -0,0 +1,359 @@
from __future__ import annotations
import hashlib
import os.path
import re
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...models.firmware import Firmware
from ...models.user import User
from ...schemas.firmware import (
FirmwareBulkImportIn,
FirmwareBulkOut,
FirmwareBulkResult,
FirmwareImportIn,
FirmwareOut,
FirmwareUpdateIn,
)
from ...services.firmware_check import CHANNELS, check_and_alert, get_state
from ..deps import get_current_user, require_role
router = APIRouter()
MAX_FIRMWARE_SIZE = 200 * 1024 * 1024 # 200 MiB лимит
# Известные архитектуры RouterOS v7 для bulk-импорта.
KNOWN_ARCHITECTURES = [
"arm64", "arm", "mipsbe", "mmips", "mipsle", "smips",
"tile", "ppc", "x86", "x86_64",
]
@router.get("", response_model=list[FirmwareOut])
def list_firmware(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Firmware]:
return db.query(Firmware).order_by(Firmware.created_at.desc()).all()
@router.post("/check")
def manual_check(
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> dict:
"""Ручная проверка наличия новых версий RouterOS по всем каналам."""
state = check_and_alert(db)
if not state:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "upstream check failed")
# Для совместимости со старым UI возвращаем top-level stable.
stable = state.get("stable") or {}
return {
"latest_version": stable.get("version", ""),
"released_at": stable.get("released_at", ""),
"channels": state,
}
@router.get("/channels")
def list_channels(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict:
"""Текущее состояние по каждому каналу + список известных архитектур."""
return {
"channels": get_state(db),
"available_channels": list(CHANNELS.keys()),
"architectures": KNOWN_ARCHITECTURES,
}
@router.post("/import", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
def import_firmware(
payload: FirmwareImportIn,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Firmware:
"""Скачать прошивку с указанного URL и сохранить во внутреннем репозитории.
Если прошивка с таким же `source_url` или (`version`+`architecture`) уже
есть — повторно не скачивается, возвращается существующая запись (HTTP 200
с тем же телом, как и для свежесозданной).
"""
url = str(payload.url)
# 1) Дедуп по URL источника.
existing = db.query(Firmware).filter(Firmware.source_url == url).first()
if existing:
return existing
# 2) Дедуп по (version, architecture), если оба поля переданы.
if payload.version and payload.architecture:
existing = (
db.query(Firmware)
.filter(
Firmware.version == payload.version,
Firmware.architecture == payload.architecture,
)
.first()
)
if existing:
return existing
try:
with httpx.stream("GET", url, follow_redirects=True, timeout=120.0) as resp:
resp.raise_for_status()
chunks: list[bytes] = []
total = 0
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
total += len(chunk)
if total > MAX_FIRMWARE_SIZE:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
)
chunks.append(chunk)
data = b"".join(chunks)
except httpx.HTTPError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"download failed: {exc}") from exc
name = payload.name or os.path.basename(url.split("?")[0]) or "firmware.bin"
sha = hashlib.sha256(data).hexdigest()
# 3) Дедуп по sha256 (на случай разных URL с тем же содержимым).
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
if existing:
return existing
rec = Firmware(
name=name,
version=payload.version,
architecture=payload.architecture,
channel=payload.channel,
size=len(data),
sha256=sha,
source_url=url,
content=data,
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def _download_firmware_url(url: str) -> bytes:
with httpx.stream("GET", url, follow_redirects=True, timeout=180.0) as resp:
resp.raise_for_status()
chunks: list[bytes] = []
total = 0
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
total += len(chunk)
if total > MAX_FIRMWARE_SIZE:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
)
chunks.append(chunk)
return b"".join(chunks)
@router.post("/import-bulk", response_model=FirmwareBulkOut)
def import_bulk(
payload: FirmwareBulkImportIn,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> FirmwareBulkOut:
"""Загрузить .npk для указанной версии по списку архитектур одним вызовом."""
results: list[FirmwareBulkResult] = []
base = "https://download.mikrotik.com/routeros"
for arch in payload.architectures:
url = f"{base}/{payload.version}/routeros-{payload.version}-{arch}.npk"
# Дедуп до закачки: по URL или (version+architecture).
existing = (
db.query(Firmware)
.filter(
(Firmware.source_url == url)
| ((Firmware.version == payload.version) & (Firmware.architecture == arch))
)
.first()
)
if existing:
results.append(FirmwareBulkResult(
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
))
continue
try:
data = _download_firmware_url(url)
sha = hashlib.sha256(data).hexdigest()
# Дедуп по содержимому.
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
if existing:
results.append(FirmwareBulkResult(
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
))
continue
rec = Firmware(
name=os.path.basename(url),
version=payload.version,
architecture=arch,
channel=payload.channel,
size=len(data),
sha256=sha,
source_url=url,
content=data,
)
db.add(rec)
db.commit()
db.refresh(rec)
results.append(FirmwareBulkResult(architecture=arch, ok=True, firmware_id=rec.id))
except HTTPException as exc:
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc.detail)))
except httpx.HTTPError as exc:
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc)))
return FirmwareBulkOut(version=payload.version, channel=payload.channel, results=results)
# routeros-7.16.1-arm64.npk / routeros-7.16.1-arm-7.16.1.npk и т.п.
_FW_NAME_RE = re.compile(
r"^routeros-(?P<version>\d+(?:\.\d+){1,2}(?:[a-z0-9.\-]*)?)-(?P<arch>[a-z0-9_]+)\.npk$",
re.IGNORECASE,
)
def _guess_meta(filename: str) -> tuple[str | None, str | None]:
"""Из имени файла вытащить (version, architecture). Возвращает (None, None) если не разобрали."""
m = _FW_NAME_RE.match(filename.strip().lower())
if not m:
return None, None
return m.group("version"), m.group("arch")
@router.post("/upload", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
async def upload_firmware(
file: UploadFile = File(..., description=".npk файл прошивки RouterOS"),
name: str | None = Form(None),
version: str | None = Form(None),
architecture: str | None = Form(None),
channel: str | None = Form(None),
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Firmware:
"""Загрузка прошивки вручную с диска пользователя (multipart/form-data).
Если `version`/`architecture` не указаны — попытка распарсить из имени файла
(формат `routeros-<version>-<arch>.npk`). Дедуп по sha256 / (version+architecture).
"""
fname = (name or file.filename or "firmware.bin").strip()
if not fname.lower().endswith(".npk"):
# Не блокируем строго, но предупреждаем — RouterOS принимает только .npk.
# Разрешаем — пусть админ сам решает.
pass
# Читаем тело с лимитом
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_FIRMWARE_SIZE:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
)
chunks.append(chunk)
data = b"".join(chunks)
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "empty file")
# Автоопределение метаданных из имени файла
if not version or not architecture:
guessed_ver, guessed_arch = _guess_meta(fname)
version = version or guessed_ver
architecture = architecture or guessed_arch
sha = hashlib.sha256(data).hexdigest()
# Дедуп: по sha256 → возвращаем существующую запись
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
if existing:
return existing
# Дедуп по (version, architecture)
if version and architecture:
existing = (
db.query(Firmware)
.filter(
Firmware.version == version,
Firmware.architecture == architecture,
)
.first()
)
if existing:
return existing
rec = Firmware(
name=fname,
version=version,
architecture=architecture,
channel=channel,
size=len(data),
sha256=sha,
source_url=None,
content=data,
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
@router.patch("/{firmware_id}", response_model=FirmwareOut)
def update_firmware(
firmware_id: int,
payload: FirmwareUpdateIn,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin", "operator")),
) -> Firmware:
rec = db.get(Firmware, firmware_id)
if not rec:
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(rec, k, v)
db.commit()
db.refresh(rec)
return rec
@router.get("/{firmware_id}/download")
def download_firmware(
firmware_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
rec = db.get(Firmware, firmware_id)
if not rec:
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
return Response(
content=rec.content,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{rec.name}"'},
)
@router.delete("/{firmware_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_firmware(
firmware_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> Response:
rec = db.get(Firmware, firmware_id)
if not rec:
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
db.delete(rec)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
APP_NAME = "ROSzetta"
APP_VERSION = "0.6.0"
router = APIRouter()
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/version")
def version() -> dict[str, str]:
return {"name": APP_NAME, "version": APP_VERSION}
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...models.device import Device
from ...models.metric import DeviceMetric
from ...models.user import User
from ...schemas.metric import MetricPoint
from ..deps import get_current_user
router = APIRouter()
@router.get("/devices/{device_id}/metrics", response_model=list[MetricPoint])
def get_metrics(
device_id: int,
hours: int = 24,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MetricPoint]:
if not db.get(Device, device_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "device not found")
since = datetime.now(timezone.utc) - timedelta(hours=max(1, min(hours, 24 * 30)))
rows = (
db.query(DeviceMetric)
.filter(DeviceMetric.device_id == device_id, DeviceMetric.created_at >= since)
.order_by(DeviceMetric.created_at.asc())
.all()
)
return [
MetricPoint(
ts=r.created_at,
cpu_load=r.cpu_load,
mem_used_pct=r.mem_used_pct,
uptime_seconds=r.uptime_seconds,
internet_ok=r.internet_ok,
rx_bps=r.rx_bps,
tx_bps=r.tx_bps,
)
for r in rows
]
@router.get("/heartbeat")
def heartbeat(
hours: float = 24,
bins: int = 48,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict:
"""Сводка статусов всех устройств по бинам времени для heartbeat-графика.
Каждый бин получает один из статусов:
- "up" — есть метрика, internet_ok != False
- "no-net" — есть метрика, internet_ok == False
- "down" — нет ни одной метрики в окне
- "none" — нет данных вообще
Приоритет внутри бина: down/no-net > up.
"""
hours = max(0.25, min(float(hours), 24 * 7))
bins = max(6, min(bins, 288))
now = datetime.now(timezone.utc)
since = now - timedelta(hours=hours)
bin_seconds = (hours * 3600) / bins
# Один сэмпл «закрашивает» окно вокруг себя, чтобы не было полосатости,
# когда интервал опроса больше длины бина (например, 1 мин probe и 30 сек бин).
halo_seconds = max(bin_seconds * 1.5, 90.0)
devices = db.query(Device).order_by(Device.name.asc()).all()
rows = (
db.query(DeviceMetric)
.filter(DeviceMetric.created_at >= since - timedelta(seconds=halo_seconds))
.order_by(DeviceMetric.created_at.asc())
.all()
)
by_dev: dict[int, list[DeviceMetric]] = {}
for r in rows:
by_dev.setdefault(r.device_id, []).append(r)
# Приоритет: no-net побеждает up; down/none перекрываются любой выборкой.
def _promote(cur: str, new: str) -> str:
if new == "no-net":
return "no-net"
if cur in ("none", "down") and new == "up":
return "up"
return cur
out_devices = []
for dev in devices:
buckets = ["none"] * bins
for r in by_dev.get(dev.id, []):
ts = r.created_at
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
offset = (ts - since).total_seconds()
lo = int((offset - halo_seconds) // bin_seconds)
hi = int((offset + halo_seconds) // bin_seconds)
new_state = "no-net" if r.internet_ok is False else "up"
for idx in range(max(0, lo), min(bins, hi + 1)):
buckets[idx] = _promote(buckets[idx], new_state)
out_devices.append({
"id": dev.id,
"name": dev.identity or dev.name,
"host": dev.host,
"status": dev.status,
"buckets": buckets,
})
return {
"since": since.isoformat(),
"until": now.isoformat(),
"bins": bins,
"hours": hours,
"devices": out_devices,
}
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends
from sqlalchemy.orm import Session
from ...core.db import get_db
from ...models.user import User
from ...services.settings import get_settings_dict, update_settings_dict
from ...services import telegram as tg
from ..deps import get_current_user, require_role
router = APIRouter()
@router.get("")
def get_settings_endpoint(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> dict[str, Any]:
s = get_settings_dict(db)
# Маскируем токен бота при отдаче
tg_cfg = s.get("telegram", {})
if tg_cfg.get("bot_token"):
tg_cfg = {**tg_cfg, "bot_token_masked": "***" + tg_cfg["bot_token"][-4:]}
# Сам токен в открытую тоже отдаём админам через /settings (для редактирования)
return s
@router.put("")
def put_settings_endpoint(
patch: dict[str, Any] = Body(...),
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict[str, Any]:
out = update_settings_dict(db, patch)
# Если изменён интервал автоопроса — переплинируем джобу.
new_pm = (out.get("ui") or {}).get("probe_interval_minutes")
if isinstance(new_pm, int):
from ...main import reschedule_probe_job
try:
reschedule_probe_job(new_pm)
except Exception: # pragma: no cover
pass
return out
@router.post("/telegram/test")
def telegram_test(
db: Session = Depends(get_db),
_: User = Depends(require_role("admin")),
) -> dict[str, Any]:
s = get_settings_dict(db)
cfg = s.get("telegram", {})
ok, msg = tg.test_credentials(cfg.get("bot_token", ""), cfg.get("chat_id", ""))
return {"ok": ok, "message": msg}