diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f22131 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Editors / OS +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +.cache/ +.parcel-cache/ + +# Env / secrets +.env +.env.* +!.env.example + +# Build / coverage +build/ +coverage/ +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..64c08cd --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +# Backend env example +APP_ENV=dev +SECRET_KEY=change-me-to-32-bytes-random-secret +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=14 + +DATABASE_URL=postgresql+psycopg2://mikrocloud:mikrocloud@postgres:5432/mikrocloud +REDIS_URL=redis://redis:6379/0 + +# MinIO / S3 +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY=minio +S3_SECRET_KEY=minio12345 +S3_BUCKET=mikrocloud-backups + +# Bootstrap admin (создаётся при первом запуске) +BOOTSTRAP_ADMIN_EMAIL=admin +BOOTSTRAP_ADMIN_PASSWORD=admin + +# CORS +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0bcfedd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev curl postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY app ./app + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..b09d711 --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..f6e50d7 --- /dev/null +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py new file mode 100644 index 0000000..ed9e922 --- /dev/null +++ b/backend/app/api/v1/alerts.py @@ -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)} diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..df6f08d --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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} diff --git a/backend/app/api/v1/backups.py b/backend/app/api/v1/backups.py new file mode 100644 index 0000000..d36a831 --- /dev/null +++ b/backend/app/api/v1/backups.py @@ -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) diff --git a/backend/app/api/v1/cli.py b/backend/app/api/v1/cli.py new file mode 100644 index 0000000..8212b3b --- /dev/null +++ b/backend/app/api/v1/cli.py @@ -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) diff --git a/backend/app/api/v1/controller_backup.py b/backend/app/api/v1/controller_backup.py new file mode 100644 index 0000000..8a64365 --- /dev/null +++ b/backend/app/api/v1/controller_backup.py @@ -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 diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py new file mode 100644 index 0000000..8efad1e --- /dev/null +++ b/backend/app/api/v1/devices.py @@ -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) diff --git a/backend/app/api/v1/firmware.py b/backend/app/api/v1/firmware.py new file mode 100644 index 0000000..244ff96 --- /dev/null +++ b/backend/app/api/v1/firmware.py @@ -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\d+(?:\.\d+){1,2}(?:[a-z0-9.\-]*)?)-(?P[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--.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) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..81ccbaf --- /dev/null +++ b/backend/app/api/v1/health.py @@ -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} diff --git a/backend/app/api/v1/metrics.py b/backend/app/api/v1/metrics.py new file mode 100644 index 0000000..2affd9f --- /dev/null +++ b/backend/app/api/v1/metrics.py @@ -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, + } diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..d7f73cd --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -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} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/bootstrap.py b/backend/app/core/bootstrap.py new file mode 100644 index 0000000..af49ccf --- /dev/null +++ b/backend/app/core/bootstrap.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from loguru import logger +from sqlalchemy.orm import Session + +from .config import get_settings +from .db import Base, SessionLocal, engine +from .security import hash_password +from ..models.user import User + + +def init_db() -> None: + # Импортируем модели, чтобы они зарегистрировались в Base.metadata + from ..models import device as _device # noqa: F401 + from ..models import user as _user # noqa: F401 + from ..models import backup as _backup # noqa: F401 + from ..models import firmware as _firmware # noqa: F401 + from ..models import alert as _alert # noqa: F401 + from ..models import metric as _metric # noqa: F401 + from ..models import settings as _settings # noqa: F401 + from ..models import interface_stat as _ifs # noqa: F401 + + Base.metadata.create_all(bind=engine) + _ensure_columns() + _ensure_admin() + + +def _ensure_columns() -> None: + """Лёгкие миграции на ALTER TABLE для совместимости со старыми БД.""" + from sqlalchemy import text + statements = [ + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_error TEXT", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS internet_ok BOOLEAN", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_uptime_seconds INTEGER", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS abnormal_reboot BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_log_warning TEXT", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS monitored_interfaces TEXT", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS uplink_interfaces TEXT", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS interface_history_hours INTEGER NOT NULL DEFAULT 24", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'router'", + "ALTER TABLE devices ADD COLUMN IF NOT EXISTS architecture VARCHAR(32)", + ] + with engine.begin() as conn: + for s in statements: + try: + conn.execute(text(s)) + except Exception as exc: # pragma: no cover + logger.warning("migration failed: {} ({})", s, exc) + + +def _ensure_admin() -> None: + settings = get_settings() + db: Session = SessionLocal() + try: + exists = db.query(User).filter(User.email == settings.bootstrap_admin_email).first() + if exists: + return + admin = User( + email=settings.bootstrap_admin_email, + hashed_password=hash_password(settings.bootstrap_admin_password), + role="admin", + is_active=True, + ) + db.add(admin) + db.commit() + logger.info("Created bootstrap admin: {}", settings.bootstrap_admin_email) + finally: + db.close() diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..4213095 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import List + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + app_env: str = "dev" + secret_key: str = "dev-secret-change-me" + access_token_expire_minutes: int = 60 + refresh_token_expire_days: int = 14 + + database_url: str = ( + "postgresql+psycopg2://mikrocloud:mikrocloud@postgres:5432/mikrocloud" + ) + redis_url: str = "redis://redis:6379/0" + + s3_endpoint: str = "http://minio:9000" + s3_access_key: str = "minio" + s3_secret_key: str = "minio12345" + s3_bucket: str = "mikrocloud-backups" + + bootstrap_admin_email: str = "admin" + bootstrap_admin_password: str = "admin" + + cors_origins: str = "http://localhost:5173" + + # sprint 06: периодические задачи + firmware_check_interval_hours: int = 24 + device_probe_interval_minutes: int = 5 + + # sprint 08: push-доставка бэкапов + backup_ftp_host: str = "0.0.0.0" + backup_ftp_port: int = 2121 + backup_push_host: str = "" # пусто → автоопределение detect_push_host() + + @property + def cors_origins_list(self) -> List[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..27b87e9 --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import get_settings + +settings = get_settings() + +connect_args: dict = {} +if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine( + settings.database_url, pool_pre_ping=True, future=True, connect_args=connect_args +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db: Session = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..9ec5d79 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from cryptography.fernet import Fernet +from jose import JWTError, jwt +from passlib.context import CryptContext + +from .config import get_settings + +settings = get_settings() + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str: + payload: dict[str, Any] = { + "sub": str(subject), + "type": "access", + "iat": _now(), + "exp": _now() + timedelta(minutes=settings.access_token_expire_minutes), + } + if extra: + payload.update(extra) + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def create_refresh_token(subject: str | int) -> str: + payload = { + "sub": str(subject), + "type": "refresh", + "iat": _now(), + "exp": _now() + timedelta(days=settings.refresh_token_expire_days), + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + try: + return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError as exc: # pragma: no cover + raise ValueError(f"invalid token: {exc}") from exc + + +# --- Симметричное шифрование секретов устройств ----------------------------- +# Производный ключ из SECRET_KEY (для dev). В prod — KMS / Vault. + +def _fernet() -> Fernet: + import base64 + import hashlib + + digest = hashlib.sha256(settings.secret_key.encode()).digest() + key = base64.urlsafe_b64encode(digest) + return Fernet(key) + + +def encrypt_secret(value: str) -> str: + return _fernet().encrypt(value.encode()).decode() + + +def decrypt_secret(token: str) -> str: + return _fernet().decrypt(token.encode()).decode() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a3294f1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from loguru import logger + +from .api.router import api_router +from .core.bootstrap import init_db +from .core.config import get_settings +from .core.db import SessionLocal + + +def _job_firmware_check() -> None: + from .services.firmware_check import check_and_alert + db = SessionLocal() + try: + check_and_alert(db) + except Exception as exc: # pragma: no cover + logger.warning("firmware check job failed: {}", exc) + finally: + db.close() + + +def _job_probe_devices() -> None: + """Периодически опрашивает все устройства, обновляет метрики/алерты.""" + from .models.device import Device + from .models.metric import DeviceMetric + from .models.interface_stat import InterfaceStat + from .core.security import decrypt_secret + from .services.events import add_alert + from .services.routeros.client import ( + RouterOSCredentials, RouterOSError, check_internet, + fetch_identity, fetch_interface_stats, fetch_resource, parse_uptime, + ) + from datetime import datetime, timedelta, timezone + db = SessionLocal() + try: + for d in db.query(Device).all(): + creds = RouterOSCredentials( + host=d.host, username=d.username, + password=decrypt_secret(d.password_enc), + port=d.port, use_tls=d.use_tls, timeout=5.0, + ) + try: + res = fetch_resource(creds) + ident = fetch_identity(creds) + except RouterOSError as exc: + if d.status != "down": + add_alert(db, severity="error", category="device", + source=f"device:{d.id}", + title=f"Устройство недоступно: {d.identity or d.name}", + message=str(exc)) + d.status = "down" + d.last_error = str(exc) + db.commit() + continue + + d.identity = ident 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) + uptime_s = parse_uptime(res.get("uptime")) + if uptime_s is not None and d.last_uptime_seconds is not None: + if uptime_s < d.last_uptime_seconds - 60: + 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") + else: + d.abnormal_reboot = False + d.last_uptime_seconds = uptime_s + try: + d.internet_ok = check_internet(creds) + except Exception: + d.internet_ok = None + if prev_status == "down": + add_alert(db, severity="info", category="device", + source=f"device:{d.id}", + title=f"Устройство снова онлайн: {d.identity or d.name}") + + def _i(v): + try: return int(v) if v is not None else None + except: return None # noqa: E722 + cpu = _i(res.get("cpu-load")) + free_mem = _i(res.get("free-memory")) + total_mem = _i(res.get("total-memory")) + mem_pct = None + if free_mem is not None and total_mem and total_mem > 0: + mem_pct = round(100 - (free_mem / total_mem) * 100, 1) + db.add(DeviceMetric( + device_id=d.id, + cpu_load=float(cpu) if cpu is not None else None, + mem_used_pct=mem_pct, + free_memory=free_mem, total_memory=total_mem, + uptime_seconds=uptime_s, internet_ok=d.internet_ok, + )) + # ---- Sprint 09: счётчики выбранных интерфейсов ---- + mon = (d.monitored_interfaces or "").strip() + up = (d.uplink_interfaces or "").strip() + wanted = {x.strip() for x in mon.split(",") if x.strip()} + wanted |= {x.strip() for x in up.split(",") if x.strip()} + if wanted: + try: + iface_rows = fetch_interface_stats(creds) + now_ts = datetime.now(timezone.utc) + for r in iface_rows: + if r["name"] in wanted: + db.add(InterfaceStat( + device_id=d.id, name=r["name"], + rx_bytes=r["rx_bytes"], tx_bytes=r["tx_bytes"], + running=r["running"], ts=now_ts, + )) + # ретенция: глубина в часах + keep_hours = int(d.interface_history_hours or 24) + cutoff = now_ts - timedelta(hours=keep_hours) + db.query(InterfaceStat).filter( + InterfaceStat.device_id == d.id, + InterfaceStat.ts < cutoff, + ).delete(synchronize_session=False) + except RouterOSError as exc: + logger.debug("iface stats failed for {}: {}", d.host, exc) + db.commit() + except Exception as exc: # pragma: no cover + logger.warning("probe job failed: {}", exc) + finally: + db.close() + + +_scheduler: AsyncIOScheduler | None = None + +# Допустимые интервалы автоопроса (мин), используются для clamp/валидации. +ALLOWED_PROBE_MINUTES: tuple[int, ...] = (1, 2, 3, 5, 10) + + +def reschedule_probe_job(minutes: int) -> int: + """Изменяет интервал джобы probe_devices на лету. Возвращает применённое значение.""" + global _scheduler + if minutes not in ALLOWED_PROBE_MINUTES: + # ближайшее снизу из разрешённых + minutes = max((m for m in ALLOWED_PROBE_MINUTES if m <= minutes), default=ALLOWED_PROBE_MINUTES[0]) + if _scheduler is None: + return minutes + _scheduler.reschedule_job("probe_devices", trigger="interval", minutes=minutes) + logger.info("probe_devices job rescheduled: every {}m", minutes) + return minutes + + +@asynccontextmanager +async def lifespan(_: FastAPI): + global _scheduler + settings = get_settings() + logger.info("Starting ROSzetta API ({} env)", settings.app_env) + init_db() + + # FTP-сервер для приёма push-бэкапов от MikroTik + try: + from .services.backup_ftp_server import start_server + start_server(host=settings.backup_ftp_host, port=settings.backup_ftp_port) + except Exception as exc: # pragma: no cover + logger.warning("Backup FTP server failed to start: {}", exc) + + # Стартовый интервал берём из настроек БД (если уже сохранены), иначе из env. + probe_minutes = settings.device_probe_interval_minutes + try: + from .services.settings import get_settings_dict + db = SessionLocal() + try: + s = get_settings_dict(db) + ui_pm = (s.get("ui") or {}).get("probe_interval_minutes") + if isinstance(ui_pm, int) and ui_pm in ALLOWED_PROBE_MINUTES: + probe_minutes = ui_pm + finally: + db.close() + except Exception as exc: # pragma: no cover + logger.warning("could not load probe interval from settings: {}", exc) + + _scheduler = AsyncIOScheduler(timezone="UTC") + from datetime import datetime, timedelta, timezone + now = datetime.now(timezone.utc) + _scheduler.add_job( + _job_firmware_check, "interval", + hours=max(1, settings.firmware_check_interval_hours), + id="firmware_check", + next_run_time=now + timedelta(seconds=30), + ) + _scheduler.add_job( + _job_probe_devices, "interval", + minutes=max(1, probe_minutes), + id="probe_devices", + next_run_time=now + timedelta(seconds=10), + ) + _scheduler.start() + logger.info("Scheduler started: firmware/{}h, probe/{}m", + settings.firmware_check_interval_hours, probe_minutes) + yield + if _scheduler: + _scheduler.shutdown(wait=False) + try: + from .services.backup_ftp_server import stop_server + stop_server() + except Exception: # pragma: no cover + pass + logger.info("Shutting down") + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="ROSzetta API", + version="0.1.0", + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(api_router) + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..1a570f2 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class Alert(Base): + __tablename__ = "alerts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info") # info|warning|error|critical + category: Mapped[str] = mapped_column(String(32), nullable=False, default="system") # firmware|backup|device|security|system + source: Mapped[str | None] = mapped_column(String(64)) # device id / module name + title: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str | None] = mapped_column(Text) + acknowledged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py new file mode 100644 index 0000000..fc9f7aa --- /dev/null +++ b/backend/app/models/backup.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class DeviceBackup(Base): + __tablename__ = "device_backups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + device_id: Mapped[int] = mapped_column( + Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False + ) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + # 'binary' (.backup) или 'text' (.rsc) + fmt: Mapped[str] = mapped_column(String(16), nullable=False) + size: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..218e42f --- /dev/null +++ b/backend/app/models/device.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class Device(Base): + __tablename__ = "devices" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 'router' | 'switch' — вид устройства (разнесение в разделы Devices / Свичи) + kind: Mapped[str] = mapped_column(String(16), default="router", nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + port: Mapped[int] = mapped_column(Integer, default=8729, nullable=False) + use_tls: Mapped[bool] = mapped_column(default=True, nullable=False) + username: Mapped[str] = mapped_column(String(64), nullable=False) + # Шифруется через core.security.encrypt_secret + password_enc: Mapped[str] = mapped_column(Text, nullable=False) + + # Метаданные с устройства + identity: Mapped[str | None] = mapped_column(String(128)) + model: Mapped[str | None] = mapped_column(String(64)) + serial: Mapped[str | None] = mapped_column(String(64)) + ros_version: Mapped[str | None] = mapped_column(String(32)) + # Архитектура платформы RouterOS: arm64 / arm / mipsbe / mmips / mipsle / smips / tile / ppc / x86 / x86_64 + architecture: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) + last_error: Mapped[str | None] = mapped_column(Text) + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Sprint 06 + internet_ok: Mapped[bool | None] = mapped_column() + last_uptime_seconds: Mapped[int | None] = mapped_column(Integer) + abnormal_reboot: Mapped[bool] = mapped_column(default=False, nullable=False) + last_log_warning: Mapped[str | None] = mapped_column(Text) + # Sprint 09 — мониторинг интерфейсов + # CSV-список имён интерфейсов, по которым собирать графики rx/tx (через запятую) + monitored_interfaces: Mapped[str | None] = mapped_column(Text) + # CSV-список аплинков (uztelecom/lte/...): для индикатора "интернет на интерфейсе X" + uplink_interfaces: Mapped[str | None] = mapped_column(Text) + # глубина хранения статистики интерфейсов (часы) + interface_history_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/models/firmware.py b/backend/app/models/firmware.py new file mode 100644 index 0000000..568c574 --- /dev/null +++ b/backend/app/models/firmware.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, LargeBinary, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class Firmware(Base): + __tablename__ = "firmwares" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + version: Mapped[str | None] = mapped_column(String(64)) + architecture: Mapped[str | None] = mapped_column(String(32)) + channel: Mapped[str | None] = mapped_column(String(32)) # stable/long-term/testing + size: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + sha256: Mapped[str | None] = mapped_column(String(64)) + source_url: Mapped[str | None] = mapped_column(Text) + content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/models/interface_stat.py b/backend/app/models/interface_stat.py new file mode 100644 index 0000000..ce53b3c --- /dev/null +++ b/backend/app/models/interface_stat.py @@ -0,0 +1,34 @@ +"""Метрики интерфейсов: счётчики rx/tx и состояние running. + +Фиксируется значение счётчиков (монотонно растущих, до перезагрузки), +во время каждого probe-цикла. На фронте берутся последние ~N точек, +для отрисовки графика bps вычисляется (delta/seconds). +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class InterfaceStat(Base): + __tablename__ = "interface_stats" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + device_id: Mapped[int] = mapped_column( + ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String(64), nullable=False) + rx_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + tx_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + running: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + ts: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + + __table_args__ = ( + Index("ix_iface_stats_dev_name_ts", "device_id", "name", "ts"), + ) diff --git a/backend/app/models/metric.py b/backend/app/models/metric.py new file mode 100644 index 0000000..3197af4 --- /dev/null +++ b/backend/app/models/metric.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class DeviceMetric(Base): + __tablename__ = "device_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + device_id: Mapped[int] = mapped_column( + ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, index=True + ) + cpu_load: Mapped[float | None] = mapped_column(Float) + mem_used_pct: Mapped[float | None] = mapped_column(Float) + free_memory: Mapped[int | None] = mapped_column(Integer) + total_memory: Mapped[int | None] = mapped_column(Integer) + uptime_seconds: Mapped[int | None] = mapped_column(Integer) + internet_ok: Mapped[bool | None] = mapped_column() + rx_bps: Mapped[int | None] = mapped_column(Integer) + tx_bps: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + + __table_args__ = ( + Index("ix_device_metrics_device_time", "device_id", "created_at"), + ) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..d72ded4 --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class AppSetting(Base): + __tablename__ = "app_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..5781a24 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(32), default="viewer", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 0000000..1f7f671 --- /dev/null +++ b/backend/app/schemas/alert.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class AlertOut(BaseModel): + id: int + severity: str + category: str + source: str | None = None + title: str + message: str | None = None + acknowledged: bool + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..980aad5 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class UserOut(BaseModel): + id: int + email: str + role: str + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class LoginIn(BaseModel): + email: str + password: str + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshIn(BaseModel): + refresh_token: str diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py new file mode 100644 index 0000000..9b732df --- /dev/null +++ b/backend/app/schemas/backup.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class BackupOut(BaseModel): + id: int + device_id: int + filename: str + fmt: str + size: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py new file mode 100644 index 0000000..986918a --- /dev/null +++ b/backend/app/schemas/device.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DeviceBase(BaseModel): + name: str = Field(min_length=1, max_length=128) + host: str + port: int = 8729 + use_tls: bool = True + username: str + kind: str = "router" + + +class DeviceCreate(DeviceBase): + password: str + + +class DeviceUpdate(BaseModel): + name: str | None = None + host: str | None = None + port: int | None = None + use_tls: bool | None = None + username: str | None = None + password: str | None = None + kind: str | None = None + monitored_interfaces: str | None = None + uplink_interfaces: str | None = None + interface_history_hours: int | None = None + + +class DeviceOut(DeviceBase): + id: int + identity: str | None = None + model: str | None = None + serial: str | None = None + ros_version: str | None = None + architecture: str | None = None + status: str + last_error: str | None = None + last_seen: datetime | None = None + internet_ok: bool | None = None + last_uptime_seconds: int | None = None + abnormal_reboot: bool = False + last_log_warning: str | None = None + monitored_interfaces: str | None = None + uplink_interfaces: str | None = None + interface_history_hours: int = 24 + created_at: datetime + + class Config: + from_attributes = True + + +class DeviceResource(BaseModel): + """Срез `/system/resource`.""" + cpu_load: int | None = None + free_memory: int | None = None + total_memory: int | None = None + uptime: str | None = None + version: str | None = None + board_name: str | None = None + architecture_name: str | None = None diff --git a/backend/app/schemas/firmware.py b/backend/app/schemas/firmware.py new file mode 100644 index 0000000..486b32a --- /dev/null +++ b/backend/app/schemas/firmware.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field, HttpUrl + + +class FirmwareImportIn(BaseModel): + url: HttpUrl + name: str | None = None + version: str | None = None + architecture: str | None = None + channel: str | None = None + + +class FirmwareBulkImportIn(BaseModel): + version: str = Field(..., description="Например: 7.16.1") + channel: str | None = "stable" + architectures: list[str] = Field(..., min_length=1) + + +class FirmwareBulkResult(BaseModel): + architecture: str + ok: bool + firmware_id: int | None = None + error: str | None = None + skipped: bool = False + + +class FirmwareBulkOut(BaseModel): + version: str + channel: str | None + results: list[FirmwareBulkResult] + + +class FirmwareUpdateIn(BaseModel): + name: str | None = Field(default=None, max_length=255) + version: str | None = None + architecture: str | None = None + channel: str | None = None + + +class FirmwareOut(BaseModel): + id: int + name: str + version: str | None + architecture: str | None + channel: str | None + size: int + sha256: str | None + source_url: str | None + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/metric.py b/backend/app/schemas/metric.py new file mode 100644 index 0000000..7f0e249 --- /dev/null +++ b/backend/app/schemas/metric.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class MetricPoint(BaseModel): + ts: datetime + cpu_load: float | None = None + mem_used_pct: float | None = None + uptime_seconds: int | None = None + internet_ok: bool | None = None + rx_bps: int | None = None + tx_bps: int | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/backup_ftp_server.py b/backend/app/services/backup_ftp_server.py new file mode 100644 index 0000000..74b3450 --- /dev/null +++ b/backend/app/services/backup_ftp_server.py @@ -0,0 +1,215 @@ +"""Встроенный FTP-сервер для приёма push-бэкапов от MikroTik. + +Идея: вместо того чтобы открывать ssh/ftp на каждом устройстве и тянуть +с него файл, контроллер сам поднимает FTP на отдельном порту и выдаёт +устройству одноразовые креды. Устройство выполняет: + + /tool fetch upload=yes mode=ftp address= port=

\ + user= password=

src-path= dst-path= + +Файлы складываются во временную директорию сессии. По завершении +загрузки коллбэк `on_file_received` маркирует файл как готовый. +Бэкенд ждёт появления всех ожидаемых файлов и читает их. + +Реализация — `pyftpdlib.servers.ThreadedFTPServer`, поднимается +в фоновом потоке и живёт вместе с процессом backend. +""" +from __future__ import annotations + +import os +import secrets +import shutil +import socket +import tempfile +import threading +import time +from dataclasses import dataclass, field +from typing import Iterable + +from loguru import logger +from pyftpdlib.authorizers import DummyAuthorizer +from pyftpdlib.handlers import FTPHandler +from pyftpdlib.servers import ThreadedFTPServer + + +@dataclass +class _Session: + session_id: str + username: str + password: str + home_dir: str + expected: set[str] + received: dict[str, str] = field(default_factory=dict) # name -> abs path + created_at: float = field(default_factory=time.time) + + +class _Server: + def __init__(self, host: str = "0.0.0.0", port: int = 2121) -> None: + self.host = host + self.port = port + self._sessions: dict[str, _Session] = {} + self._sessions_by_user: dict[str, _Session] = {} + self._lock = threading.RLock() + self._authorizer = DummyAuthorizer() + self._server: ThreadedFTPServer | None = None + self._thread: threading.Thread | None = None + self._root_tmp = tempfile.mkdtemp(prefix="mikbak-ftp-") + + srv = self # closure для хэндлера + + class _Handler(FTPHandler): + def on_file_received(self, file: str) -> None: # type: ignore[override] + try: + user = (self.username or "").strip() + name = os.path.basename(file) + srv._mark_received(user, name, file) + except Exception as exc: # pragma: no cover + logger.warning("FTP on_file_received error: {}", exc) + + _Handler.authorizer = self._authorizer + _Handler.banner = "mikrocloud backup ftp ready" + # Пассивный диапазон фиксируем (нужно открыть в compose). + _Handler.passive_ports = range(30000, 30050) + self._handler_cls = _Handler + + # ---------- lifecycle ---------- + def start(self) -> None: + if self._server is not None: + return + self._server = ThreadedFTPServer((self.host, self.port), self._handler_cls) + self._server.max_cons = 64 + self._thread = threading.Thread( + target=self._server.serve_forever, + name="backup-ftp", + daemon=True, + ) + self._thread.start() + logger.info("Backup FTP server started on {}:{}", self.host, self.port) + + def stop(self) -> None: + if self._server is None: + return + try: + self._server.close_all() + except Exception: # pragma: no cover + pass + self._server = None + self._thread = None + try: + shutil.rmtree(self._root_tmp, ignore_errors=True) + except Exception: # pragma: no cover + pass + logger.info("Backup FTP server stopped") + + # ---------- sessions ---------- + def open_session(self, expected_files: Iterable[str]) -> _Session: + """Создаёт уникального пользователя и личный каталог.""" + with self._lock: + sid = secrets.token_hex(8) + user = f"mb_{sid}" + password = secrets.token_urlsafe(18) + home = os.path.join(self._root_tmp, sid) + os.makedirs(home, exist_ok=True) + self._authorizer.add_user(user, password, home, perm="elradfmw") + sess = _Session( + session_id=sid, + username=user, + password=password, + home_dir=home, + expected=set(expected_files), + ) + self._sessions[sid] = sess + self._sessions_by_user[user] = sess + logger.info("FTP backup session opened: sid={} user={} expected={}", + sid, user, sess.expected) + return sess + + def close_session(self, session_id: str) -> None: + with self._lock: + sess = self._sessions.pop(session_id, None) + if sess is None: + return + self._sessions_by_user.pop(sess.username, None) + try: + self._authorizer.remove_user(sess.username) + except Exception: # pragma: no cover + pass + try: + shutil.rmtree(sess.home_dir, ignore_errors=True) + except Exception: # pragma: no cover + pass + logger.info("FTP backup session closed: sid={}", session_id) + + def _mark_received(self, username: str, name: str, abs_path: str) -> None: + with self._lock: + sess = self._sessions_by_user.get(username) + if sess is None: + logger.warning("FTP upload from unknown user: {} ({})", username, name) + return + sess.received[name] = abs_path + logger.info("FTP backup file received: sid={} name={} size={}b", + sess.session_id, name, os.path.getsize(abs_path)) + + def wait_files(self, session_id: str, timeout: float = 60.0) -> dict[str, bytes]: + """Ожидает поступления всех expected-файлов и возвращает их содержимое.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._lock: + sess = self._sessions.get(session_id) + if sess is None: + raise RuntimeError(f"session {session_id} not found") + missing = sess.expected - set(sess.received.keys()) + if not missing: + out: dict[str, bytes] = {} + for name, path in sess.received.items(): + with open(path, "rb") as f: + out[name] = f.read() + return out + time.sleep(0.3) + with self._lock: + sess = self._sessions.get(session_id) + missing = sess.expected - set(sess.received.keys()) if sess else set() + raise TimeoutError(f"backup files not received: missing={sorted(missing)}") + + +_INSTANCE: _Server | None = None +_INSTANCE_LOCK = threading.Lock() + + +def get_server() -> _Server | None: + return _INSTANCE + + +def start_server(host: str = "0.0.0.0", port: int = 2121) -> _Server: + global _INSTANCE + with _INSTANCE_LOCK: + if _INSTANCE is None: + _INSTANCE = _Server(host=host, port=port) + _INSTANCE.start() + return _INSTANCE + + +def stop_server() -> None: + global _INSTANCE + with _INSTANCE_LOCK: + if _INSTANCE is not None: + _INSTANCE.stop() + _INSTANCE = None + + +def detect_push_host(default: str | None = None) -> str: + """Подсказка: IP контроллера, как его видят устройства. + Берётся через udp-сокет к 8.8.8.8 (соединение не открывается). + Используется fallback, если в ENV не задан BACKUP_PUSH_HOST. + """ + if default: + return default + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.3) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "0.0.0.0" diff --git a/backend/app/services/controller_backup.py b/backend/app/services/controller_backup.py new file mode 100644 index 0000000..f02bb1a --- /dev/null +++ b/backend/app/services/controller_backup.py @@ -0,0 +1,150 @@ +"""Бэкап самого контроллера: дамп БД и/или конфигурации.""" +from __future__ import annotations + +import io +import json +import os +import subprocess +import tarfile +from datetime import datetime, timezone + +from loguru import logger + +from ..core.config import get_settings + + +def _safe_settings_dump() -> dict: + s = get_settings() + data = s.model_dump() + # маскируем секреты + for k in list(data.keys()): + if any(x in k.lower() for x in ("password", "secret", "key")): + data[k] = "***" + return data + + +def make_config_only_archive() -> tuple[str, bytes]: + """Tar.gz с настройками контроллера (без БД).""" + buf = io.BytesIO() + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + name = f"controller-config-{ts}.tar.gz" + + settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode() + + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo(name="settings.json") + info.size = len(settings_json) + info.mtime = int(datetime.now().timestamp()) + tar.addfile(info, io.BytesIO(settings_json)) + + readme = ( + b"ROSzetta - config-only backup\n" + b"Contains masked settings.json (no DB, no secrets).\n" + ) + info2 = tarfile.TarInfo(name="README.txt") + info2.size = len(readme) + info2.mtime = int(datetime.now().timestamp()) + tar.addfile(info2, io.BytesIO(readme)) + + return name, buf.getvalue() + + +def _dump_database() -> bytes: + """Возвращает pg_dump БД (custom-format) либо raise.""" + s = get_settings() + # parse postgresql+psycopg2://user:pass@host:port/db + url = s.database_url.replace("postgresql+psycopg2://", "postgresql://") + cmd = ["pg_dump", "-Fc", url] + logger.info("running pg_dump") + try: + out = subprocess.run( + cmd, + check=True, + capture_output=True, + timeout=300, + env={**os.environ}, + ) + except FileNotFoundError as exc: + raise RuntimeError("pg_dump not installed in backend image") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"pg_dump failed: {exc.stderr.decode(errors='replace')[:400]}") from exc + return out.stdout + + +def make_full_archive() -> tuple[str, bytes]: + """Tar.gz с дампом БД + settings.json.""" + buf = io.BytesIO() + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + name = f"controller-full-{ts}.tar.gz" + + db_dump = _dump_database() + settings_json = json.dumps(_safe_settings_dump(), indent=2, default=str).encode() + + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for fname, data in [ + ("db.dump", db_dump), + ("settings.json", settings_json), + ( + "README.txt", + b"ROSzetta - full backup\n" + b"Restore: pg_restore -d db.dump\n", + ), + ]: + info = tarfile.TarInfo(name=fname) + info.size = len(data) + info.mtime = int(datetime.now().timestamp()) + tar.addfile(info, io.BytesIO(data)) + + return name, buf.getvalue() + + +def restore_full_archive(data: bytes) -> dict: + """Разворачивает full-бэкап: дроп схемы public + pg_restore из db.dump в архиве. + + ВНИМАНИЕ: операция деструктивна. Текущая БД будет полностью заменена. + """ + s = get_settings() + try: + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + try: + member = tar.getmember("db.dump") + except KeyError as exc: + raise RuntimeError("Архив не содержит db.dump (нужен full backup)") from exc + f = tar.extractfile(member) + if f is None: + raise RuntimeError("Не удалось прочитать db.dump из архива") + dump_bytes = f.read() + except tarfile.TarError as exc: + raise RuntimeError(f"Невалидный tar.gz: {exc}") from exc + + url = s.database_url.replace("postgresql+psycopg2://", "postgresql://") + + logger.warning("controller restore: dropping schema public") + try: + subprocess.run( + ["psql", url, "-v", "ON_ERROR_STOP=1", "-c", + "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"], + check=True, capture_output=True, timeout=60, env={**os.environ}, + ) + except FileNotFoundError as exc: + raise RuntimeError("psql not installed in backend image") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"psql DROP SCHEMA failed: {exc.stderr.decode(errors='replace')[:400]}") from exc + + logger.warning("controller restore: running pg_restore ({} bytes)", len(dump_bytes)) + try: + proc = subprocess.run( + ["pg_restore", "--no-owner", "--no-privileges", "-d", url], + input=dump_bytes, + check=True, capture_output=True, timeout=600, env={**os.environ}, + ) + except FileNotFoundError as exc: + raise RuntimeError("pg_restore not installed in backend image") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"pg_restore failed: {exc.stderr.decode(errors='replace')[:400]}") from exc + + return { + "ok": True, + "message": "Бэкап успешно развёрнут. Перезайдите в систему — данные обновлены.", + "stderr": proc.stderr.decode(errors='replace')[:400] if proc.stderr else "", + } diff --git a/backend/app/services/events.py b/backend/app/services/events.py new file mode 100644 index 0000000..81e2732 --- /dev/null +++ b/backend/app/services/events.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models.alert import Alert +from .settings import get_settings_dict, severity_meets +from . import telegram as tg + + +# Соответствие категории алерта ключу notify-toggle. +_NOTIFY_KEY_BY_CATEGORY = { + "device": "device_status", + "internet": "internet", + "abnormal_reboot": "abnormal_reboot", + "firmware": "firmware", +} + + +def add_alert( + db: Session, + *, + title: str, + severity: str = "info", + category: str = "system", + source: str | None = None, + message: str | None = None, +) -> Alert | None: + """Создаёт алерт с учётом включенных нотификаций. Возвращает None, если категория отключена.""" + cfg = get_settings_dict(db) + notify_cfg = cfg.get("notify", {}) + notify_key = _NOTIFY_KEY_BY_CATEGORY.get(category) + if notify_key is not None and notify_cfg.get(notify_key) is False: + return None + + a = Alert( + title=title, + severity=severity, + category=category, + source=source, + message=message, + ) + db.add(a) + db.commit() + db.refresh(a) + + tg_cfg = cfg.get("telegram", {}) + if tg_cfg.get("enabled") and severity_meets(severity, tg_cfg.get("min_severity", "warning")): + text = f"[{severity.upper()}] {title}" + if message: + text += f"\n{message}" + if source: + text += f"\nsrc: {source}" + tg.send_message(tg_cfg.get("bot_token", ""), tg_cfg.get("chat_id", ""), text) + + return a + + +def add_audit(*args, **kwargs) -> None: + """No-op. Аудит-логи удалены, функция оставлена как заглушка для совместимости.""" + return None diff --git a/backend/app/services/firmware_check.py b/backend/app/services/firmware_check.py new file mode 100644 index 0000000..9446741 --- /dev/null +++ b/backend/app/services/firmware_check.py @@ -0,0 +1,103 @@ +"""Сервис проверки новых версий прошивок MikroTik по нескольким каналам.""" +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone + +import httpx +from loguru import logger +from sqlalchemy.orm import Session + +from ..models.settings import AppSetting +from .events import add_alert + +# Каналы и URL-ы для проверки. +CHANNELS: dict[str, str] = { + "stable": "https://download.mikrotik.com/routeros/NEWESTa7.stable", + "long-term": "https://download.mikrotik.com/routeros/NEWESTa7.long-term", + "testing": "https://download.mikrotik.com/routeros/NEWESTa7.testing", +} + +STATE_KEY = "firmware_state" + + +def _fetch_channel(url: str, timeout: float = 10.0) -> tuple[str, datetime] | None: + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as cli: + r = cli.get(url) + r.raise_for_status() + text = r.text.strip() + except httpx.HTTPError as exc: + logger.warning("firmware check: HTTP error for {}: {}", url, exc) + return None + m = re.match(r"(\S+)\s+(\d+)", text) + if not m: + logger.warning("firmware check: unexpected response for {}: {!r}", url, text[:120]) + return None + return m.group(1), datetime.fromtimestamp(int(m.group(2)), tz=timezone.utc) + + +def _load_state(db: Session) -> dict: + row = db.query(AppSetting).filter(AppSetting.key == STATE_KEY).first() + if not row: + return {} + try: + return json.loads(row.value) or {} + except Exception: + return {} + + +def _save_state(db: Session, state: dict) -> None: + row = db.query(AppSetting).filter(AppSetting.key == STATE_KEY).first() + if not row: + row = AppSetting(key=STATE_KEY, value=json.dumps(state)) + db.add(row) + else: + row.value = json.dumps(state) + db.commit() + + +def get_state(db: Session) -> dict: + """Состояние проверок по каналам: {channel: {version, released_at, last_check}}.""" + return _load_state(db) + + +def fetch_latest_version(timeout: float = 10.0) -> tuple[str, datetime] | None: + """Backwards-compat: возвращает только stable.""" + return _fetch_channel(CHANNELS["stable"], timeout=timeout) + + +def check_and_alert(db: Session) -> dict: + """Проверяет все каналы. При появлении новой версии создаёт alert. Возвращает обновлённый state.""" + state = _load_state(db) + now_iso = datetime.now(timezone.utc).isoformat() + for channel, url in CHANNELS.items(): + res = _fetch_channel(url) + prev = (state.get(channel) or {}).get("version") + if res is None: + # сохраняем last_check всё равно, чтобы видеть попытку + state.setdefault(channel, {})["last_check"] = now_iso + state[channel]["last_check_ok"] = False + continue + version, released_at = res + state[channel] = { + "version": version, + "released_at": released_at.isoformat(), + "last_check": now_iso, + "last_check_ok": True, + } + if prev and prev != version: + add_alert( + db, + severity="info", + category="firmware", + source=f"mikrotik.com/{channel}", + title=f"RouterOS {channel}: новая версия {version}", + message=f"Предыдущая отслеживаемая: {prev}", + ) + logger.info("firmware check {}: new version {} (was {})", channel, version, prev) + elif not prev: + logger.info("firmware check {}: initial = {}", channel, version) + _save_state(db, state) + return state diff --git a/backend/app/services/routeros/__init__.py b/backend/app/services/routeros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/routeros/backup.py b/backend/app/services/routeros/backup.py new file mode 100644 index 0000000..9e6ede7 --- /dev/null +++ b/backend/app/services/routeros/backup.py @@ -0,0 +1,177 @@ +"""Создание бэкапа конфигурации MikroTik с PUSH-доставкой на контроллер. + +Поток: +1. На устройстве запускается `/system/backup/save name=...` и `/export file=...`. +2. Ждём появления файлов в `/file`. +3. Контроллер открывает в своём встроенном FTP-сервере одноразовую сессию + (уникальный пользователь/пароль, изолированный каталог). +4. На устройстве выполняется `/tool fetch upload=yes mode=ftp ...`, + которое отправляет файлы НА контроллер. На MikroTik не нужно включать + ftp/ssh — нужен только исходящий доступ к контроллеру. +5. Бэкенд читает файлы из каталога сессии, удаляет файлы с устройства, + закрывает FTP-сессию и возвращает байты. +""" +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +from loguru import logger + +from .client import RouterOSCredentials, RouterOSError, routeros_session +from ..backup_ftp_server import detect_push_host, get_server, start_server + + +@dataclass +class BackupFiles: + binary_name: str + binary_data: bytes + text_name: str + text_data: bytes + + +# ---------- helpers вокруг librouteros ---------- + +def _exec_path(api: Any, *path: str, **params: Any) -> list[dict[str, Any]]: + """Выполнить RouterOS-команду. Последний сегмент — имя cmd для librouteros. + + Пример: _exec_path(api, "system", "backup", "save", name="x") + => api.path("system", "backup")("save", name="x") + """ + if not path: + raise RouterOSError("_exec_path requires at least one path segment") + *base, cmd = path + p = api.path(*base) if base else api.path() + return list(p(cmd, **params)) + + +def _list_files(api: Any) -> list[dict[str, Any]]: + return list(api.path("file")) + + +def _wait_file(api: Any, name: str, timeout: float = 15.0) -> dict[str, Any] | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for row in _list_files(api): + if row.get("name") == name and int(row.get("size") or 0) > 0: + return row + time.sleep(0.5) + return None + + +def _delete_file(api: Any, name: str) -> None: + try: + for row in _list_files(api): + if row.get("name") == name: + api.path("file").remove(row[".id"]) + return + except Exception as exc: # pragma: no cover + logger.warning("Could not delete file {} on device: {}", name, exc) + + +# ---------- главный сценарий ---------- + +def create_backup_via_push( + creds: RouterOSCredentials, + base_name: str, + push_host: str, + push_port: int = 2121, + timeout: float = 90.0, +) -> BackupFiles: + """Полный цикл: создать backup+export на устройстве, дождаться upload на контроллер.""" + binary_name = f"{base_name}.backup" + text_name = f"{base_name}.rsc" + + server = get_server() or start_server(port=push_port) + session = server.open_session(expected_files={binary_name, text_name}) + + try: + logger.info( + "Backup PUSH: device={} base={} push={}:{} user={}", + creds.host, base_name, push_host, push_port, session.username, + ) + with routeros_session(creds) as api: + # 1) бинарный backup + try: + _exec_path(api, "system", "backup", "save", name=base_name) + except Exception as exc: + raise RouterOSError(f"backup save failed: {exc}") from exc + if _wait_file(api, binary_name) is None: + raise RouterOSError(f"backup file {binary_name} not appeared on device") + + # 2) текстовый export + try: + _exec_path(api, "export", file=base_name) + except Exception as exc: + raise RouterOSError(f"export failed: {exc}") from exc + if _wait_file(api, text_name) is None: + raise RouterOSError(f"export file {text_name} not appeared on device") + + # 3) push обоих файлов + for fname in (binary_name, text_name): + try: + _exec_path( + api, "tool", "fetch", + **{ + "upload": "yes", + "mode": "ftp", + "address": push_host, + "port": str(push_port), + "user": session.username, + "password": session.password, + "src-path": fname, + "dst-path": fname, + }, + ) + except Exception as exc: + raise RouterOSError(f"push {fname} failed: {exc}") from exc + + # 4) ждём, пока FTP-сервер контроллера получит оба + try: + files = server.wait_files(session.session_id, timeout=timeout) + except TimeoutError as exc: + raise RouterOSError(str(exc)) from exc + + if binary_name not in files or text_name not in files: + raise RouterOSError(f"unexpected push contents: got={sorted(files.keys())}") + + # 5) подчищаем флэш на устройстве + try: + with routeros_session(creds) as api: + _delete_file(api, binary_name) + _delete_file(api, text_name) + except Exception as exc: # pragma: no cover + logger.warning("Cleanup failed for {}: {}", base_name, exc) + + binary_data = files[binary_name] + text_data = files[text_name] + logger.info( + "Backup PUSH ok: {} binary={}b text={}b", + base_name, len(binary_data), len(text_data), + ) + return BackupFiles( + binary_name=binary_name, + binary_data=binary_data, + text_name=text_name, + text_data=text_data, + ) + finally: + try: + server.close_session(session.session_id) + except Exception: # pragma: no cover + pass + + +# Обратно-совместимый алиас — используется существующими роутами. +def create_and_download_backup( + creds: RouterOSCredentials, + base_name: str, + push_host: str | None = None, + push_port: int = 2121, + **_legacy: Any, +) -> BackupFiles: + """Совместимая обёртка: принимает push_host/port вместо ssh/ftp_port.""" + if not push_host: + push_host = detect_push_host() + return create_backup_via_push(creds, base_name, push_host=push_host, push_port=push_port) diff --git a/backend/app/services/routeros/client.py b/backend/app/services/routeros/client.py new file mode 100644 index 0000000..29760f2 --- /dev/null +++ b/backend/app/services/routeros/client.py @@ -0,0 +1,283 @@ +"""Тонкий враппер вокруг librouteros для синхронных вызовов из API/воркеров.""" +from __future__ import annotations + +import socket +import ssl +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Iterator + +from librouteros import connect +from librouteros.exceptions import LibRouterosError +from librouteros.login import plain +from loguru import logger + + +class RouterOSError(RuntimeError): + pass + + +@dataclass +class RouterOSCredentials: + host: str + username: str + password: str + # По умолчанию api-ssl: порт 8729 + TLS. plain api (8728) можно использовать + # для legacy-устройств, явно передав port=8728, use_tls=False. + port: int = 8729 + use_tls: bool = True + timeout: float = 5.0 + + +@contextmanager +def routeros_session(creds: RouterOSCredentials) -> Iterator[Any]: + kwargs: dict[str, Any] = { + "port": creds.port, + "timeout": creds.timeout, + "login_method": plain, + } + if creds.use_tls: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + kwargs["ssl_wrapper"] = ctx.wrap_socket + + try: + api = connect( + host=creds.host, + username=creds.username, + password=creds.password, + **kwargs, + ) + logger.info("RouterOS connected: {}:{} user={}", creds.host, creds.port, creds.username) + except (LibRouterosError, OSError, socket.timeout) as exc: + logger.warning( + "RouterOS connection failed: {}:{} user={} reason={}", + creds.host, creds.port, creds.username, exc, + ) + raise RouterOSError(f"connect {creds.host}:{creds.port} failed: {exc}") from exc + + try: + yield api + finally: + try: + api.close() + except Exception: # pragma: no cover + pass + + +def fetch_resource(creds: RouterOSCredentials) -> dict[str, Any]: + """Возвращает первую запись `/system/resource`.""" + with routeros_session(creds) as api: + rows = list(api.path("system", "resource")) + return rows[0] if rows else {} + + +def fetch_identity(creds: RouterOSCredentials) -> str | None: + with routeros_session(creds) as api: + rows = list(api.path("system", "identity")) + if not rows: + return None + return rows[0].get("name") + + +def fetch_interfaces(creds: RouterOSCredentials) -> list[dict[str, Any]]: + with routeros_session(creds) as api: + return list(api.path("interface")) + + +def cmd_reboot(creds: RouterOSCredentials) -> None: + """Перезагрузить устройство (/system/reboot).""" + logger.info("Sending reboot to {}:{}", creds.host, creds.port) + with routeros_session(creds) as api: + tuple(api.path("system", "reboot")) + + +def cmd_safe_mode(creds: RouterOSCredentials) -> None: + """Войти в safe mode (/system/safe-mode) — отправляет команду, устройство + подтвердит переход (RouterOS 7+). Если устройство уже в safe mode, + команда завершает его.""" + logger.info("Toggling safe-mode on {}:{}", creds.host, creds.port) + with routeros_session(creds) as api: + tuple(api.path("system", "safe-mode")) + + +def check_internet(creds: RouterOSCredentials, target: str = "8.8.8.8") -> bool: + """Проверка интернет-доступа на устройстве через `/ping count=1`.""" + try: + with routeros_session(creds) as api: + rows = list(api(cmd="/ping", address=target, count="2")) + for row in rows: + recv = int(row.get("received") or 0) + if recv > 0: + return True + return False + except (RouterOSError, Exception) as exc: + logger.warning("internet check failed for {}: {}", creds.host, exc) + return False + + +def parse_uptime(uptime: str | None) -> int | None: + """Парсит RouterOS uptime '1w2d3h4m5s' → секунды.""" + if not uptime: + return None + import re + units = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1} + total = 0 + for value, unit in re.findall(r"(\d+)([wdhms])", uptime): + total += int(value) * units[unit] + return total or None + + +def execute_cli(creds: RouterOSCredentials, command: str) -> list[dict[str, Any]]: + """Выполнить произвольную команду RouterOS API. + + Команда должна быть в формате RouterOS API path-style, например: + `/system/identity/print` + `/interface/print` + `/ip/address/print where interface=ether1` + + Дополнительные параметры через `name=value` после команды. + Возвращает список словарей-результатов. + """ + parts = command.strip().split() + if not parts: + raise RouterOSError("empty command") + cmd = parts[0] + if not cmd.startswith("/"): + raise RouterOSError("command must start with '/'") + kwargs: dict[str, str] = {} + where: dict[str, str] = {} + in_where = False + for token in parts[1:]: + if token == "where": + in_where = True + continue + if "=" in token: + k, v = token.split("=", 1) + (where if in_where else kwargs)[k] = v + logger.info("CLI exec on {}: {} args={} where={}", creds.host, cmd, kwargs, where) + try: + with routeros_session(creds) as api: + res = api(cmd=cmd, **kwargs) + rows = list(res) + if where: + rows = [r for r in rows if all(str(r.get(k)) == v for k, v in where.items())] + return rows + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"cli failed: {exc}") from exc + + +# ---------- Sprint 09 helpers ---------- + +def fetch_interface_stats(creds: RouterOSCredentials) -> list[dict[str, Any]]: + """Список интерфейсов со счётчиками rx/tx и флагом running. + + Возвращает: [{"name", "rx_bytes", "tx_bytes", "running", "type", "comment"}]. + """ + out: list[dict[str, Any]] = [] + try: + with routeros_session(creds) as api: + for r in api.path("interface"): + def _i(v: Any) -> int: + try: + return int(v) + except (TypeError, ValueError): + return 0 + running = str(r.get("running", "")).lower() == "true" + disabled = str(r.get("disabled", "")).lower() == "true" + out.append({ + "name": r.get("name"), + "rx_bytes": _i(r.get("rx-byte")), + "tx_bytes": _i(r.get("tx-byte")), + "running": running, + "disabled": disabled, + "type": r.get("type"), + "comment": r.get("comment") or None, + "mac_address": r.get("mac-address") or None, + }) + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"interface stats failed: {exc}") from exc + return out + + +def fetch_dhcp_leases(creds: RouterOSCredentials) -> list[dict[str, Any]]: + """Все лизы DHCP-сервера на устройстве.""" + out: list[dict[str, Any]] = [] + try: + with routeros_session(creds) as api: + for r in api.path("ip", "dhcp-server", "lease"): + out.append({ + "address": r.get("address"), + "mac_address": r.get("mac-address"), + "host_name": r.get("host-name") or r.get("comment"), + "comment": r.get("comment") or None, + "server": r.get("server"), + "status": r.get("status"), + "dynamic": str(r.get("dynamic", "")).lower() == "true", + "blocked": str(r.get("blocked", "")).lower() == "true", + "last_seen": r.get("last-seen"), + "expires_after": r.get("expires-after"), + }) + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"dhcp leases failed: {exc}") from exc + return out + + +def cmd_upgrade_check(creds: RouterOSCredentials, channel: str = "stable") -> dict[str, Any]: + """Запросить у MikroTik проверку доступного обновления и инициировать + /system/package/update/check-for-updates. Возвращает текущее состояние.""" + try: + with routeros_session(creds) as api: + try: + tuple(api.path("system", "package", "update").call("set", + **{"channel": channel})) + except Exception: + pass + try: + tuple(api(cmd="/system/package/update/check-for-updates")) + except Exception: + pass + rows = list(api.path("system", "package", "update")) + return rows[0] if rows else {} + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"upgrade check failed: {exc}") from exc + + +def cmd_upgrade_install(creds: RouterOSCredentials) -> None: + """Запустить установку обновления (устройство ребутнётся).""" + try: + with routeros_session(creds) as api: + tuple(api(cmd="/system/package/update/install")) + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"upgrade install failed: {exc}") from exc + + +def push_firmware_via_ftp( + creds: RouterOSCredentials, + server: str, + port: int, + user: str, + password: str, + src_path: str, + dst_filename: str, +) -> None: + """Загрузить файл с FTP-сервера контроллера на устройство (`/tool/fetch download`). + Используется для установки прошивки из локального репозитория без выгрузки на устройство. + """ + url = f"ftp://{server}:{port}/{src_path}" + try: + with routeros_session(creds) as api: + tuple(api( + cmd="/tool/fetch", + url=url, user=user, password=password, + mode="ftp", **{"dst-path": dst_filename}, + )) + except (LibRouterosError, OSError) as exc: + raise RouterOSError(f"fetch firmware failed: {exc}") from exc + + +def cmd_reboot_for_upgrade(creds: RouterOSCredentials) -> None: + """`/system/reboot` — после загрузки .npk RouterOS установит апдейт при загрузке.""" + cmd_reboot(creds) + diff --git a/backend/app/services/settings.py b/backend/app/services/settings.py new file mode 100644 index 0000000..e9cf6c1 --- /dev/null +++ b/backend/app/services/settings.py @@ -0,0 +1,89 @@ +"""Глобальные настройки контроллера: хранятся в БД как один JSON-блоб (key='global').""" +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy.orm import Session + +from ..models.settings import AppSetting + +KEY = "global" + +# Дефолтные значения. Нельзя менять ключи — только добавлять новые. +DEFAULTS: dict[str, Any] = { + # Брендинг и локализация интерфейса + "ui": { + "instance_name": "ROSzetta", # отображается в шапке + "locale": "ru", # ru | en | uz + "theme": "mk-dark", # см. фронтенд theme.ts + "heartbeat_hours": 6, # окно heartbeat-сетки на дашборде: 6 | 3 | 1 | 0.5 + "probe_interval_minutes": 5, # автоопрос устройств: 1 | 2 | 3 | 5 | 10 + }, + # Видимость пунктов меню + "menu": { + "dashboard": True, + "devices": True, + "switches": True, + "firmware": True, + "notif_center": True, + "cli": True, + "settings": True, + }, + # Включение/отключение генерации алертов и учёта в global health + "notify": { + "device_status": True, # переход up<->down + "internet": True, # отсутствие интернета на устройстве + "abnormal_reboot": True, # аномальная перезагрузка + "firmware": True, # вышла новая версия RouterOS + "style": "jokes", # стиль сообщений GlobalHealth: jokes | serious + }, + # Telegram-бот (опциональная отправка алертов) + "telegram": { + "enabled": False, + "bot_token": "", + "chat_id": "", + "min_severity": "warning", # info|warning|error|critical + }, +} + + +def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + out = dict(base) + for k, v in override.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _merge(out[k], v) + else: + out[k] = v + return out + + +def get_settings_dict(db: Session) -> dict[str, Any]: + row = db.query(AppSetting).filter(AppSetting.key == KEY).first() + if not row: + return json.loads(json.dumps(DEFAULTS)) + try: + stored = json.loads(row.value) + except Exception: + stored = {} + return _merge(DEFAULTS, stored if isinstance(stored, dict) else {}) + + +def update_settings_dict(db: Session, patch: dict[str, Any]) -> dict[str, Any]: + current = get_settings_dict(db) + merged = _merge(current, patch) + row = db.query(AppSetting).filter(AppSetting.key == KEY).first() + if not row: + row = AppSetting(key=KEY, value=json.dumps(merged)) + db.add(row) + else: + row.value = json.dumps(merged) + db.commit() + return merged + + +_SEVERITY_RANK = {"info": 0, "warning": 1, "error": 2, "critical": 3} + + +def severity_meets(actual: str, threshold: str) -> bool: + return _SEVERITY_RANK.get(actual, 0) >= _SEVERITY_RANK.get(threshold, 1) diff --git a/backend/app/services/telegram.py b/backend/app/services/telegram.py new file mode 100644 index 0000000..1f92dcb --- /dev/null +++ b/backend/app/services/telegram.py @@ -0,0 +1,31 @@ +"""Опциональная отправка сообщений в Telegram-бот.""" +from __future__ import annotations + +import httpx +from loguru import logger + + +def send_message(bot_token: str, chat_id: str, text: str) -> bool: + if not bot_token or not chat_id: + return False + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + try: + r = httpx.post( + url, + json={"chat_id": chat_id, "text": text, "parse_mode": "HTML", "disable_web_page_preview": True}, + timeout=8.0, + ) + if r.status_code != 200: + logger.warning("telegram send failed: {} {}", r.status_code, r.text[:200]) + return False + return True + except Exception as exc: # noqa: BLE001 + logger.warning("telegram send error: {}", exc) + return False + + +def test_credentials(bot_token: str, chat_id: str) -> tuple[bool, str]: + if not bot_token or not chat_id: + return False, "Не заданы bot_token или chat_id" + ok = send_message(bot_token, chat_id, "ROSzetta\nТестовое сообщение \u2705") + return (ok, "OK" if ok else "Не удалось отправить (см. логи)") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..276b5d4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic[email]==2.9.2 +pydantic-settings==2.5.2 +email-validator==2.2.0 +SQLAlchemy==2.0.35 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +alembic==1.13.3 +passlib[bcrypt]==1.7.4 +bcrypt==4.2.0 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.12 +httpx==0.27.2 +librouteros==3.4.1 +paramiko==3.5.0 +pysnmp==6.2.6 +redis==5.1.1 +celery==5.4.0 +boto3==1.35.36 +cryptography==43.0.1 +loguru==0.7.2 +APScheduler==3.10.4 +pyftpdlib==1.5.10 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..867cc82 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,10 @@ +POSTGRES_USER=mikrocloud +POSTGRES_PASSWORD=mikrocloud +POSTGRES_DB=mikrocloud + +MINIO_ROOT_USER=minio +MINIO_ROOT_PASSWORD=minio12345 + +SECRET_KEY=please-change-this-32-bytes-secret-now +BOOTSTRAP_ADMIN_EMAIL=admin +BOOTSTRAP_ADMIN_PASSWORD=admin diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..b62a8f3 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,90 @@ +name: mikrocloud + +services: + postgres: + image: timescale/timescaledb:2.16.1-pg16 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-mikrocloud} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mikrocloud} + POSTGRES_DB: ${POSTGRES_DB:-mikrocloud} + volumes: + - pg_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + + minio: + image: minio/minio:RELEASE.2024-09-22T00-33-43Z + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio12345} + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + + backend: + build: + context: ../backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + APP_ENV: dev + SECRET_KEY: ${SECRET_KEY:-please-change-this-32-bytes-secret-now} + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-mikrocloud}:${POSTGRES_PASSWORD:-mikrocloud}@postgres:5432/${POSTGRES_DB:-mikrocloud} + REDIS_URL: redis://redis:6379/0 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minio} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minio12345} + S3_BUCKET: mikrocloud-backups + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-admin} + CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173" + # Push-доставка бэкапов (FTP-сервер контроллера) + BACKUP_FTP_HOST: "0.0.0.0" + BACKUP_FTP_PORT: "2121" + BACKUP_PUSH_HOST: ${BACKUP_PUSH_HOST:-} + ports: + - "8000:8000" + - "2121:2121" + - "30000-30049:30000-30049" + + frontend: + build: + context: ../frontend + restart: unless-stopped + depends_on: + - backend + environment: + VITE_API_URL: http://backend:8000 + CHOKIDAR_USEPOLLING: "true" + ports: + - "80:5173" + # Bind-mount исходников хоста в контейнер, чтобы Vite HMR подхватывал правки + # без пересборки образа. Анонимный volume на node_modules защищает их от + # перекрытия (внутри образа они уже установлены). + volumes: + - ../frontend:/app + - /app/node_modules + +volumes: + pg_data: + minio_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b6b92b4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..db8e3ec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + ROSzetta + + +

+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3344bc9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2803 @@ +{ + "name": "mikrocloud-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mikrocloud-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.7", + "lucide-react": "^0.453.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.453.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", + "integrity": "sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fffdb58 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "mikrotik-controller-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0", + "lucide-react": "^0.453.0", + "recharts": "^2.13.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e008c9c --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +}; diff --git a/frontend/public/mikrotik-logo.svg b/frontend/public/mikrotik-logo.svg new file mode 100644 index 0000000..c6a8ecc --- /dev/null +++ b/frontend/public/mikrotik-logo.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c2c4537 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,48 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from '@/store/auth'; +import Login from '@/pages/Login'; +import AppLayout from '@/components/AppLayout'; +import Dashboard from '@/pages/Dashboard'; +import DevicesIndex from '@/pages/DevicesIndex'; +import DeviceDetail from '@/pages/DeviceDetail'; +import AlertsPage from '@/pages/Alerts'; +import CLIPage from '@/pages/CLI'; +import NotificationCenter from '@/pages/NotificationCenter'; +import SettingsPage from '@/pages/Settings'; + +function Protected({ children }: { children: JSX.Element }) { + const token = useAuth((s) => s.accessToken); + if (!token) return ; + return children; +} + +export default function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c57c303 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,234 @@ +import axios from 'axios'; +import { useAuth } from '@/store/auth'; + +export const api = axios.create({ + baseURL: '/api/v1', + timeout: 15000, +}); + +api.interceptors.request.use((config) => { + const token = useAuth.getState().accessToken; + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + (r) => r, + (err) => { + if (err?.response?.status === 401) { + useAuth.getState().logout(); + } + return Promise.reject(err); + }, +); + +export interface Device { + id: number; + name: string; + kind: 'router' | 'switch' | string; + host: string; + port: number; + use_tls: boolean; + username: string; + identity: string | null; + model: string | null; + serial: string | null; + ros_version: string | null; + architecture: string | null; + status: 'up' | 'down' | 'unknown' | string; + last_error: string | null; + last_seen: string | null; + internet_ok: boolean | null; + last_uptime_seconds: number | null; + abnormal_reboot: boolean; + last_log_warning: string | null; + monitored_interfaces: string | null; + uplink_interfaces: string | null; + interface_history_hours: number; + created_at: string; +} + +export interface InterfaceInfo { + name: string; + rx_bytes: number; + tx_bytes: number; + running: boolean; + disabled?: boolean; + type: string | null; + comment: string | null; + mac_address?: string | null; +} + +export interface InterfaceTrafficPoint { + ts: string; + rx_bps: number | null; + tx_bps: number | null; + running: boolean; +} + +export interface InterfaceTrafficOut { + series: Record; + hours: number; +} + +export interface UplinkStatus { + name: string; + running: boolean | null; + ts: string | null; +} + +export interface DhcpLease { + address: string; + mac_address: string; + host_name: string | null; + comment: string | null; + server: string | null; + status: string | null; + dynamic: boolean; + blocked: boolean; + last_seen: string | null; + expires_after: string | null; +} + +export interface DeviceResource { + cpu_load: number | null; + free_memory: number | null; + total_memory: number | null; + uptime: string | null; + version: string | null; + board_name: string | null; + architecture_name: string | null; +} + +export interface DeviceBackup { + id: number; + device_id: number; + filename: string; + fmt: 'binary' | 'text' | string; + size: number; + created_at: string; +} + +export interface Firmware { + id: number; + name: string; + version: string | null; + architecture: string | null; + channel: string | null; + size: number; + sha256: string | null; + source_url: string | null; + created_at: string; +} + +export interface Alert { + id: number; + severity: 'info' | 'warning' | 'error' | 'critical' | string; + category: string; + source: string | null; + title: string; + message: string | null; + acknowledged: boolean; + created_at: string; +} + +export interface MetricPoint { + ts: string; + cpu_load: number | null; + mem_used_pct: number | null; + uptime_seconds: number | null; + internet_ok: boolean | null; + rx_bps: number | null; + tx_bps: number | null; +} + + +export interface CLIDeviceResult { + device_id: number; + device_name: string | null; + ok: boolean; + rows: Record[] | null; + error: string | null; +} + +export interface CLIRunOut { + command: string; + results: CLIDeviceResult[]; +} + +export interface AppSettings { + ui: { + instance_name: string; + locale: 'ru' | 'en' | 'uz' | string; + theme: string; + heartbeat_hours: number; + probe_interval_minutes: number; + }; + menu: { + dashboard: boolean; + devices: boolean; + switches: boolean; + firmware: boolean; + notif_center: boolean; + cli: boolean; + settings: boolean; + }; + notify: { + device_status: boolean; + internet: boolean; + abnormal_reboot: boolean; + firmware: boolean; + style: 'jokes' | 'serious'; + }; + telegram: { + enabled: boolean; + bot_token: string; + chat_id: string; + min_severity: 'info' | 'warning' | 'error' | 'critical' | string; + }; +} + +export interface FirmwareChannelInfo { + version?: string; + released_at?: string; + last_check?: string; + last_check_ok?: boolean; +} + +export interface FirmwareChannelsOut { + channels: Record; + available_channels: string[]; + architectures: string[]; +} + +export interface FirmwareBulkResult { + architecture: string; + ok: boolean; + firmware_id: number | null; + error: string | null; + skipped?: boolean; +} + +export interface FirmwareBulkOut { + version: string; + channel: string | null; + results: FirmwareBulkResult[]; +} + +export type HeartbeatBucket = 'up' | 'no-net' | 'down' | 'none'; + +export interface HeartbeatDevice { + id: number; + name: string; + host: string; + status: string; + buckets: HeartbeatBucket[]; +} + +export interface HeartbeatOut { + since: string; + until: string; + bins: number; + hours: number; + devices: HeartbeatDevice[]; +} diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx new file mode 100644 index 0000000..17dde2e --- /dev/null +++ b/frontend/src/components/AboutModal.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import { api } from '@/api/client'; + +export default function AboutModal({ onClose }: { onClose: () => void }) { + const [info, setInfo] = useState<{ name: string; version: string } | null>(null); + + useEffect(() => { + api.get<{ name: string; version: string }>('/version') + .then((r) => setInfo(r.data)) + .catch(() => {}); + }, []); + + return ( +
+
e.stopPropagation()}> + +
+ logo +
+
{info?.name ?? 'ROSzetta'}
+
v{info?.version ?? '—'}
+
+
+
+
Контроллер для управления MikroTik / RouterOS устройствами.
+
+
Разработчик
+
CoRE group
+ + http://core.uz + +
+
+
+
+ ); +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..5a90687 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,457 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + LayoutDashboard, Router, LogOut, Info, + CheckCircle2, AlertTriangle, Bell, Terminal, + Menu, X, Settings as SettingsIcon, + ChevronDown, ChevronUp, +} from 'lucide-react'; +import { useAuth } from '@/store/auth'; +import { api, Device } from '@/api/client'; +import AboutModal from './AboutModal'; +import { useSettings } from '@/store/settings'; +import { pickOkMessage } from '@/utils/okMessages'; +import { useT } from '@/i18n'; + +type MenuKey = + | 'dashboard' | 'devices' | 'switches' | 'firmware' | 'alerts' + | 'notif_center' | 'cli' | 'settings'; + +type NavChild = { + tKey: string; + to: string; + /** Ключ из settings.menu для гранулярной видимости (если задан). */ + menuKey?: MenuKey; +}; + +type NavItem = { + /** Ключ родителя для settings.menu (видимость самой группы). */ + key: MenuKey; + /** Куда переходить при клике по самому пункту (или endpoint первого подпункта). */ + to: string; + tKey: string; + icon: any; + children?: NavChild[]; +}; + +const NAV_TOP: NavItem[] = [ + { key: 'dashboard', to: '/dashboard', tKey: 'nav.dashboard', icon: LayoutDashboard }, + { + key: 'devices', to: '/devices', tKey: 'nav.devices', icon: Router, + children: [ + { menuKey: 'devices', tKey: 'nav.devicesRouters', to: '/devices' }, + { menuKey: 'switches', tKey: 'nav.switches', to: '/devices#switches' }, + ], + }, + { + key: 'notif_center', to: '/notifications', tKey: 'nav.notifCenter', icon: Bell, + children: [ + { menuKey: 'alerts', tKey: 'nav.alerts', to: '/notifications#alerts' }, + { tKey: 'nav.telegram', to: '/notifications#telegram' }, + ], + }, + { + key: 'cli', to: '/cli', tKey: 'nav.automation', icon: Terminal, + children: [ + { tKey: 'nav.cli', to: '/cli' }, + { menuKey: 'firmware', tKey: 'nav.firmware', to: '/cli#firmware' }, + ], + }, +]; + +const NAV_BOTTOM: NavItem[] = [ + { + key: 'settings', to: '/settings', tKey: 'nav.settings', icon: SettingsIcon, + children: [ + { tKey: 'nav.settingsUsers', to: '/settings#users' }, + { tKey: 'nav.settingsPassword', to: '/settings#password' }, + { tKey: 'nav.settingsConfig', to: '/settings#config' }, + ], + }, +]; + +// ------------------------------------------------------------------ +// Header-виджеты (без изменений по сравнению с предыдущей версией) +// ------------------------------------------------------------------ + +function GlobalHealth() { + const [devices, setDevices] = useState(null); + const settings = useSettings((s) => s.settings); + const style = settings?.notify?.style ?? 'jokes'; + const [okMsg] = useState(() => pickOkMessage()); + const t = useT(); + + useEffect(() => { + const load = () => + api.get('/devices').then((r) => setDevices(r.data)).catch(() => {}); + load(); + const t = setInterval(load, 30000); + return () => clearInterval(t); + }, []); + + if (!devices) return ; + const n = settings?.notify; + const problems = devices.filter((d) => { + if (n?.device_status !== false && d.status === 'down') return true; + if (n?.abnormal_reboot !== false && d.abnormal_reboot) return true; + if (n?.internet !== false && d.internet_ok === false) return true; + if (d.last_error) return true; + return false; + }).length; + const total = devices.length; + if (total === 0) return {t('health.empty')}; + if (problems === 0) { + return ( + + {t('health.ok')} · {total} + {style === 'jokes' && · {okMsg}} + + ); + } + return ( + + {t('health.issues')}: {problems} / {total} + + ); +} + +function AlertsBell() { + const navigate = useNavigate(); + const [count, setCount] = useState(0); + useEffect(() => { + const load = () => + api.get<{ count: number }>('/alerts/unread-count') + .then((r) => setCount(r.data.count)).catch(() => {}); + load(); + const t = setInterval(load, 20000); + return () => clearInterval(t); + }, []); + return ( + + ); +} + +function HeaderClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const date = now.toLocaleDateString([], { day: '2-digit', month: '2-digit' }); + return ( + + {date} + {time} + + ); +} + +function UserMenu({ email }: { + email: string | null; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const initials = (email || '?').slice(0, 1).toUpperCase(); + + return ( +
+ + {open && ( +
+
+
Вы вошли как
+
{email ?? '—'}
+
+
+ )} +
+ ); +} + +// ------------------------------------------------------------------ +// Sidebar — стили строк по образцу (Zabbix-like): без скруглений, +// активный пункт — тёмная плашка во всю ширину с акцентной полосой +// слева; подменю — отдельный блок темнее, чем сама панель. +// ------------------------------------------------------------------ + +const ROW_BASE = + 'group flex items-center gap-3 w-full px-4 py-2.5 text-[13.5px] transition-colors select-none ' + + 'border-l-2 border-transparent'; +const ROW_IDLE = 'text-mk-mute hover:bg-white/[0.04] hover:text-mk-text'; +const ROW_ACTIVE = 'bg-black/30 text-mk-text border-l-mk-accent'; + +const SUBMENU_WRAP = 'bg-black/20 border-y border-black/40'; +const CHILD_BASE = + 'flex items-center w-full pl-12 pr-4 py-2 text-[13px] transition-colors ' + + 'border-l-2 border-transparent'; +const CHILD_IDLE = 'text-mk-mute hover:bg-white/[0.04] hover:text-mk-text'; +const CHILD_ACTIVE = 'bg-black/30 text-mk-text border-l-mk-accent'; + +function isChildActive(c: NavChild, location: { pathname: string; hash: string }): boolean { + const [path, hash] = c.to.split('#'); + if (location.pathname !== path) return false; + const wantHash = hash ? '#' + hash : ''; + return location.hash === wantHash; +} + +function NavGroup({ + item, t, isVisibleChild, +}: { + item: NavItem; + t: (k: string) => string; + isVisibleChild: (c: NavChild) => boolean; +}) { + const location = useLocation(); + const isOnParent = + location.pathname === item.to || location.pathname.startsWith(item.to + '/'); + const [open, setOpen] = useState(isOnParent); + + useEffect(() => { if (isOnParent) setOpen(true); }, [isOnParent]); + + const visibleChildren = (item.children ?? []).filter(isVisibleChild); + if (visibleChildren.length === 0) return null; + + const Caret = open ? ChevronUp : ChevronDown; + const parentActive = isOnParent; + + return ( +
+ + {open && ( +
+ {visibleChildren.map((c) => ( + + `${CHILD_BASE} ${isChildActive(c, location) ? CHILD_ACTIVE : CHILD_IDLE}` + } + > + {t(c.tKey)} + + ))} +
+ )} +
+ ); +} + +function NavRow({ item, t }: { item: NavItem; t: (k: string) => string }) { + return ( + + `${ROW_BASE} ${isActive ? ROW_ACTIVE : ROW_IDLE}` + } + > + + {t(item.tKey)} + + ); +} + +export default function AppLayout() { + const { email, logout } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [aboutOpen, setAboutOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [version, setVersion] = useState(null); + const settings = useSettings((s) => s.settings); + const loadSettings = useSettings((s) => s.load); + const t = useT(); + + useEffect(() => { setSidebarOpen(false); }, [location.pathname]); + + useEffect(() => { + api.get<{ version: string }>('/version').then((r) => setVersion(r.data.version)).catch(() => {}); + loadSettings(); + }, []); + + // Видимость родителя — из settings.menu по `key`. + const isVisibleGroup = (n: NavItem): boolean => + !settings?.menu || settings.menu[n.key] !== false; + // Видимость подпункта — по child.menuKey (если задан). Без menuKey — всегда виден. + const isVisibleChild = (c: NavChild): boolean => + !c.menuKey || !settings?.menu || settings.menu[c.menuKey] !== false; + + const topNav = useMemo(() => NAV_TOP.filter(isVisibleGroup), [settings]); + const bottomNav = useMemo(() => NAV_BOTTOM.filter(isVisibleGroup), [settings]); + + const onLogout = () => { + if (!window.confirm(t('logout.confirm'))) return; + logout(); + navigate('/login', { replace: true }); + }; + + const renderItem = (n: NavItem) => + n.children + ? + : ; + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + aria-hidden + /> + )} + + +
+
+ +
+ {settings?.ui?.instance_name && ( + + {settings.ui.instance_name} + + )} +
+
+ Состояние системы: + +
+
+ + {version && ( + + v{version} + + )} + + + +
+
+
+ Состояние системы: + +
+
+ +
+
+ + {aboutOpen && setAboutOpen(false)} />} +
+ ); +} diff --git a/frontend/src/components/ChatBot.tsx b/frontend/src/components/ChatBot.tsx new file mode 100644 index 0000000..8049646 --- /dev/null +++ b/frontend/src/components/ChatBot.tsx @@ -0,0 +1,93 @@ +import { FormEvent, useState } from 'react'; +import { X, Send, Bot } from 'lucide-react'; + +interface Msg { + who: 'bot' | 'me'; + text: string; + ts: number; +} + +const HINT = `Это заглушка чат-бота. Здесь будет интеграция с Telegram/AI. +Можно спрашивать про устройства, настройки, бэкапы.`; + +function botReply(q: string): string { + const s = q.toLowerCase(); + if (/устройств|devices/.test(s)) return 'Список устройств доступен в разделе "Devices".'; + if (/бэкап|backup/.test(s)) return 'Бэкапы создаются на странице устройства, кнопкой "Backup".'; + if (/прошив|firmware/.test(s)) return 'Репозиторий прошивок — в левом меню "Прошивки".'; + if (/привет|hi|hello/.test(s)) return 'Привет! Чем помочь?'; + return 'Ок, принял. (бот пока в режиме заглушки)'; +} + +interface ChatBotProps { + open?: boolean; + onClose?: () => void; + embedded?: boolean; +} + +export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) { + const [msgs, setMsgs] = useState([ + { who: 'bot', text: HINT, ts: Date.now() }, + ]); + const [input, setInput] = useState(''); + + const send = (e: FormEvent) => { + e.preventDefault(); + const text = input.trim(); + if (!text) return; + const now = Date.now(); + setMsgs((m) => [...m, { who: 'me', text, ts: now }]); + setInput(''); + setTimeout(() => { + setMsgs((m) => [...m, { who: 'bot', text: botReply(text), ts: Date.now() }]); + }, 350); + }; + + if (!open) return null; + const wrapperCls = embedded + ? 'card p-0 flex flex-col h-[60vh] min-h-[360px]' + : 'fixed bottom-5 left-60 z-40 w-80 h-96 card p-0 flex flex-col shadow-2xl'; + return ( +
+
+ +
Помощник
+ beta + {!embedded && onClose && ( + + )} +
+
+ {msgs.map((m, i) => ( +
+ {m.text} +
+ ))} +
+
+ setInput(e.target.value)} + /> + +
+
+ ); +} diff --git a/frontend/src/components/DeviceMockup.tsx b/frontend/src/components/DeviceMockup.tsx new file mode 100644 index 0000000..b193ad3 --- /dev/null +++ b/frontend/src/components/DeviceMockup.tsx @@ -0,0 +1,926 @@ +// SVG-мокапы лицевых панелей MikroTik. Подсвечивают живые порты по InterfaceInfo[]. +// Сейчас реализован hAP ac lite (RB952Ui-5ac2nD): синий корпус, 5 ethernet, +// первый — PoE in (Internet), 2–4 LAN, 5 — PoE out (оранжевая обводка). + +import { InterfaceInfo } from '@/api/client'; + +export interface DeviceMockupProps { + /** Имя модели из RouterOS (board-name), например "hAP ac lite". */ + boardName: string | null | undefined; + /** Текущий снимок интерфейсов с устройства. */ + interfaces: InterfaceInfo[]; +} + +const isHapAcLite = (b?: string | null): boolean => + !!b && /h\s*A\s*P\s*ac\s*lite/i.test(b); + +const isHapLike = (b?: string | null): boolean => !!b && /\bh\s*A\s*P\b/i.test(b); + +const isRb5009 = (b?: string | null): boolean => + !!b && /RB?\s*5009/i.test(b); + +const isChr = (b?: string | null): boolean => + !!b && /\bCHR\b/i.test(b); + +const isHexS = (b?: string | null): boolean => + !!b && /h\s*EX\s*S|RB?\s*760/i.test(b); + +const isL009 = (b?: string | null): boolean => + !!b && /\bL\s*009/i.test(b); + +const isRb4011 = (b?: string | null): boolean => + !!b && /RB?\s*4011/i.test(b); + +// Найти интерфейс по базовому имени, допуская суффиксы вида `ether1-Uztelecom`, +// `ether2_LAN`, `ether3 description` и т.п. Сначала пробуем точное совпадение, потом по префиксу. +function findPort(interfaces: InterfaceInfo[], baseName: string): InterfaceInfo | undefined { + const exact = interfaces.find((x) => x.name === baseName); + if (exact) return exact; + const re = new RegExp(`^${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\-_.:]|$)`, 'i'); + return interfaces.find((x) => re.test(x.name)); +} + +// Цвета порта по статусу. +function portColor(it: InterfaceInfo | undefined): { fill: string; stroke: string; label: string } { + if (!it) return { fill: '#0c0c0c', stroke: '#3a3a3a', label: 'нет данных' }; + if (it.disabled) return { fill: '#1a1a1a', stroke: '#5b5b5b', label: 'отключён' }; + if (it.running) return { fill: '#0a3a14', stroke: '#22c55e', label: 'up' }; + return { fill: '#1a1a1a', stroke: '#ef4444', label: 'down' }; +} + +export default function DeviceMockup({ boardName, interfaces }: DeviceMockupProps) { + if (isHapAcLite(boardName) || (isHapLike(boardName) && interfaces.filter((it) => /^ether/.test(it.name)).length === 5)) { + return ; + } + if (isRb5009(boardName)) { + return ; + } + if (isRb4011(boardName)) { + return ; + } + if (isHexS(boardName)) { + return ; + } + if (isL009(boardName)) { + return ; + } + if (isChr(boardName)) { + return ; + } + return ( +
+ Мокап для модели {boardName || '—'} ещё не подготовлен. + Статусы интерфейсов смотрите во вкладке «Интерфейсы». +
+ ); +} + +// --------- hAP ac lite --------- + +function HapAcLiteMockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const byName = new Map(interfaces.map((it) => [it.name, it])); + // Раскладка портов: ether1 = Internet/PoE in, ether2..ether4 = LAN, ether5 = PoE out. + const ports = [ + { name: 'ether1', label: 'Internet', poe: 'in' as const }, + { name: 'ether2', label: '2', poe: null as const }, + { name: 'ether3', label: '3', poe: null as const }, + { name: 'ether4', label: '4', poe: null as const }, + { name: 'ether5', label: '5', poe: 'out' as const }, + ]; + + // Размеры в условных единицах — масштабируются через viewBox. + const W = 1180, H = 230; + const bodyR = 14; + const portW = 130, portH = 110; + const firstPortX = 360; + const portGap = 12; + const portsTopY = 50; + + return ( +
+
+ Лицевая панель hAP ac lite · подсветка портов в реальном времени +
+
+ + {/* Корпус */} + + + {/* Power разъём + подпись */} + Power + + + DC10-28V + + {/* hAPaclite лого */} + hAP + ac + lite + {/* WiFi-дуга над лого */} + + + {/* RES (кнопка с кругом и подписью WPS) */} + + + RES + WPS + + {/* PWR кнопка (квадрат) */} + PWR + + + {/* USR светодиод */} + USR + + + {/* Тёмная полоса фоны для верхних/нижних лейблов */} + + + + {/* Оранжевая зона PoE out над портом 5 */} + + {/* Оранжевая зона PoE out внизу */} + + + {/* Порты */} + {ports.map((p, i) => { + const x = firstPortX + i * (portW + portGap); + const it = findPort(interfaces, p.name); + const col = portColor(it); + return ( + + {/* Верхний лейбл (Internet / 2 / 3 / 4 / 5) */} + + {p.label} + + + {/* Корпус порта (металлический ободок) */} + + {/* Внутренний экран порта */} + + {/* RJ45 «зубчики» */} + + + {/* LED-индикатор (точка) */} + + {/* Имя интерфейса под портом для понятности */} + {p.name} + + {/* Тултип через */} + + {p.name} ({p.label}){p.poe === 'in' ? ' · PoE in' : p.poe === 'out' ? ' · PoE out' : ''} + {'\n'}статус: {col.label} + {it?.comment ? `\ncomment: ${it.comment}` : ''} + {it?.mac_address ? `\nmac: ${it.mac_address}` : ''} + + + ); + })} + + {/* Нижние подписи: PoE in / LAN / PoE out */} + PoE in + LAN + PoE out + +
+ + {/* Легенда */} +
+ + up (running) + + + down + + + disabled / нет данных + +
+
+ ); +} + +// --------- RB5009UG+S+ --------- +// Чёрный корпус, 8 GigE портов (ether1..ether8) + 1 SFP+ (sfp-sfpplus1). +// Слева: DC jack 12-57V, кнопка R (reset), USB 3.0 порт. +// ether1 — PoE in (жёлтая обводка), ether8 — 2.5GbE (синяя обводка), sfp-sfpplus1 — 10G. + +function Rb5009Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const byName = new Map(interfaces.map((it) => [it.name, it])); + const W = 520, H = 66; + const portW = 32, portH = 32, gap = 3; + const portsY = (H - portH) / 2 - 1; + const portsStartX = 132; + const sfpW = 60; + const sfp = findPort(interfaces, 'sfp-sfpplus1') || findPort(interfaces, 'sfpplus1'); + + const ports = [ + { name: 'ether1', label: '1', accent: 'poe' as const }, + { name: 'ether2', label: '2', accent: null as const }, + { name: 'ether3', label: '3', accent: null as const }, + { name: 'ether4', label: '4', accent: null as const }, + { name: 'ether5', label: '5', accent: null as const }, + { name: 'ether6', label: '6', accent: null as const }, + { name: 'ether7', label: '7', accent: null as const }, + { name: 'ether8', label: '8', accent: '2g5' as const }, + ]; + + const accentColor = (a: 'poe' | '2g5' | null) => + a === 'poe' ? '#f0851a' : a === '2g5' ? '#2563eb' : null; + const sfpX = portsStartX + ports.length * (portW + gap) + 6; + + return ( +
+
+ Лицевая панель RB5009UG+S+ · подсветка портов в реальном времени +
+
+ + {/* Чёрный корпус */} + + + {/* DC jack */} + 12-57V DC + + + DC IN + + {/* RES */} + R + + + RES + + {/* USB 3.0 */} + USB + + + + USB 3.0 + + {/* PWR/USR LED */} + + PWR + + USR + + {/* Лейблы цифр над портами + полоса акцента (PoE/2.5G) */} + {ports.map((p, i) => { + const x = portsStartX + i * (portW + gap); + const accent = accentColor(p.accent); + return ( + + {accent && ( + + )} + {p.label} + + ); + })} + + {/* Порты */} + {ports.map((p, i) => { + const x = portsStartX + i * (portW + gap); + const it = findPort(interfaces, p.name); + const col = portColor(it); + return ( + + + + + + + {p.name} (порт {p.label}) + {p.accent === 'poe' ? ' · PoE in' : ''} + {p.accent === '2g5' ? ' · 2.5 GbE' : ''} + {'\n'}статус: {col.label} + {it?.comment ? `\ncomment: ${it.comment}` : ''} + {it?.mac_address ? `\nmac: ${it.mac_address}` : ''} + + + ); + })} + + {/* SFP+ слот */} + {(() => { + const col = portColor(sfp); + return ( + + + SFP+ + + + + + + 10G SFP+ + + sfp-sfpplus1 · 10 GbE SFP+ + {'\n'}статус: {col.label} + {sfp?.comment ? `\ncomment: ${sfp.comment}` : ''} + + + ); + })()} + + {/* Подписи акцентов снизу */} + PoE in + 2.5G + +
+ +
+ ); +} + +// --------- RB4011iGS+ --------- +// Чёрный корпус 1U: слева RESET + PWR LED, затем SFP+ слот, 5 GigE портов (1-5, PoE-in 18-57V на ether1), +// центральная LED-матрица статусов (1-5 сверху, 6-10 снизу) и 5 GigE портов (6-10, PoE-out на ether10). + +function Rb4011Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const W = 500, H = 66; + const portW = 32, portH = 32, gap = 3; + const portsY = (H - portH) / 2 - 1; + const sfpW = 50; + const sfpX = 30; + const group1StartX = sfpX + sfpW + 4; + const ledBlockW = 24; + const ledBlockGap = 4; + const group2StartX = + group1StartX + 5 * (portW + gap) - gap + ledBlockGap + ledBlockW + ledBlockGap; + + const sfp = findPort(interfaces, 'sfp-sfpplus1') || findPort(interfaces, 'sfpplus1'); + + const portsLeft = [ + { name: 'ether1', label: '1' }, + { name: 'ether2', label: '2' }, + { name: 'ether3', label: '3' }, + { name: 'ether4', label: '4' }, + { name: 'ether5', label: '5' }, + ]; + const portsRight = [ + { name: 'ether6', label: '6' }, + { name: 'ether7', label: '7' }, + { name: 'ether8', label: '8' }, + { name: 'ether9', label: '9' }, + { name: 'ether10', label: '10' }, + ]; + + return ( +
+
+ Лицевая панель RB4011iGS+ · подсветка портов в реальном времени +
+
+ + {/* Чёрный корпус */} + + + {/* RESET кнопка */} + + + RESET + + {/* PWR LED */} + PWR + + + {/* SFP+ слот */} + {(() => { + const col = portColor(sfp); + return ( + + + SFP+ + + + + + + SFP+ 10G + + sfp-sfpplus1 · 10 GbE SFP+ + {'\n'}статус: {col.label} + {sfp?.comment ? `\ncomment: ${sfp.comment}` : ''} + + + ); + })()} + + {/* Акцентная полоска PoE-in над ether1 */} + + + {/* Лейблы цифр над портами 1-5 */} + {portsLeft.map((p, i) => { + const x = group1StartX + i * (portW + gap); + return ( + + {p.label} + + ); + })} + {/* Порты 1-5 */} + {portsLeft.map((p, i) => { + const x = group1StartX + i * (portW + gap); + const it = findPort(interfaces, p.name); + const col = portColor(it); + const isPoeIn = i === 0; + return ( + + + + + + + {p.name} (порт {p.label}){isPoeIn ? ' · PoE in 18-57V' : ''} + {'\n'}статус: {col.label} + {it?.comment ? `\ncomment: ${it.comment}` : ''} + {it?.mac_address ? `\nmac: ${it.mac_address}` : ''} + + + ); + })} + + {/* Подпись группы 1-5 снизу */} + + PoE in 18-57V + + + {/* Центральная LED-матрица статусов */} + {(() => { + const lx = group1StartX + 5 * (portW + gap) - gap + ledBlockGap; + const cy1 = portsY + 9; + const cy2 = portsY + portH - 9; + return ( + + + {[0, 1, 2, 3, 4].map((i) => { + const cx = lx + 3.5 + i * 4.2; + const top = findPort(interfaces, `ether${i + 1}`); + const bot = findPort(interfaces, `ether${i + 6}`); + return ( + + + {top ? `ether${i + 1}: ${top.running ? 'up' : top.disabled ? 'disabled' : 'down'}` : `ether${i + 1}: нет данных`} + + + {bot ? `ether${i + 6}: ${bot.running ? 'up' : bot.disabled ? 'disabled' : 'down'}` : `ether${i + 6}: нет данных`} + + + ); + })} + + ); + })()} + + {/* Акцентная полоска PoE-out над ether10 */} + + + {/* Лейблы цифр над портами 6-10 */} + {portsRight.map((p, i) => { + const x = group2StartX + i * (portW + gap); + return ( + + {p.label} + + ); + })} + {/* Порты 6-10 */} + {portsRight.map((p, i) => { + const x = group2StartX + i * (portW + gap); + const it = findPort(interfaces, p.name); + const col = portColor(it); + const isPoeOut = i === 4; + return ( + + + + + + + {p.name} (порт {p.label}){isPoeOut ? ' · PoE out' : ''} + {'\n'}статус: {col.label} + {it?.comment ? `\ncomment: ${it.comment}` : ''} + {it?.mac_address ? `\nmac: ${it.mac_address}` : ''} + + + ); + })} + + {/* Подпись группы 6-10 снизу */} + + PoE out + + +
+ +
+ ); +} + +// --------- CHR (Cloud Hosted Router) --------- +// Виртуальная машина MikroTik — нет физической панели. +// Простой белый прямоугольник: слева лейбл «CHR», справа порты ether* в ряд. +// Количество портов — динамическое (сколько отдало устройство). + +function ChrMockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const ports = interfaces + .filter((it) => /^ether/i.test(it.name)) + .sort((a, b) => { + const ai = parseInt(a.name.replace(/\D/g, ''), 10) || 0; + const bi = parseInt(b.name.replace(/\D/g, ''), 10) || 0; + return ai - bi; + }); + + // Фиксированные размеры: 500×66 px. SVG в viewBox 1:1 пикселям, scale=1. + // Порты 30×32 px начинаются после блока «mikrotik» слева, если все не помещаются — + // их можно прокрутить горизонтально через overflow-x-auto обёртки. + const W = 500; + const H = 66; + const padX = 6; + const labelW = 92; + const gap = 4; + const portW = 30; + const portH = 32; + const portsY = (H - portH) / 2 - 2; + const portsStartX = padX + labelW + 6; + + return ( +
+
+ Виртуальный роутер MikroTik CHR · подсветка портов в реальном времени +
+
+ + {/* Белый фон-корпус */} + + + {/* Лейбл mikrotik слева (шрифт в 2 раза мельче) */} + mikrotik + Cloud Hosted Router + + {/* Разделитель */} + + + {/* Порты */} + {ports.length === 0 && ( + нет интерфейсов ether* + )} + {ports.map((it, i) => { + const x = portsStartX + i * (portW + gap); + const col = portColor(it); + // Короткий лейбл — только номер порта (ether7 → "7"). + const num = (it.name.match(/(\d+)$/) || [, it.name])[1]; + return ( + + {/* Корпус виртуального порта */} + + {/* Номер порта внутри */} + + {num} + + {/* Имя интерфейса под портом */} + + {it.name} + + + + {it.name} + {it.type ? ` · ${it.type}` : ''} + {'\n'}статус: {col.label} + {it.comment ? `\ncomment: ${it.comment}` : ''} + {it.mac_address ? `\nmac: ${it.mac_address}` : ''} + + + ); + })} + +
+ + {/* Легенда */} +
+ + up (running) + + + down + + + disabled / нет данных + +
+
+ ); +} + +// --------- hEX S (RB760iGS) --------- +// Тёмно-серый корпус, Power DC + лого, SFP, 5 GigE портов. +// ether1 = INTERNET / PoE in, ether2-4 = LAN, ether5 = PoE out (оранжевый), sfp1. + +function HexSMockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const byName = new Map(interfaces.map((it) => [it.name, it])); + const W = 320, H = 66; + const padX = 4; + const portW = 32, portH = 32, gap = 3; + const portsY = (H - portH) / 2 - 1; + const portsStartX = 96; + const sfp = findPort(interfaces, 'sfp1') || findPort(interfaces, 'sfp-sfpplus1'); + + const ports = [ + { name: 'ether1', label: '1', accent: 'poe-in' as const }, + { name: 'ether2', label: '2', accent: null as const }, + { name: 'ether3', label: '3', accent: null as const }, + { name: 'ether4', label: '4', accent: null as const }, + { name: 'ether5', label: '5', accent: 'poe-out' as const }, + ]; + + return ( +
+
+ Лицевая панель hEX S · подсветка портов в реальном времени +
+
+ + {/* Корпус тёмно-серый */} + + + {/* Power разъём + подпись */} + Power + + + 12-57V DC + + {/* hEX s лого */} + hEX + s + + {/* SFP слот */} + + {(() => { + const col = portColor(sfp); + return + {sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'} + ; + })()} + SFP + INTERNET + + {/* Passive/af/at подпись над портом 1 */} + + Passive/af/at + + {/* Оранжевая зона над/под портом 5 (PoE out) */} + + + + {/* Лейблы цифр над портами 2-5 */} + {ports.slice(1).map((p, idx) => { + const i = idx + 1; + const x = portsStartX + i * (portW + gap); + return ( + + {p.label} + + ); + })} + + {/* Порты */} + {ports.map((p, i) => { + const x = portsStartX + i * (portW + gap); + const it = findPort(interfaces, p.name); + const col = portColor(it); + return ( + + + + + + {p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''} + + ); + })} + + {/* Нижние подписи */} + PoE in + LAN + PoE out + +
+ +
+ ); +} + +// --------- L009 (L009UiGS-RM) --------- +// Красный 19" rack: RES, DC 24-56V, SFP, USB 3.0, 8 GigE портов. +// ether1 = PoE in, ether8 = PoE out (оранжевый), sfp1. + +function L009Mockup({ interfaces }: { interfaces: InterfaceInfo[] }) { + const byName = new Map(interfaces.map((it) => [it.name, it])); + const W = 480, H = 66; + const portW = 36, portH = 32, gap = 3; + const portsY = (H - portH) / 2 - 1; + // Слева до портов: RES + DC + SFP + USB ≈ 110px + const portsStartX = 116; + // Между ether4 и ether5 — небольшой визуальный разрыв + const groupGap = 8; + const sfp = findPort(interfaces, 'sfp1'); + + const ports = [ + { name: 'ether1', label: '1', accent: 'poe-in' as const }, + { name: 'ether2', label: '2', accent: null as const }, + { name: 'ether3', label: '3', accent: null as const }, + { name: 'ether4', label: '4', accent: null as const }, + { name: 'ether5', label: '5', accent: null as const }, + { name: 'ether6', label: '6', accent: null as const }, + { name: 'ether7', label: '7', accent: null as const }, + { name: 'ether8', label: '8', accent: 'poe-out' as const }, + ]; + + const xOf = (i: number) => portsStartX + i * (portW + gap) + (i >= 4 ? groupGap : 0); + + return ( +
+
+ Лицевая панель L009UiGS · подсветка портов в реальном времени +
+
+ + {/* Красный корпус */} + + + {/* RES кнопка */} + RES + + + {/* power led */} + + + {/* DC разъём */} + 24-56 V DC + + + ⊖-⊙-⊕ + + {/* SFP слот */} + SFP + + {(() => { + const col = portColor(sfp); + return + {sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'} + ; + })()} + SFP + + {/* USB 3.0 */} + USB + + + + USB 3.0 + + {/* Оранжевая зона над/под портом 8 (PoE out) */} + + + + {/* Лейблы цифр над портами */} + {ports.map((p, i) => ( + + {p.label} + + ))} + + {/* Порты */} + {ports.map((p, i) => { + const x = xOf(i); + const it = findPort(interfaces, p.name); + const col = portColor(it); + return ( + + + + + + {p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''} + + ); + })} + + {/* Нижние подписи скоростей */} + PoE in + PoE out + +
+ +
+ ); +} + +// Общая мини-легенда для физических мокапов. +function MockupLegend() { + return ( +
+ + up + + + down + + + disabled + +
+ ); +} diff --git a/frontend/src/components/FirmwareChannelsCard.tsx b/frontend/src/components/FirmwareChannelsCard.tsx new file mode 100644 index 0000000..730eb8e --- /dev/null +++ b/frontend/src/components/FirmwareChannelsCard.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { Layers, RefreshCw, CheckCircle2, AlertTriangle } from 'lucide-react'; +import { api, FirmwareChannelsOut } from '@/api/client'; + +function fmtDt(s?: string): string { + if (!s) return '—'; + try { return new Date(s).toLocaleString(); } catch { return s; } +} + +/** + * Самодостаточная карточка «Каналы RouterOS» — сама грузит данные и + * умеет запускать проверку обновлений. Используется на дашборде и + * во вкладке «Репозиторий прошивок» страницы Автоматизации. + */ +export default function FirmwareChannelsCard() { + const [data, setData] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const reload = () => api.get('/firmware/channels') + .then((r) => setData(r.data)).catch(() => {}); + + useEffect(() => { reload(); }, []); + + const onRefresh = async () => { + setRefreshing(true); + try { + await api.post('/firmware/check'); + await reload(); + } catch { /* ignore */ } + finally { setRefreshing(false); } + }; + + if (!data) return null; + const order = data.available_channels; + return ( +
+
+ +

Каналы RouterOS

+ +
+
+ {order.map((ch) => { + const info = data.channels[ch]; + const ok = info?.last_check_ok !== false && info?.version; + return ( +
+
+ {ok ? ( + + ) : ( + + )} + {ch} +
+
{info?.version || '—'}
+
+ Выпущена: {fmtDt(info?.released_at)} +
+
+ Проверено: {fmtDt(info?.last_check)} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..49efc99 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,165 @@ +// Минимальный i18n: словарь + хук useT(). Без внешних зависимостей. +import { useSettings } from '../store/settings'; + +export type Locale = 'ru' | 'en' | 'uz'; + +const dict: Record> = { + ru: { + 'nav.dashboard': 'Дашборд', + 'nav.devices': 'Мониторинг', + 'nav.devicesRouters': 'Роутеры', + 'nav.firmware': 'Прошивки', + 'nav.alerts': 'Алерты', + 'nav.cli': 'CLI', + 'nav.automation':'Автоматизация', + 'nav.switches': 'Свичи', + 'nav.audit': 'Аудит', + 'nav.logs': 'Просмотр логов', + 'nav.notifCenter':'Центр уведомлений', + 'nav.telegram': 'Telegram', + 'nav.settings': 'Настройки', + 'nav.settingsUsers': 'Пользователи', + 'nav.settingsPassword': 'Смена пароля', + 'nav.settingsConfig': 'Конфигурация', + 'nav.logout': 'Выйти', + 'logout.confirm':'Выйти из системы?', + 'health.ok': 'Всё ОК', + 'health.issues': 'Проблем', + 'health.empty': 'Нет устройств', + 'settings.title': 'Настройки', + 'settings.identity': 'Идентификация установки', + 'settings.identity.hint': 'Это название отображается в шапке интерфейса.', + 'settings.instanceName': 'Название установки', + 'settings.locale': 'Язык интерфейса', + 'settings.theme': 'Тема оформления', + 'settings.menu': 'Видимость пунктов меню', + 'settings.notify': 'Уведомления', + 'settings.telegram': 'Telegram-бот', + 'settings.heartbeat': 'Окно Heartbeat на дашборде', + 'settings.heartbeat.hint':'Сколько времени отображается в сетке состояния устройств.', + 'settings.probe': 'Автоматический опрос устройств', + 'settings.probe.hint': 'Как часто опрашивать все устройства (сбор метрик, статуса, интернета).', + 'common.save': 'Сохранить', + 'common.saved': 'Сохранено', + 'common.cancel': 'Отмена', + }, + en: { + 'nav.dashboard': 'Dashboard', + 'nav.devices': 'Monitoring', + 'nav.devicesRouters': 'Routers', + 'nav.firmware': 'Firmware', + 'nav.alerts': 'Alerts', + 'nav.cli': 'CLI', + 'nav.automation':'Automation', + 'nav.switches': 'Switches', + 'nav.audit': 'Audit', + 'nav.logs': 'View logs', + 'nav.notifCenter':'Notification Center', + 'nav.telegram': 'Telegram', + 'nav.settings': 'Settings', + 'nav.settingsUsers': 'Users', + 'nav.settingsPassword': 'Change password', + 'nav.settingsConfig': 'Configuration', + 'nav.logout': 'Logout', + 'logout.confirm':'Sign out?', + 'health.ok': 'All OK', + 'health.issues': 'Issues', + 'health.empty': 'No devices', + 'settings.title': 'Settings', + 'settings.identity': 'Installation identity', + 'settings.identity.hint': 'This name is shown in the header.', + 'settings.instanceName': 'Installation name', + 'settings.locale': 'Interface language', + 'settings.theme': 'Theme', + 'settings.menu': 'Menu items visibility', + 'settings.notify': 'Notifications', + 'settings.telegram': 'Telegram bot', + 'settings.heartbeat': 'Dashboard Heartbeat window', + 'settings.heartbeat.hint':'How much history is shown in the device heartbeat grid.', + 'settings.probe': 'Automatic device polling', + 'settings.probe.hint': 'How often to probe all devices (metrics, status, internet check).', + 'common.save': 'Save', + 'common.saved': 'Saved', + 'common.cancel': 'Cancel', + }, + uz: { + 'nav.dashboard': 'Boshqaruv paneli', + 'nav.devices': 'Monitoring', + 'nav.devicesRouters': 'Routerlar', + 'nav.firmware': 'Proshivkalar', + 'nav.alerts': 'Ogohlantirishlar', + 'nav.cli': 'CLI', + 'nav.automation':'Avtomatlashtirish', + 'nav.switches': 'Switchlar', + 'nav.audit': 'Audit', + 'nav.logs': "Loglarni ko'rish", + 'nav.notifCenter':'Bildirishnomalar markazi', + 'nav.telegram': 'Telegram', + 'nav.settings': 'Sozlamalar', + 'nav.settingsUsers': 'Foydalanuvchilar', + 'nav.settingsPassword': "Parolni o'zgartirish", + 'nav.settingsConfig': 'Konfiguratsiya', + 'nav.logout': 'Chiqish', + 'logout.confirm':'Tizimdan chiqasizmi?', + 'health.ok': "Hammasi joyida", + 'health.issues': 'Muammolar', + 'health.empty': "Qurilmalar yo'q", + 'settings.title': 'Sozlamalar', + 'settings.identity': 'Tizim identifikatsiyasi', + 'settings.identity.hint': 'Bu nom interfeys sarlavhasida ko\'rsatiladi.', + 'settings.instanceName': 'Tizim nomi', + 'settings.locale': 'Interfeys tili', + 'settings.theme': 'Mavzu', + 'settings.menu': 'Menyu elementlari ko\'rinishi', + 'settings.notify': 'Bildirishnomalar', + 'settings.telegram': 'Telegram bot', + 'settings.heartbeat': 'Boshqaruv panelidagi Heartbeat oynasi', + 'settings.heartbeat.hint':'Qurilmalar holati panelida qancha vaqt ko\'rsatiladi.', + 'settings.probe': 'Qurilmalarni avtomatik so\'rash', + 'settings.probe.hint': 'Barcha qurilmalar qanchalik tez-tez so\'raladi (metrikalar, holat, internet).', + 'common.save': 'Saqlash', + 'common.saved': 'Saqlandi', + 'common.cancel': 'Bekor qilish', + }, +}; + +export function t(locale: Locale, key: string): string { + return dict[locale]?.[key] ?? dict.ru[key] ?? key; +} + +export function useT() { + const locale = useSettings((s) => (s.settings?.ui?.locale as Locale) ?? 'ru'); + return (key: string) => t(locale, key); +} + +export const LOCALES: { code: Locale; label: string }[] = [ + { code: 'ru', label: 'Русский' }, + { code: 'en', label: 'English' }, + { code: 'uz', label: "O'zbekcha" }, +]; + +export const THEMES: { id: string; label: string; swatch: [string, string, string] }[] = [ + { id: 'mk-dark', label: 'ROSzetta Dark', swatch: ['#0b0e14', '#11151c', '#1b78ff'] }, + { id: 'abyss', label: 'Abyss (VS Code)', swatch: ['#000c18', '#051336', '#4d9cff'] }, + { id: 'midnight', label: 'Midnight', swatch: ['#0a0f1f', '#121a30', '#5b6cff'] }, + { id: 'dracula', label: 'Dracula', swatch: ['#282a36', '#343746', '#bd93f9'] }, + { id: 'light', label: 'Light', swatch: ['#ffffff', '#f5f6f8', '#1b78ff'] }, + { id: 'solarized-light', label: 'Solarized Light', swatch: ['#fdf6e3', '#eee8d5', '#268bd2'] }, +]; + +// Доступные окна Heartbeat (в часах). +export const HEARTBEAT_RANGES: { hours: number; label: string }[] = [ + { hours: 6, label: '6ч' }, + { hours: 3, label: '3ч' }, + { hours: 1, label: '1ч' }, + { hours: 0.5, label: '30м' }, +]; + +// Допустимые интервалы автоопроса устройств (мин). +export const PROBE_INTERVALS: { minutes: number; label: string }[] = [ + { minutes: 1, label: '1 мин' }, + { minutes: 2, label: '2 мин' }, + { minutes: 3, label: '3 мин' }, + { minutes: 5, label: '5 мин' }, + { minutes: 10, label: '10 мин' }, +]; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f3baeed --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,131 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ── Темы оформления ──────────────────────────────────────────────── */ +/* Значения цветов хранятся как raw "R G B", чтобы Tailwind мог применять opacity-модификаторы (/15 и т.п.) */ +/* По умолчанию — ROSzetta Dark */ +:root, +[data-theme='mk-dark'], +[data-theme='unifi-dark'] { + --c-bg: 11 15 23; + --c-panel: 17 22 34; + --c-panel2: 24 31 46; + --c-border: 38 47 65; + --c-text: 230 236 245; + --c-mute: 130 145 170; + --c-accent: 37 99 235; + --c-accent2: 59 130 246; + --c-ok: 34 197 94; + --c-warn: 245 158 11; + --c-err: 239 68 68; +} +/* VS Code Abyss */ +[data-theme='abyss'] { + --c-bg: 0 12 24; + --c-panel: 5 19 54; + --c-panel2: 8 34 82; + --c-border: 19 68 151; + --c-text: 102 136 204; + --c-mute: 64 99 133; + --c-accent: 47 126 255; + --c-accent2: 77 156 255; + --c-ok: 34 197 94; + --c-warn: 245 158 11; + --c-err: 255 83 112; +} +/* Midnight (тёмно-синий) */ +[data-theme='midnight'] { + --c-bg: 10 15 31; + --c-panel: 18 26 48; + --c-panel2: 26 35 64; + --c-border: 36 48 89; + --c-text: 216 222 240; + --c-mute: 119 133 168; + --c-accent: 91 108 255; + --c-accent2: 138 150 255; + --c-ok: 34 197 94; + --c-warn: 245 158 11; + --c-err: 239 68 68; +} +/* Dracula-like */ +[data-theme='dracula'] { + --c-bg: 40 42 54; + --c-panel: 52 55 70; + --c-panel2: 61 64 83; + --c-border: 74 77 99; + --c-text: 248 248 242; + --c-mute: 138 141 166; + --c-accent: 189 147 249; + --c-accent2: 255 121 198; + --c-ok: 80 250 123; + --c-warn: 241 250 140; + --c-err: 255 85 85; +} +/* Light */ +[data-theme='light'] { + --c-bg: 255 255 255; + --c-panel: 245 246 248; + --c-panel2: 236 239 244; + --c-border: 214 218 226; + --c-text: 26 32 44; + --c-mute: 107 114 128; + --c-accent: 5 89 201; + --c-accent2: 27 120 255; + --c-ok: 22 163 74; + --c-warn: 217 119 6; + --c-err: 220 38 38; +} +/* Solarized Light */ +[data-theme='solarized-light'] { + --c-bg: 253 246 227; + --c-panel: 238 232 213; + --c-panel2: 227 220 198; + --c-border: 211 205 179; + --c-text: 7 54 66; + --c-mute: 88 110 117; + --c-accent: 38 139 210; + --c-accent2: 42 161 152; + --c-ok: 133 153 0; + --c-warn: 181 137 0; + --c-err: 220 50 47; +} + +@layer base { + html, body, #root { height: 100%; } + body { + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + background-color: var(--c-bg); + color: var(--c-text); + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md + text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-primary { @apply btn bg-mk-accent hover:bg-mk-accent2 text-white; } + .btn-ghost { @apply btn bg-transparent hover:bg-mk-panel2 text-mk-text border border-mk-border; } + + .card { + @apply bg-mk-panel border border-mk-border rounded-xl p-3 sm:p-5; + } + .table-wrap { + @apply -mx-3 sm:mx-0 overflow-x-auto; + } + .table-wrap > table { + @apply min-w-[640px] w-full; + } + .input { + @apply w-full bg-mk-panel2 border border-mk-border rounded-md px-3 py-2 text-sm + placeholder-mk-mute focus:outline-none focus:border-mk-accent2; + } + .badge { + @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium; + } + .badge-up { @apply badge bg-mk-ok/15 text-mk-ok; } + .badge-down { @apply badge bg-mk-err/15 text-mk-err; } + .badge-unk { @apply badge bg-mk-mute/15 text-mk-mute; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c68f490 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/frontend/src/pages/Alerts.tsx b/frontend/src/pages/Alerts.tsx new file mode 100644 index 0000000..b6ea921 --- /dev/null +++ b/frontend/src/pages/Alerts.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { Bell, CheckCheck, Trash2, AlertTriangle, AlertCircle, Info, Eraser } from 'lucide-react'; +import { api, Alert as AlertT } from '@/api/client'; + +function sevIcon(s: string) { + if (s === 'critical' || s === 'error') return ; + if (s === 'warning') return ; + return ; +} + +export default function AlertsPage() { + const [alerts, setAlerts] = useState([]); + const [onlyUnack, setOnlyUnack] = useState(false); + + const reload = () => + api.get('/alerts', { params: { only_unack: onlyUnack } }) + .then((r) => setAlerts(r.data)); + + useEffect(() => { reload(); }, [onlyUnack]); + + const ack = async (id: number) => { await api.post(`/alerts/${id}/ack`); reload(); }; + const ackAll = async () => { await api.post('/alerts/ack-all'); reload(); }; + const remove = async (id: number) => { + if (!confirm('Удалить алерт?')) return; + await api.delete(`/alerts/${id}`); reload(); + }; + const purge = async () => { + const onlyAcked = confirm('OK — удалить только прочитанные.\nОтмена — удалить все.'); + if (!confirm(onlyAcked ? 'Удалить все прочитанные алерты?' : 'Удалить ВСЕ алерты?')) return; + await api.delete('/alerts', { params: { only_acked: onlyAcked } }); + reload(); + }; + + return ( +
+
+
+ +

Alert Center

+ всего: {alerts.length} +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + {alerts.length === 0 && ( + + )} + {alerts.map((a) => ( + + + + + + + + + ))} + +
ЗаголовокКатегорияИсточникВремяДействия
Нет алертов
{sevIcon(a.severity)} +
{a.title}
+ {a.message &&
{a.message}
} +
{a.category}{a.source ?? '—'}{new Date(a.created_at).toLocaleString()} + {!a.acknowledged && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/CLI.tsx b/frontend/src/pages/CLI.tsx new file mode 100644 index 0000000..0abdc9c --- /dev/null +++ b/frontend/src/pages/CLI.tsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Terminal, Play, AlertTriangle, Bot, HardDrive } from 'lucide-react'; +import { api, CLIRunOut, Device } from '@/api/client'; +import ChatBot from '@/components/ChatBot'; +import FirmwarePage from '@/pages/Firmware'; + +const PRESETS = [ + '/system/identity/print', + '/system/resource/print', + '/interface/print', + '/ip/address/print', + '/ip/route/print', + '/system/clock/print', + '/log/print', +]; + +const DANGEROUS = [ + '/system/reboot', + '/system/shutdown', + '/system/reset-configuration', + '/system/routerboard/upgrade', + '/file/remove', +]; + +export default function CLIPage() { + const [params] = useSearchParams(); + const [devices, setDevices] = useState([]); + const initialIds = (params.get('ids') ?? '').split(',').map(Number).filter(Boolean); + const [selected, setSelected] = useState>(new Set(initialIds)); + const [command, setCommand] = useState('/system/resource/print'); + const [out, setOut] = useState(null); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [tab, setTab] = useState<'cli' | 'assistant' | 'firmware'>(() => { + const h = (typeof window !== 'undefined' ? window.location.hash : '').replace('#', ''); + if (h === 'assistant' || h === 'firmware' || h === 'cli') return h; + return 'cli'; + }); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', `#${tab}`); + } + }, [tab]); + + useEffect(() => { + api.get('/devices').then((r) => setDevices(r.data)); + }, []); + + const isDangerous = useMemo( + () => DANGEROUS.some((p) => command.trim().startsWith(p)), + [command], + ); + + const toggle = (id: number) => { + setSelected((s) => { + const x = new Set(s); + if (x.has(id)) x.delete(id); else x.add(id); + return x; + }); + }; + + const run = async () => { + setErr(null); + if (selected.size === 0) { setErr('Выберите хотя бы одно устройство'); return; } + if (!command.trim()) { setErr('Введите команду'); return; } + if (isDangerous && !confirm(`Опасная команда!\n\n${command}\n\nЗапустить на ${selected.size} устройств?`)) { + return; + } + setBusy(true); + try { + const r = await api.post('/cli/run', { + device_ids: Array.from(selected), + command, + confirm: isDangerous, + }); + setOut(r.data); + } catch (ex: any) { + setErr(ex?.response?.data?.detail ?? 'Ошибка'); + } finally { setBusy(false); } + }; + + return ( +
+
+ +

Автоматизация

+ CLI и помощник +
+ +
+ + + +
+ + {tab === 'assistant' && } + {tab === 'firmware' && } + + {tab === 'cli' && ( +
+
+
+

Устройства ({selected.size})

+
+ {devices.map((d) => ( + + ))} +
+
+ +
+

Команда

+