start
This commit is contained in:
+31
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"])
|
||||||
@@ -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)}
|
||||||
@@ -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}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...core.db import get_db
|
||||||
|
from ...models.firmware import Firmware
|
||||||
|
from ...models.user import User
|
||||||
|
from ...schemas.firmware import (
|
||||||
|
FirmwareBulkImportIn,
|
||||||
|
FirmwareBulkOut,
|
||||||
|
FirmwareBulkResult,
|
||||||
|
FirmwareImportIn,
|
||||||
|
FirmwareOut,
|
||||||
|
FirmwareUpdateIn,
|
||||||
|
)
|
||||||
|
from ...services.firmware_check import CHANNELS, check_and_alert, get_state
|
||||||
|
from ..deps import get_current_user, require_role
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_FIRMWARE_SIZE = 200 * 1024 * 1024 # 200 MiB лимит
|
||||||
|
|
||||||
|
# Известные архитектуры RouterOS v7 для bulk-импорта.
|
||||||
|
KNOWN_ARCHITECTURES = [
|
||||||
|
"arm64", "arm", "mipsbe", "mmips", "mipsle", "smips",
|
||||||
|
"tile", "ppc", "x86", "x86_64",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[FirmwareOut])
|
||||||
|
def list_firmware(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[Firmware]:
|
||||||
|
return db.query(Firmware).order_by(Firmware.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check")
|
||||||
|
def manual_check(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin", "operator")),
|
||||||
|
) -> dict:
|
||||||
|
"""Ручная проверка наличия новых версий RouterOS по всем каналам."""
|
||||||
|
state = check_and_alert(db)
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "upstream check failed")
|
||||||
|
# Для совместимости со старым UI возвращаем top-level stable.
|
||||||
|
stable = state.get("stable") or {}
|
||||||
|
return {
|
||||||
|
"latest_version": stable.get("version", ""),
|
||||||
|
"released_at": stable.get("released_at", ""),
|
||||||
|
"channels": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/channels")
|
||||||
|
def list_channels(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Текущее состояние по каждому каналу + список известных архитектур."""
|
||||||
|
return {
|
||||||
|
"channels": get_state(db),
|
||||||
|
"available_channels": list(CHANNELS.keys()),
|
||||||
|
"architectures": KNOWN_ARCHITECTURES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def import_firmware(
|
||||||
|
payload: FirmwareImportIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin", "operator")),
|
||||||
|
) -> Firmware:
|
||||||
|
"""Скачать прошивку с указанного URL и сохранить во внутреннем репозитории.
|
||||||
|
|
||||||
|
Если прошивка с таким же `source_url` или (`version`+`architecture`) уже
|
||||||
|
есть — повторно не скачивается, возвращается существующая запись (HTTP 200
|
||||||
|
с тем же телом, как и для свежесозданной).
|
||||||
|
"""
|
||||||
|
url = str(payload.url)
|
||||||
|
|
||||||
|
# 1) Дедуп по URL источника.
|
||||||
|
existing = db.query(Firmware).filter(Firmware.source_url == url).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# 2) Дедуп по (version, architecture), если оба поля переданы.
|
||||||
|
if payload.version and payload.architecture:
|
||||||
|
existing = (
|
||||||
|
db.query(Firmware)
|
||||||
|
.filter(
|
||||||
|
Firmware.version == payload.version,
|
||||||
|
Firmware.architecture == payload.architecture,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.stream("GET", url, follow_redirects=True, timeout=120.0) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_FIRMWARE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"download failed: {exc}") from exc
|
||||||
|
|
||||||
|
name = payload.name or os.path.basename(url.split("?")[0]) or "firmware.bin"
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
# 3) Дедуп по sha256 (на случай разных URL с тем же содержимым).
|
||||||
|
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
rec = Firmware(
|
||||||
|
name=name,
|
||||||
|
version=payload.version,
|
||||||
|
architecture=payload.architecture,
|
||||||
|
channel=payload.channel,
|
||||||
|
size=len(data),
|
||||||
|
sha256=sha,
|
||||||
|
source_url=url,
|
||||||
|
content=data,
|
||||||
|
)
|
||||||
|
db.add(rec)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _download_firmware_url(url: str) -> bytes:
|
||||||
|
with httpx.stream("GET", url, follow_redirects=True, timeout=180.0) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_FIRMWARE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-bulk", response_model=FirmwareBulkOut)
|
||||||
|
def import_bulk(
|
||||||
|
payload: FirmwareBulkImportIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin", "operator")),
|
||||||
|
) -> FirmwareBulkOut:
|
||||||
|
"""Загрузить .npk для указанной версии по списку архитектур одним вызовом."""
|
||||||
|
results: list[FirmwareBulkResult] = []
|
||||||
|
base = "https://download.mikrotik.com/routeros"
|
||||||
|
for arch in payload.architectures:
|
||||||
|
url = f"{base}/{payload.version}/routeros-{payload.version}-{arch}.npk"
|
||||||
|
# Дедуп до закачки: по URL или (version+architecture).
|
||||||
|
existing = (
|
||||||
|
db.query(Firmware)
|
||||||
|
.filter(
|
||||||
|
(Firmware.source_url == url)
|
||||||
|
| ((Firmware.version == payload.version) & (Firmware.architecture == arch))
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
results.append(FirmwareBulkResult(
|
||||||
|
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = _download_firmware_url(url)
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
# Дедуп по содержимому.
|
||||||
|
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||||
|
if existing:
|
||||||
|
results.append(FirmwareBulkResult(
|
||||||
|
architecture=arch, ok=True, firmware_id=existing.id, skipped=True,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
rec = Firmware(
|
||||||
|
name=os.path.basename(url),
|
||||||
|
version=payload.version,
|
||||||
|
architecture=arch,
|
||||||
|
channel=payload.channel,
|
||||||
|
size=len(data),
|
||||||
|
sha256=sha,
|
||||||
|
source_url=url,
|
||||||
|
content=data,
|
||||||
|
)
|
||||||
|
db.add(rec)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
results.append(FirmwareBulkResult(architecture=arch, ok=True, firmware_id=rec.id))
|
||||||
|
except HTTPException as exc:
|
||||||
|
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc.detail)))
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
results.append(FirmwareBulkResult(architecture=arch, ok=False, error=str(exc)))
|
||||||
|
return FirmwareBulkOut(version=payload.version, channel=payload.channel, results=results)
|
||||||
|
|
||||||
|
|
||||||
|
# routeros-7.16.1-arm64.npk / routeros-7.16.1-arm-7.16.1.npk и т.п.
|
||||||
|
_FW_NAME_RE = re.compile(
|
||||||
|
r"^routeros-(?P<version>\d+(?:\.\d+){1,2}(?:[a-z0-9.\-]*)?)-(?P<arch>[a-z0-9_]+)\.npk$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_meta(filename: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Из имени файла вытащить (version, architecture). Возвращает (None, None) если не разобрали."""
|
||||||
|
m = _FW_NAME_RE.match(filename.strip().lower())
|
||||||
|
if not m:
|
||||||
|
return None, None
|
||||||
|
return m.group("version"), m.group("arch")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FirmwareOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_firmware(
|
||||||
|
file: UploadFile = File(..., description=".npk файл прошивки RouterOS"),
|
||||||
|
name: str | None = Form(None),
|
||||||
|
version: str | None = Form(None),
|
||||||
|
architecture: str | None = Form(None),
|
||||||
|
channel: str | None = Form(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin", "operator")),
|
||||||
|
) -> Firmware:
|
||||||
|
"""Загрузка прошивки вручную с диска пользователя (multipart/form-data).
|
||||||
|
|
||||||
|
Если `version`/`architecture` не указаны — попытка распарсить из имени файла
|
||||||
|
(формат `routeros-<version>-<arch>.npk`). Дедуп по sha256 / (version+architecture).
|
||||||
|
"""
|
||||||
|
fname = (name or file.filename or "firmware.bin").strip()
|
||||||
|
if not fname.lower().endswith(".npk"):
|
||||||
|
# Не блокируем строго, но предупреждаем — RouterOS принимает только .npk.
|
||||||
|
# Разрешаем — пусть админ сам решает.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Читаем тело с лимитом
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_FIRMWARE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"firmware exceeds {MAX_FIRMWARE_SIZE} bytes",
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "empty file")
|
||||||
|
|
||||||
|
# Автоопределение метаданных из имени файла
|
||||||
|
if not version or not architecture:
|
||||||
|
guessed_ver, guessed_arch = _guess_meta(fname)
|
||||||
|
version = version or guessed_ver
|
||||||
|
architecture = architecture or guessed_arch
|
||||||
|
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
# Дедуп: по sha256 → возвращаем существующую запись
|
||||||
|
existing = db.query(Firmware).filter(Firmware.sha256 == sha).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
# Дедуп по (version, architecture)
|
||||||
|
if version and architecture:
|
||||||
|
existing = (
|
||||||
|
db.query(Firmware)
|
||||||
|
.filter(
|
||||||
|
Firmware.version == version,
|
||||||
|
Firmware.architecture == architecture,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
rec = Firmware(
|
||||||
|
name=fname,
|
||||||
|
version=version,
|
||||||
|
architecture=architecture,
|
||||||
|
channel=channel,
|
||||||
|
size=len(data),
|
||||||
|
sha256=sha,
|
||||||
|
source_url=None,
|
||||||
|
content=data,
|
||||||
|
)
|
||||||
|
db.add(rec)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{firmware_id}", response_model=FirmwareOut)
|
||||||
|
def update_firmware(
|
||||||
|
firmware_id: int,
|
||||||
|
payload: FirmwareUpdateIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin", "operator")),
|
||||||
|
) -> Firmware:
|
||||||
|
rec = db.get(Firmware, firmware_id)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||||
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(rec, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{firmware_id}/download")
|
||||||
|
def download_firmware(
|
||||||
|
firmware_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> Response:
|
||||||
|
rec = db.get(Firmware, firmware_id)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||||
|
return Response(
|
||||||
|
content=rec.content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{rec.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{firmware_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||||
|
def delete_firmware(
|
||||||
|
firmware_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_role("admin")),
|
||||||
|
) -> Response:
|
||||||
|
rec = db.get(Firmware, firmware_id)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "firmware not found")
|
||||||
|
db.delete(rec)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Встроенный FTP-сервер для приёма push-бэкапов от MikroTik.
|
||||||
|
|
||||||
|
Идея: вместо того чтобы открывать ssh/ftp на каждом устройстве и тянуть
|
||||||
|
с него файл, контроллер сам поднимает FTP на отдельном порту и выдаёт
|
||||||
|
устройству одноразовые креды. Устройство выполняет:
|
||||||
|
|
||||||
|
/tool fetch upload=yes mode=ftp address=<ctrl> port=<p> \
|
||||||
|
user=<u> password=<p> src-path=<file> dst-path=<file>
|
||||||
|
|
||||||
|
Файлы складываются во временную директорию сессии. По завершении
|
||||||
|
загрузки коллбэк `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"
|
||||||
@@ -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> 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 "",
|
||||||
|
}
|
||||||
@@ -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"<b>[{severity.upper()}] {title}</b>"
|
||||||
|
if message:
|
||||||
|
text += f"\n{message}"
|
||||||
|
if source:
|
||||||
|
text += f"\n<i>src: {source}</i>"
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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, "<b>ROSzetta</b>\nТестовое сообщение \u2705")
|
||||||
|
return (ok, "OK" if ok else "Не удалось отправить (см. логи)")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/mikrotik-logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ROSzetta</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-unifi-bg text-unifi-text">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2803
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Стилизованный логотип в духе MikroTik: квадрат с голубой буквой M -->
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="10" fill="#0b0e14" stroke="#1b78ff" stroke-width="2"/>
|
||||||
|
<path d="M12 48 V20 L24 36 L32 24 L40 36 L52 20 V48"
|
||||||
|
stroke="#1b78ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<circle cx="32" cy="52" r="2.4" fill="#1b78ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 553 B |
@@ -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 <Navigate to="/login" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<AppLayout />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="devices" element={<DevicesIndex />} />
|
||||||
|
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||||
|
<Route path="switches" element={<Navigate to="/devices#switches" replace />} />
|
||||||
|
<Route path="firmware" element={<Navigate to="/cli#firmware" replace />} />
|
||||||
|
<Route path="notifications" element={<NotificationCenter />} />
|
||||||
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
|
<Route path="cli" element={<CLIPage />} />
|
||||||
|
<Route path="audit" element={<Navigate to="/notifications" replace />} />
|
||||||
|
<Route path="logs" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="network_map" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, InterfaceTrafficPoint[]>;
|
||||||
|
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<string, unknown>[] | 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<string, FirmwareChannelInfo>;
|
||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||||
|
<div className="card w-full max-w-md relative" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="absolute top-3 right-3 text-mk-mute hover:text-mk-text"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<img src="/mikrotik-logo.svg" alt="logo" className="w-12 h-12" />
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{info?.name ?? 'ROSzetta'}</div>
|
||||||
|
<div className="text-xs text-mk-mute font-mono">v{info?.version ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-mk-text space-y-2">
|
||||||
|
<div>Контроллер для управления MikroTik / RouterOS устройствами.</div>
|
||||||
|
<div className="pt-3 border-t border-mk-border">
|
||||||
|
<div className="text-xs text-mk-mute uppercase tracking-wider mb-1">Разработчик</div>
|
||||||
|
<div className="font-medium">CoRE group</div>
|
||||||
|
<a
|
||||||
|
href="http://core.uz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-mk-accent2 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
http://core.uz
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Device[] | null>(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<Device[]>('/devices').then((r) => setDevices(r.data)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 30000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!devices) return <span className="text-xs text-mk-mute">…</span>;
|
||||||
|
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 <span className="text-xs text-mk-mute">{t('health.empty')}</span>;
|
||||||
|
if (problems === 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 bg-mk-ok/15 text-mk-ok text-sm font-medium"
|
||||||
|
title="Global system status"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={15} /> {t('health.ok')} · {total}
|
||||||
|
{style === 'jokes' && <span className="text-xs opacity-80">· {okMsg}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-mk-err/15 text-mk-err text-sm font-medium">
|
||||||
|
<AlertTriangle size={15} /> {t('health.issues')}: {problems} / {total}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/notifications#alerts')}
|
||||||
|
className="relative p-2 hover:bg-white/[0.04] text-mk-text"
|
||||||
|
title="Центр уведомлений"
|
||||||
|
>
|
||||||
|
<Bell size={18} />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-mk-err text-white text-[10px] font-bold flex items-center justify-center">
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline-flex items-center gap-2 text-[11px] font-mono text-mk-mute px-2 py-0.5 border border-mk-border"
|
||||||
|
title={now.toLocaleString()}
|
||||||
|
>
|
||||||
|
<span className="text-mk-mute/70">{date}</span>
|
||||||
|
<span className="text-mk-text">{time}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMenu({ email }: {
|
||||||
|
email: string | null;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement | null>(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 (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 p-1 pl-1 pr-2 hover:bg-white/[0.04] text-mk-text"
|
||||||
|
title={email ?? ''}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-mk-accent/20 text-mk-accent2 text-xs font-semibold">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<span className="hidden md:inline text-xs text-mk-mute max-w-[140px] truncate">{email ?? '—'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-1.5 w-64 border border-mk-border bg-mk-panel shadow-xl z-30"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 border-b border-mk-border">
|
||||||
|
<div className="text-xs text-mk-mute">Вы вошли как</div>
|
||||||
|
<div className="text-sm font-medium truncate" title={email ?? ''}>{email ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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<boolean>(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 (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`${ROW_BASE} ${parentActive ? ROW_ACTIVE : ROW_IDLE}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<item.icon size={18} className="shrink-0 opacity-90" />
|
||||||
|
<span className="flex-1 text-left truncate">{t(item.tKey)}</span>
|
||||||
|
<Caret size={15} className="opacity-60" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={SUBMENU_WRAP}>
|
||||||
|
{visibleChildren.map((c) => (
|
||||||
|
<NavLink
|
||||||
|
key={c.to}
|
||||||
|
to={c.to}
|
||||||
|
className={() =>
|
||||||
|
`${CHILD_BASE} ${isChildActive(c, location) ? CHILD_ACTIVE : CHILD_IDLE}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{t(c.tKey)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavRow({ item, t }: { item: NavItem; t: (k: string) => string }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${ROW_BASE} ${isActive ? ROW_ACTIVE : ROW_IDLE}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon size={18} className="shrink-0 opacity-90" />
|
||||||
|
<span className="truncate">{t(item.tKey)}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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
|
||||||
|
? <NavGroup key={n.to} item={n} t={t} isVisibleChild={isVisibleChild} />
|
||||||
|
: <NavRow key={n.to} item={n} t={t} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full relative">
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-30 md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside
|
||||||
|
className={`w-60 shrink-0 bg-mk-panel border-r border-mk-border flex flex-col
|
||||||
|
fixed md:static inset-y-0 left-0 z-40 transition-transform duration-200
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}
|
||||||
|
>
|
||||||
|
<div className="h-14 flex items-center gap-2 px-4 border-b border-mk-border">
|
||||||
|
<img src="/mikrotik-logo.svg" alt="MikroTik" className="w-6 h-6 shrink-0" />
|
||||||
|
<div className="flex flex-col min-w-0 flex-1 leading-tight">
|
||||||
|
<span className="font-semibold tracking-wide text-sm text-mk-text">ROSzetta</span>
|
||||||
|
{settings?.ui?.instance_name && (
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-mk-mute truncate"
|
||||||
|
title={settings.ui.instance_name}
|
||||||
|
>
|
||||||
|
{settings.ui.instance_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="md:hidden p-1 text-mk-mute hover:text-mk-text"
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Верхняя часть — основное меню. Прижато к верху, скроллится. */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-1">
|
||||||
|
{topNav.map(renderItem)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Нижняя часть — Настройки и Выход. Прижата к низу. */}
|
||||||
|
<div className="border-t border-mk-border/70">
|
||||||
|
{bottomNav.map(renderItem)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className={`${ROW_BASE} ${ROW_IDLE}`}
|
||||||
|
title={email ?? ''}
|
||||||
|
>
|
||||||
|
<LogOut size={18} className="shrink-0 opacity-90" />
|
||||||
|
<span className="truncate">{t('nav.logout')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 overflow-auto">
|
||||||
|
<header className="h-12 border-b border-mk-border flex md:grid md:grid-cols-3 items-center gap-2 md:gap-3 px-3 md:px-5 sticky top-0 bg-mk-bg/85 backdrop-blur z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="md:hidden p-1.5 -ml-1 text-mk-text hover:bg-white/[0.04]"
|
||||||
|
aria-label="Открыть меню"
|
||||||
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center min-w-0 flex-1 md:flex-none">
|
||||||
|
{settings?.ui?.instance_name && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center text-sm font-medium text-mk-text truncate"
|
||||||
|
title={settings.ui.instance_name}
|
||||||
|
>
|
||||||
|
{settings.ui.instance_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center justify-center gap-2">
|
||||||
|
<span className="text-sm text-mk-mute whitespace-nowrap">Состояние системы:</span>
|
||||||
|
<GlobalHealth />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-1 md:gap-2">
|
||||||
|
<span className="hidden lg:inline-flex"><HeaderClock /></span>
|
||||||
|
{version && (
|
||||||
|
<span className="hidden sm:inline-flex text-[11px] text-mk-mute font-mono px-2 py-0.5 border border-mk-border">
|
||||||
|
v{version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<AlertsBell />
|
||||||
|
<button
|
||||||
|
onClick={() => setAboutOpen(true)}
|
||||||
|
className="hidden sm:inline-flex p-2 hover:bg-white/[0.04] text-mk-mute hover:text-mk-text"
|
||||||
|
title="О программе"
|
||||||
|
>
|
||||||
|
<Info size={18} />
|
||||||
|
</button>
|
||||||
|
<UserMenu email={email} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="md:hidden px-3 pt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-mk-mute">Состояние системы:</span>
|
||||||
|
<GlobalHealth />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 md:p-5">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Msg[]>([
|
||||||
|
{ 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 (
|
||||||
|
<div className={wrapperCls}>
|
||||||
|
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2">
|
||||||
|
<Bot size={18} className="text-mk-accent2" />
|
||||||
|
<div className="font-medium text-sm">Помощник</div>
|
||||||
|
<span className="ml-2 text-xs text-mk-mute">beta</span>
|
||||||
|
{!embedded && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
|
||||||
|
{msgs.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${
|
||||||
|
m.who === 'me'
|
||||||
|
? 'ml-auto bg-mk-accent/20 text-mk-text'
|
||||||
|
: 'mr-auto bg-mk-panel2 text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Спросите бота…"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" type="submit" aria-label="Отправить">
|
||||||
|
<Send size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <HapAcLiteMockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
if (isRb5009(boardName)) {
|
||||||
|
return <Rb5009Mockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
if (isRb4011(boardName)) {
|
||||||
|
return <Rb4011Mockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
if (isHexS(boardName)) {
|
||||||
|
return <HexSMockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
if (isL009(boardName)) {
|
||||||
|
return <L009Mockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
if (isChr(boardName)) {
|
||||||
|
return <ChrMockup interfaces={interfaces} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="card text-sm text-mk-mute">
|
||||||
|
Мокап для модели <span className="font-mono">{boardName || '—'}</span> ещё не подготовлен.
|
||||||
|
Статусы интерфейсов смотрите во вкладке «Интерфейсы».
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Лицевая панель <b>hAP ac lite</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ height: '66px', width: 'auto', maxWidth: '100%', display: 'block' }}
|
||||||
|
>
|
||||||
|
{/* Корпус */}
|
||||||
|
<rect x="2" y="2" width={W - 4} height={H - 4} rx={bodyR} ry={bodyR} fill="#5cb4e5" stroke="#3990c2" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Power разъём + подпись */}
|
||||||
|
<text x="60" y="35" fontSize="20" fill="#ffffff" fontWeight="700">Power</text>
|
||||||
|
<circle cx="60" cy="100" r="28" fill="#0a0a0a" stroke="#143d59" strokeWidth="3" />
|
||||||
|
<circle cx="60" cy="100" r="9" fill="#1a1a1a" stroke="#0a0a0a" strokeWidth="2" />
|
||||||
|
<text x="60" y="180" fontSize="13" fill="#ffffff" textAnchor="middle">DC10-28V</text>
|
||||||
|
|
||||||
|
{/* hAPaclite лого */}
|
||||||
|
<text x="225" y="40" fontSize="34" fill="#ffffff" fontWeight="800" fontFamily="Inter, sans-serif">hAP</text>
|
||||||
|
<text x="310" y="27" fontSize="13" fill="#ffffff" fontWeight="700">ac</text>
|
||||||
|
<text x="310" y="42" fontSize="13" fill="#ffffff" fontWeight="700">lite</text>
|
||||||
|
{/* WiFi-дуга над лого */}
|
||||||
|
<path d="M 230 14 Q 260 -2 290 14" fill="none" stroke="#ffffff" strokeWidth="2.5" />
|
||||||
|
|
||||||
|
{/* RES (кнопка с кругом и подписью WPS) */}
|
||||||
|
<circle cx="160" cy="100" r="14" fill="none" stroke="#d04848" strokeWidth="3" />
|
||||||
|
<circle cx="160" cy="100" r="4" fill="#222" />
|
||||||
|
<text x="160" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">RES</text>
|
||||||
|
<text x="160" y="135" fontSize="11" fill="#ffffff" textAnchor="middle">WPS</text>
|
||||||
|
|
||||||
|
{/* PWR кнопка (квадрат) */}
|
||||||
|
<text x="210" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">PWR</text>
|
||||||
|
<rect x="197" y="88" width="26" height="22" rx="3" fill="#444" stroke="#222" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* USR светодиод */}
|
||||||
|
<text x="260" y="78" fontSize="13" fill="#ffffff" textAnchor="middle" fontWeight="700">USR</text>
|
||||||
|
<rect x="251" y="92" width="18" height="14" rx="2" fill="#1f6f1f" />
|
||||||
|
|
||||||
|
{/* Тёмная полоса фоны для верхних/нижних лейблов */}
|
||||||
|
<rect x="350" y="8" width={W - 360} height="26" fill="#1c1c1c" />
|
||||||
|
<rect x="350" y="178" width={W - 360} height="40" fill="#1c1c1c" />
|
||||||
|
|
||||||
|
{/* Оранжевая зона PoE out над портом 5 */}
|
||||||
|
<rect
|
||||||
|
x={firstPortX + 4 * (portW + portGap) - 6}
|
||||||
|
y="8"
|
||||||
|
width={portW + 12}
|
||||||
|
height="26"
|
||||||
|
fill="#f0851a"
|
||||||
|
/>
|
||||||
|
{/* Оранжевая зона PoE out внизу */}
|
||||||
|
<rect
|
||||||
|
x={firstPortX + 4 * (portW + portGap) - 6}
|
||||||
|
y="178"
|
||||||
|
width={portW + 12}
|
||||||
|
height="40"
|
||||||
|
fill="#f0851a"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Порты */}
|
||||||
|
{ports.map((p, i) => {
|
||||||
|
const x = firstPortX + i * (portW + portGap);
|
||||||
|
const it = findPort(interfaces, p.name);
|
||||||
|
const col = portColor(it);
|
||||||
|
return (
|
||||||
|
<g key={p.name}>
|
||||||
|
{/* Верхний лейбл (Internet / 2 / 3 / 4 / 5) */}
|
||||||
|
<text
|
||||||
|
x={x + portW / 2}
|
||||||
|
y="27"
|
||||||
|
fontSize="16"
|
||||||
|
fill="#ffffff"
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Корпус порта (металлический ободок) */}
|
||||||
|
<rect x={x} y={portsTopY} width={portW} height={portH} rx="6" fill="#d4d4d4" stroke="#888" strokeWidth="1.5" />
|
||||||
|
{/* Внутренний экран порта */}
|
||||||
|
<rect x={x + 8} y={portsTopY + 8} width={portW - 16} height={portH - 16} rx="3" fill={col.fill} stroke={col.stroke} strokeWidth="3" />
|
||||||
|
{/* RJ45 «зубчики» */}
|
||||||
|
<rect x={x + 24} y={portsTopY + 14} width={portW - 48} height="14" fill="#000" />
|
||||||
|
<rect x={x + 30} y={portsTopY + 28} width={portW - 60} height="8" fill="#000" />
|
||||||
|
{/* LED-индикатор (точка) */}
|
||||||
|
<circle
|
||||||
|
cx={x + portW - 18}
|
||||||
|
cy={portsTopY + portH - 16}
|
||||||
|
r="4"
|
||||||
|
fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'}
|
||||||
|
/>
|
||||||
|
{/* Имя интерфейса под портом для понятности */}
|
||||||
|
<text x={x + portW / 2} y={portsTopY + portH - 6} fontSize="10" fill="#999" textAnchor="middle">{p.name}</text>
|
||||||
|
|
||||||
|
{/* Тултип через <title> */}
|
||||||
|
<title>
|
||||||
|
{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}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Нижние подписи: PoE in / LAN / PoE out */}
|
||||||
|
<text x={firstPortX + portW / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">PoE in</text>
|
||||||
|
<text x={firstPortX + portW + portGap + (portW * 3 + portGap * 2) / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">LAN</text>
|
||||||
|
<text x={firstPortX + 4 * (portW + portGap) + portW / 2} y="202" fontSize="14" fill="#ffffff" textAnchor="middle" fontWeight="600">PoE out</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mt-3 text-xs text-mk-mute">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up (running)
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled / нет данных
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Лицевая панель <b>RB5009UG+S+</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{/* Чёрный корпус */}
|
||||||
|
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#1a1a1a" stroke="#3a3a3a" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* DC jack */}
|
||||||
|
<text x="14" y="9" fontSize="3.5" fill="#cccccc" fontWeight="700" textAnchor="middle">12-57V DC</text>
|
||||||
|
<circle cx="14" cy="32" r="9" fill="#0a0a0a" stroke="#444" strokeWidth="0.8" />
|
||||||
|
<circle cx="14" cy="32" r="3" fill="#222" />
|
||||||
|
<text x="14" y="58" fontSize="3" fill="#888" textAnchor="middle">DC IN</text>
|
||||||
|
|
||||||
|
{/* RES */}
|
||||||
|
<text x="38" y="9" fontSize="4" fill="#cccccc" fontWeight="700" textAnchor="middle">R</text>
|
||||||
|
<circle cx="38" cy="22" r="2.5" fill="none" stroke="#d04848" strokeWidth="0.8" />
|
||||||
|
<circle cx="38" cy="22" r="1" fill="#222" />
|
||||||
|
<text x="38" y="58" fontSize="3" fill="#888" textAnchor="middle">RES</text>
|
||||||
|
|
||||||
|
{/* USB 3.0 */}
|
||||||
|
<text x="72" y="9" fontSize="4" fill="#cccccc" fontWeight="700" textAnchor="middle">USB</text>
|
||||||
|
<rect x="56" y="20" width="32" height="22" rx="1" fill="#0a0a0a" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x="58" y="22" width="28" height="18" fill="#1a4b8c" />
|
||||||
|
<rect x="66" y="26" width="12" height="6" fill="#0a0a0a" />
|
||||||
|
<text x="72" y="58" fontSize="3" fill="#888" textAnchor="middle">USB 3.0</text>
|
||||||
|
|
||||||
|
{/* PWR/USR LED */}
|
||||||
|
<circle cx="104" cy="12" r="2" fill="#22c55e" />
|
||||||
|
<text x="104" y="22" fontSize="3" fill="#888" textAnchor="middle">PWR</text>
|
||||||
|
<circle cx="120" cy="12" r="2" fill="#1f6f1f" />
|
||||||
|
<text x="120" y="22" fontSize="3" fill="#888" textAnchor="middle">USR</text>
|
||||||
|
|
||||||
|
{/* Лейблы цифр над портами + полоса акцента (PoE/2.5G) */}
|
||||||
|
{ports.map((p, i) => {
|
||||||
|
const x = portsStartX + i * (portW + gap);
|
||||||
|
const accent = accentColor(p.accent);
|
||||||
|
return (
|
||||||
|
<g key={`lbl-${p.name}`}>
|
||||||
|
{accent && (
|
||||||
|
<rect x={x} y="1" width={portW} height="3" fill={accent} />
|
||||||
|
)}
|
||||||
|
<text x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">{p.label}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Порты */}
|
||||||
|
{ports.map((p, i) => {
|
||||||
|
const x = portsStartX + i * (portW + gap);
|
||||||
|
const it = findPort(interfaces, p.name);
|
||||||
|
const col = portColor(it);
|
||||||
|
return (
|
||||||
|
<g key={p.name}>
|
||||||
|
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||||
|
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||||
|
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<title>
|
||||||
|
{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}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* SFP+ слот */}
|
||||||
|
{(() => {
|
||||||
|
const col = portColor(sfp);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={sfpX} y="1" width={sfpW} height="3" fill="#7c3aed" />
|
||||||
|
<text x={sfpX + sfpW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">SFP+</text>
|
||||||
|
<rect x={sfpX} y={portsY} width={sfpW} height={portH} rx="2" fill="#1a1a1a" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={sfpX + 3} y={portsY + 3} width={sfpW - 6} height={portH - 6} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||||
|
<rect x={sfpX + 3} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||||
|
<rect x={sfpX + sfpW - 7} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||||
|
<circle cx={sfpX + sfpW - 5} cy={portsY + portH - 4} r="1.3" fill={sfp?.running ? '#22c55e' : sfp?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<text x={sfpX + sfpW / 2} y={H - 2} fontSize="3.5" fill="#888" textAnchor="middle">10G SFP+</text>
|
||||||
|
<title>
|
||||||
|
sfp-sfpplus1 · 10 GbE SFP+
|
||||||
|
{'\n'}статус: {col.label}
|
||||||
|
{sfp?.comment ? `\ncomment: ${sfp.comment}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Подписи акцентов снизу */}
|
||||||
|
<text x={portsStartX + portW / 2} y={H - 2} fontSize="3" fill="#f0851a" textAnchor="middle">PoE in</text>
|
||||||
|
<text x={portsStartX + 7 * (portW + gap) + portW / 2} y={H - 2} fontSize="3" fill="#2563eb" textAnchor="middle">2.5G</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<MockupLegend />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Лицевая панель <b>RB4011iGS+</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{/* Чёрный корпус */}
|
||||||
|
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#1a1a1a" stroke="#3a3a3a" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* RESET кнопка */}
|
||||||
|
<circle cx="10" cy="24" r="3" fill="none" stroke="#d04848" strokeWidth="0.8" />
|
||||||
|
<circle cx="10" cy="24" r="1.2" fill="#222" />
|
||||||
|
<text x="10" y="44" fontSize="3.5" fill="#888" textAnchor="middle">RESET</text>
|
||||||
|
|
||||||
|
{/* PWR LED */}
|
||||||
|
<text x="22" y="20" fontSize="3.5" fill="#cccccc" fontWeight="700" textAnchor="middle">PWR</text>
|
||||||
|
<circle cx="22" cy="26" r="1.6" fill="#22c55e" />
|
||||||
|
|
||||||
|
{/* SFP+ слот */}
|
||||||
|
{(() => {
|
||||||
|
const col = portColor(sfp);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={sfpX} y="1" width={sfpW} height="3" fill="#7c3aed" />
|
||||||
|
<text x={sfpX + sfpW / 2} y="10" fontSize="5.5" fill="#ffffff" fontWeight="800" textAnchor="middle">SFP+</text>
|
||||||
|
<rect x={sfpX} y={portsY} width={sfpW} height={portH} rx="2" fill="#1a1a1a" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={sfpX + 3} y={portsY + 3} width={sfpW - 6} height={portH - 6} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||||
|
<rect x={sfpX + 3} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||||
|
<rect x={sfpX + sfpW - 7} y={portsY + 3} width="4" height={portH - 6} fill="#0a0a0a" />
|
||||||
|
<circle cx={sfpX + sfpW - 5} cy={portsY + portH - 4} r="1.3" fill={sfp?.running ? '#22c55e' : sfp?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<text x={sfpX + sfpW / 2} y={H - 2} fontSize="3.5" fill="#aaaaaa" textAnchor="middle">SFP+ 10G</text>
|
||||||
|
<title>
|
||||||
|
sfp-sfpplus1 · 10 GbE SFP+
|
||||||
|
{'\n'}статус: {col.label}
|
||||||
|
{sfp?.comment ? `\ncomment: ${sfp.comment}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Акцентная полоска PoE-in над ether1 */}
|
||||||
|
<rect x={group1StartX} y="1" width={portW} height="3" fill="#f0851a" />
|
||||||
|
|
||||||
|
{/* Лейблы цифр над портами 1-5 */}
|
||||||
|
{portsLeft.map((p, i) => {
|
||||||
|
const x = group1StartX + i * (portW + gap);
|
||||||
|
return (
|
||||||
|
<text key={`lbl-${p.name}`} x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Порты 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 (
|
||||||
|
<g key={p.name}>
|
||||||
|
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||||
|
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||||
|
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<title>
|
||||||
|
{p.name} (порт {p.label}){isPoeIn ? ' · PoE in 18-57V' : ''}
|
||||||
|
{'\n'}статус: {col.label}
|
||||||
|
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||||
|
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Подпись группы 1-5 снизу */}
|
||||||
|
<text
|
||||||
|
x={group1StartX + (5 * (portW + gap) - gap) / 2}
|
||||||
|
y={H - 2}
|
||||||
|
fontSize="3.5"
|
||||||
|
fill="#f0851a"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontWeight="700"
|
||||||
|
>
|
||||||
|
PoE in 18-57V
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Центральная LED-матрица статусов */}
|
||||||
|
{(() => {
|
||||||
|
const lx = group1StartX + 5 * (portW + gap) - gap + ledBlockGap;
|
||||||
|
const cy1 = portsY + 9;
|
||||||
|
const cy2 = portsY + portH - 9;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={lx} y={portsY} width={ledBlockW} height={portH} rx="1.5" fill="#0a0a0a" stroke="#444" strokeWidth="0.4" />
|
||||||
|
{[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 (
|
||||||
|
<g key={`led-${i}`}>
|
||||||
|
<circle cx={cx} cy={cy1} r="1.3" fill={top?.running ? '#22c55e' : top?.disabled ? '#444' : '#1f3f1f'}>
|
||||||
|
<title>{top ? `ether${i + 1}: ${top.running ? 'up' : top.disabled ? 'disabled' : 'down'}` : `ether${i + 1}: нет данных`}</title>
|
||||||
|
</circle>
|
||||||
|
<circle cx={cx} cy={cy2} r="1.3" fill={bot?.running ? '#22c55e' : bot?.disabled ? '#444' : '#1f3f1f'}>
|
||||||
|
<title>{bot ? `ether${i + 6}: ${bot.running ? 'up' : bot.disabled ? 'disabled' : 'down'}` : `ether${i + 6}: нет данных`}</title>
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Акцентная полоска PoE-out над ether10 */}
|
||||||
|
<rect x={group2StartX + 4 * (portW + gap)} y="1" width={portW} height="3" fill="#f0851a" />
|
||||||
|
|
||||||
|
{/* Лейблы цифр над портами 6-10 */}
|
||||||
|
{portsRight.map((p, i) => {
|
||||||
|
const x = group2StartX + i * (portW + gap);
|
||||||
|
return (
|
||||||
|
<text key={`lbl-${p.name}`} x={x + portW / 2} y="10" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Порты 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 (
|
||||||
|
<g key={p.name}>
|
||||||
|
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#c8c8c8" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.5" />
|
||||||
|
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||||
|
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<title>
|
||||||
|
{p.name} (порт {p.label}){isPoeOut ? ' · PoE out' : ''}
|
||||||
|
{'\n'}статус: {col.label}
|
||||||
|
{it?.comment ? `\ncomment: ${it.comment}` : ''}
|
||||||
|
{it?.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Подпись группы 6-10 снизу */}
|
||||||
|
<text
|
||||||
|
x={group2StartX + (5 * (portW + gap) - gap) / 2}
|
||||||
|
y={H - 2}
|
||||||
|
fontSize="3.5"
|
||||||
|
fill="#f0851a"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontWeight="700"
|
||||||
|
>
|
||||||
|
PoE out
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<MockupLegend />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Виртуальный роутер <b>MikroTik CHR</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: '500px', height: '66px', maxWidth: '100%', display: 'block' }}
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{/* Белый фон-корпус */}
|
||||||
|
<rect x="1" y="1" width={W - 2} height={H - 2} rx="6" fill="#ffffff" stroke="#cccccc" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Лейбл mikrotik слева (шрифт в 2 раза мельче) */}
|
||||||
|
<text x={padX} y={H / 2} fontSize="14" fill="#1a1a1a" fontWeight="800" fontFamily="Inter, sans-serif">mikrotik</text>
|
||||||
|
<text x={padX} y={H / 2 + 12} fontSize="6" fill="#666666">Cloud Hosted Router</text>
|
||||||
|
|
||||||
|
{/* Разделитель */}
|
||||||
|
<line x1={padX + labelW - 4} y1="8" x2={padX + labelW - 4} y2={H - 8} stroke="#dddddd" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Порты */}
|
||||||
|
{ports.length === 0 && (
|
||||||
|
<text x={portsStartX + 10} y={H / 2 + 3} fontSize="7" fill="#888888">нет интерфейсов ether*</text>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<g key={it.name}>
|
||||||
|
{/* Корпус виртуального порта */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={portsY}
|
||||||
|
width={portW}
|
||||||
|
height={portH}
|
||||||
|
rx="3"
|
||||||
|
fill={col.fill}
|
||||||
|
stroke={col.stroke}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
{/* Номер порта внутри */}
|
||||||
|
<text
|
||||||
|
x={x + portW / 2}
|
||||||
|
y={portsY + portH / 2 + 4}
|
||||||
|
fontSize="12"
|
||||||
|
fill={it.running ? '#86efac' : it.disabled ? '#bbbbbb' : '#fca5a5'}
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</text>
|
||||||
|
{/* Имя интерфейса под портом */}
|
||||||
|
<text
|
||||||
|
x={x + portW / 2}
|
||||||
|
y={portsY + portH + 8}
|
||||||
|
fontSize="5"
|
||||||
|
fill="#888888"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{it.name}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<title>
|
||||||
|
{it.name}
|
||||||
|
{it.type ? ` · ${it.type}` : ''}
|
||||||
|
{'\n'}статус: {col.label}
|
||||||
|
{it.comment ? `\ncomment: ${it.comment}` : ''}
|
||||||
|
{it.mac_address ? `\nmac: ${it.mac_address}` : ''}
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mt-3 text-xs text-mk-mute">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up (running)
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled / нет данных
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Лицевая панель <b>hEX S</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{/* Корпус тёмно-серый */}
|
||||||
|
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#3a3f47" stroke="#1f2227" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Power разъём + подпись */}
|
||||||
|
<text x="14" y="13" fontSize="5" fill="#dddddd" fontWeight="700">Power</text>
|
||||||
|
<circle cx="14" cy="32" r="7" fill="#0a0a0a" stroke="#222" strokeWidth="0.8" />
|
||||||
|
<circle cx="14" cy="32" r="2.2" fill="#222" />
|
||||||
|
<text x="14" y="48" fontSize="4" fill="#aaaaaa" textAnchor="middle">12-57V DC</text>
|
||||||
|
|
||||||
|
{/* hEX s лого */}
|
||||||
|
<text x="44" y="14" fontSize="11" fill="#ffffff" fontWeight="900" fontFamily="Inter, sans-serif">hEX</text>
|
||||||
|
<text x="68" y="11" fontSize="5" fill="#ffffff" fontWeight="700">s</text>
|
||||||
|
|
||||||
|
{/* SFP слот */}
|
||||||
|
<rect x="42" y="22" width="28" height="22" rx="2" fill="#0a0a0a" stroke="#555" strokeWidth="0.5" />
|
||||||
|
{(() => {
|
||||||
|
const col = portColor(sfp);
|
||||||
|
return <rect x="44" y="24" width="24" height="18" rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1">
|
||||||
|
<title>{sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'}</title>
|
||||||
|
</rect>;
|
||||||
|
})()}
|
||||||
|
<text x="56" y="52" fontSize="4" fill="#aaaaaa" textAnchor="middle">SFP</text>
|
||||||
|
<text x="56" y="58" fontSize="4" fill="#888888" textAnchor="middle" fontStyle="italic">INTERNET</text>
|
||||||
|
|
||||||
|
{/* Passive/af/at подпись над портом 1 */}
|
||||||
|
<rect x={portsStartX - 1} y="3" width={portW + 2} height="8" rx="2" fill="#1f2227" stroke="#555" strokeWidth="0.4" />
|
||||||
|
<text x={portsStartX + portW / 2} y="9" fontSize="4" fill="#dddddd" fontWeight="700" textAnchor="middle">Passive/af/at</text>
|
||||||
|
|
||||||
|
{/* Оранжевая зона над/под портом 5 (PoE out) */}
|
||||||
|
<rect x={portsStartX + 4 * (portW + gap) - 1} y="0" width={portW + 2} height="12" fill="#f0851a" />
|
||||||
|
<rect x={portsStartX + 4 * (portW + gap) - 1} y={H - 8} width={portW + 2} height="8" fill="#f0851a" />
|
||||||
|
|
||||||
|
{/* Лейблы цифр над портами 2-5 */}
|
||||||
|
{ports.slice(1).map((p, idx) => {
|
||||||
|
const i = idx + 1;
|
||||||
|
const x = portsStartX + i * (portW + gap);
|
||||||
|
return (
|
||||||
|
<text key={p.label} x={x + portW / 2} y="9" fontSize="6" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Порты */}
|
||||||
|
{ports.map((p, i) => {
|
||||||
|
const x = portsStartX + i * (portW + gap);
|
||||||
|
const it = findPort(interfaces, p.name);
|
||||||
|
const col = portColor(it);
|
||||||
|
return (
|
||||||
|
<g key={p.name}>
|
||||||
|
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#d4d0c4" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.2" />
|
||||||
|
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||||
|
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<title>{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''}</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Нижние подписи */}
|
||||||
|
<text x={portsStartX + portW / 2} y={H - 2} fontSize="3.5" fill="#dddddd" textAnchor="middle">PoE in</text>
|
||||||
|
<text x={portsStartX + (portW + gap) + (3 * (portW + gap) - gap) / 2} y={H - 2} fontSize="3.5" fill="#aaaaaa" textAnchor="middle">LAN</text>
|
||||||
|
<text x={portsStartX + 4 * (portW + gap) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE out</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<MockupLegend />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- 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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute mb-2">
|
||||||
|
Лицевая панель <b>L009UiGS</b> · подсветка портов в реальном времени
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: `${W}px`, height: '66px', maxWidth: '100%', display: 'block' }}
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{/* Красный корпус */}
|
||||||
|
<rect x="1" y="1" width={W - 2} height={H - 2} rx="4" fill="#c92020" stroke="#7a1010" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* RES кнопка */}
|
||||||
|
<text x="10" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">RES</text>
|
||||||
|
<circle cx="10" cy="22" r="2.2" fill="none" stroke="#ffffff" strokeWidth="0.8" />
|
||||||
|
<circle cx="10" cy="22" r="0.9" fill="#222" />
|
||||||
|
{/* power led */}
|
||||||
|
<text x="10" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">⏻</text>
|
||||||
|
|
||||||
|
{/* DC разъём */}
|
||||||
|
<text x="28" y="9" fontSize="3.5" fill="#ffffff" textAnchor="middle">24-56 V DC</text>
|
||||||
|
<circle cx="28" cy="32" r="9" fill="#0a0a0a" stroke="#5a0a0a" strokeWidth="1" />
|
||||||
|
<circle cx="28" cy="32" r="3" fill="#222" />
|
||||||
|
<text x="28" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">⊖-⊙-⊕</text>
|
||||||
|
|
||||||
|
{/* SFP слот */}
|
||||||
|
<text x="60" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">SFP</text>
|
||||||
|
<rect x="48" y="16" width="24" height="32" rx="1.5" fill="#0a0a0a" stroke="#888" strokeWidth="0.5" />
|
||||||
|
{(() => {
|
||||||
|
const col = portColor(sfp);
|
||||||
|
return <rect x="50" y="18" width="20" height="28" rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1">
|
||||||
|
<title>{sfp ? `${sfp.name} · SFP\nстатус: ${col.label}` : 'SFP · нет данных'}</title>
|
||||||
|
</rect>;
|
||||||
|
})()}
|
||||||
|
<text x="60" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">SFP</text>
|
||||||
|
|
||||||
|
{/* USB 3.0 */}
|
||||||
|
<text x="92" y="9" fontSize="4" fill="#ffffff" fontWeight="700" textAnchor="middle">USB</text>
|
||||||
|
<rect x="78" y="20" width="28" height="22" rx="1" fill="#0a0a0a" stroke="#888" strokeWidth="0.5" />
|
||||||
|
<rect x="80" y="22" width="24" height="18" fill="#1a4b8c" />
|
||||||
|
<rect x="88" y="26" width="8" height="6" fill="#0a0a0a" />
|
||||||
|
<text x="92" y="58" fontSize="3" fill="#ffffff" textAnchor="middle">USB 3.0</text>
|
||||||
|
|
||||||
|
{/* Оранжевая зона над/под портом 8 (PoE out) */}
|
||||||
|
<rect x={xOf(7) - 1} y="0" width={portW + 2} height="11" fill="#f0851a" />
|
||||||
|
<rect x={xOf(7) - 1} y={H - 8} width={portW + 2} height="8" fill="#f0851a" />
|
||||||
|
|
||||||
|
{/* Лейблы цифр над портами */}
|
||||||
|
{ports.map((p, i) => (
|
||||||
|
<text key={p.label} x={xOf(i) + portW / 2} y="8" fontSize="5.5" fill="#ffffff" fontWeight="800" textAnchor="middle">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Порты */}
|
||||||
|
{ports.map((p, i) => {
|
||||||
|
const x = xOf(i);
|
||||||
|
const it = findPort(interfaces, p.name);
|
||||||
|
const col = portColor(it);
|
||||||
|
return (
|
||||||
|
<g key={p.name}>
|
||||||
|
<rect x={x} y={portsY} width={portW} height={portH} rx="2" fill="#d4d0c4" stroke="#666" strokeWidth="0.5" />
|
||||||
|
<rect x={x + 2} y={portsY + 2} width={portW - 4} height={portH - 4} rx="1" fill={col.fill} stroke={col.stroke} strokeWidth="1.2" />
|
||||||
|
<rect x={x + 6} y={portsY + 4} width={portW - 12} height="5" fill="#000" />
|
||||||
|
<circle cx={x + portW - 4} cy={portsY + portH - 4} r="1.3" fill={it?.running ? '#22c55e' : it?.disabled ? '#777' : '#5a1a1a'} />
|
||||||
|
<title>{p.name} (порт {p.label}){p.accent === 'poe-in' ? ' · PoE in' : p.accent === 'poe-out' ? ' · PoE out' : ''}{'\n'}статус: {col.label}{it?.comment ? `\ncomment: ${it.comment}` : ''}</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Нижние подписи скоростей */}
|
||||||
|
<text x={xOf(0) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE in</text>
|
||||||
|
<text x={xOf(7) + portW / 2} y={H - 2} fontSize="3.5" fill="#ffffff" textAnchor="middle" fontWeight="700">PoE out</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<MockupLegend />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общая мини-легенда для физических мокапов.
|
||||||
|
function MockupLegend() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-2 text-[10px] text-mk-mute">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-sm bg-mk-ok/30 ring-1 ring-mk-ok" /> up
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-sm bg-mk-err/10 ring-1 ring-mk-err" /> down
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-sm bg-mk-panel2 ring-1 ring-mk-mute" /> disabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FirmwareChannelsOut | null>(null);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const reload = () => api.get<FirmwareChannelsOut>('/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 (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Каналы RouterOS</h3>
|
||||||
|
<button className="ml-auto btn-ghost !py-1 !text-xs" onClick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} /> Проверить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{order.map((ch) => {
|
||||||
|
const info = data.channels[ch];
|
||||||
|
const ok = info?.last_check_ok !== false && info?.version;
|
||||||
|
return (
|
||||||
|
<div key={ch} className="border border-mk-border rounded-md p-3 bg-mk-panel2/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 size={14} className="text-mk-ok" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle size={14} className="text-mk-warn" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm">{ch}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold mt-1">{info?.version || '—'}</div>
|
||||||
|
<div className="text-[11px] text-mk-mute mt-1">
|
||||||
|
Выпущена: {fmtDt(info?.released_at)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-mk-mute">
|
||||||
|
Проверено: {fmtDt(info?.last_check)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Минимальный i18n: словарь + хук useT(). Без внешних зависимостей.
|
||||||
|
import { useSettings } from '../store/settings';
|
||||||
|
|
||||||
|
export type Locale = 'ru' | 'en' | 'uz';
|
||||||
|
|
||||||
|
const dict: Record<Locale, Record<string, string>> = {
|
||||||
|
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 мин' },
|
||||||
|
];
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -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 <AlertCircle size={14} className="text-mk-err" />;
|
||||||
|
if (s === 'warning') return <AlertTriangle size={14} className="text-mk-warn" />;
|
||||||
|
return <Info size={14} className="text-mk-accent2" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const [alerts, setAlerts] = useState<AlertT[]>([]);
|
||||||
|
const [onlyUnack, setOnlyUnack] = useState(false);
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
api.get<AlertT[]>('/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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell size={16} />
|
||||||
|
<h2 className="text-base font-semibold">Alert Center</h2>
|
||||||
|
<span className="text-xs text-mk-mute">всего: {alerts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-mk-mute flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" checked={onlyUnack} onChange={(e) => setOnlyUnack(e.target.checked)} />
|
||||||
|
только непрочитанные
|
||||||
|
</label>
|
||||||
|
<button className="btn-ghost !py-1 !text-xs" onClick={ackAll}>
|
||||||
|
<CheckCheck size={13} /> Прочитать всё
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-1 !text-xs text-mk-warn" onClick={purge}>
|
||||||
|
<Eraser size={13} /> Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 w-8"></th>
|
||||||
|
<th className="text-left px-2 py-1.5">Заголовок</th>
|
||||||
|
<th className="text-left px-2 py-1.5">Категория</th>
|
||||||
|
<th className="text-left px-2 py-1.5">Источник</th>
|
||||||
|
<th className="text-left px-2 py-1.5">Время</th>
|
||||||
|
<th className="text-right px-2 py-1.5">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{alerts.length === 0 && (
|
||||||
|
<tr><td colSpan={6} className="px-3 py-3 text-center text-mk-mute">Нет алертов</td></tr>
|
||||||
|
)}
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<tr key={a.id} className={`border-t border-mk-border hover:bg-mk-panel2/40 ${
|
||||||
|
a.acknowledged ? 'opacity-60' : ''
|
||||||
|
}`}>
|
||||||
|
<td className="px-2 py-1">{sevIcon(a.severity)}</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<div className={a.acknowledged ? '' : 'font-medium'}>{a.title}</div>
|
||||||
|
{a.message && <div className="text-[11px] text-mk-mute">{a.message}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute">{a.category}</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute font-mono text-[11px]">{a.source ?? '—'}</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute text-[11px]">{new Date(a.created_at).toLocaleString()}</td>
|
||||||
|
<td className="px-2 py-1 text-right">
|
||||||
|
{!a.acknowledged && (
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => ack(a.id)} title="Прочитано">
|
||||||
|
<CheckCheck size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(a.id)} title="Удалить">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Device[]>([]);
|
||||||
|
const initialIds = (params.get('ids') ?? '').split(',').map(Number).filter(Boolean);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set(initialIds));
|
||||||
|
const [command, setCommand] = useState('/system/resource/print');
|
||||||
|
const [out, setOut] = useState<CLIRunOut | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(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<Device[]>('/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<CLIRunOut>('/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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal size={16} />
|
||||||
|
<h2 className="text-base font-semibold">Автоматизация</h2>
|
||||||
|
<span className="text-xs text-mk-mute">CLI и помощник</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border-b border-mk-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('cli')}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
|
tab === 'cli'
|
||||||
|
? 'border-mk-accent text-mk-text'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Terminal size={14} /> CLI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('assistant')}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
|
tab === 'assistant'
|
||||||
|
? 'border-mk-accent text-mk-text'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bot size={14} /> Помощник
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('firmware')}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
|
tab === 'firmware'
|
||||||
|
? 'border-mk-accent text-mk-text'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HardDrive size={14} /> Репозиторий прошивок
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'assistant' && <ChatBot embedded />}
|
||||||
|
{tab === 'firmware' && <FirmwarePage embedded />}
|
||||||
|
|
||||||
|
{tab === 'cli' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div className="card p-3">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-mk-mute mb-2">Устройства ({selected.size})</h3>
|
||||||
|
<div className="max-h-64 overflow-auto space-y-0.5">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<label key={d.id} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
|
||||||
|
<input type="checkbox" checked={selected.has(d.id)} onChange={() => toggle(d.id)} />
|
||||||
|
<span className={`w-2 h-2 rounded-full ${d.status === 'up' ? 'bg-mk-ok' : d.status === 'down' ? 'bg-mk-err' : 'bg-mk-mute'}`} />
|
||||||
|
<span className="truncate">{d.identity || d.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-mk-mute font-mono">{d.host}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-3 md:col-span-2 space-y-2">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-mk-mute">Команда</h3>
|
||||||
|
<textarea
|
||||||
|
className="input font-mono text-sm h-20"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
placeholder="/system/resource/print"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded bg-mk-panel2 hover:bg-mk-panel2/60 text-mk-mute font-mono"
|
||||||
|
onClick={() => setCommand(p)}
|
||||||
|
>{p}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isDangerous && (
|
||||||
|
<div className="text-xs text-mk-warn flex items-center gap-1.5">
|
||||||
|
<AlertTriangle size={12} /> Опасная команда — потребуется подтверждение
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-primary" onClick={run} disabled={busy}>
|
||||||
|
<Play size={14} /> {busy ? 'Выполнение…' : 'Запустить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{out && (
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-mk-border text-xs text-mk-mute font-mono">
|
||||||
|
$ {out.command}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-mk-border">
|
||||||
|
{out.results.map((r) => (
|
||||||
|
<div key={r.device_id} className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${r.ok ? 'bg-mk-ok' : 'bg-mk-err'}`} />
|
||||||
|
<span className="text-sm font-medium">{r.device_name ?? `device:${r.device_id}`}</span>
|
||||||
|
</div>
|
||||||
|
{r.error && <div className="text-xs text-mk-err font-mono">{r.error}</div>}
|
||||||
|
{r.ok && r.rows && (
|
||||||
|
<pre className="text-[11px] font-mono bg-mk-bg p-2 rounded overflow-auto max-h-64">
|
||||||
|
{JSON.stringify(r.rows, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Activity, Router as RouterIcon, AlertTriangle, CheckCircle2, WifiOff, Bell } from 'lucide-react';
|
||||||
|
import { api, Alert as AlertT, Device, HeartbeatBucket, HeartbeatOut } from '@/api/client';
|
||||||
|
import { useSettings } from '@/store/settings';
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon, label, value, accent,
|
||||||
|
}: { icon: any; label: string; value: string | number; accent: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card flex items-center gap-3 !p-4">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${accent}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-mk-mute uppercase tracking-wider">{label}</div>
|
||||||
|
<div className="text-xl font-semibold leading-tight">{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKET_COLORS: Record<HeartbeatBucket, string> = {
|
||||||
|
up: 'bg-mk-ok',
|
||||||
|
'no-net':'bg-mk-warn',
|
||||||
|
down: 'bg-mk-err',
|
||||||
|
none: 'bg-mk-panel2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUCKET_LABEL: Record<HeartbeatBucket, string> = {
|
||||||
|
up: 'OK', 'no-net': 'нет интернета', down: 'оффлайн', none: 'нет данных',
|
||||||
|
};
|
||||||
|
|
||||||
|
function HeartbeatGrid({ data }: { data: HeartbeatOut }) {
|
||||||
|
const since = new Date(data.since);
|
||||||
|
const until = new Date(data.until);
|
||||||
|
const binMin = Math.round(((until.getTime() - since.getTime()) / data.bins) / 60000);
|
||||||
|
return (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Activity size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Heartbeat — {data.hours < 1 ? `${Math.round(data.hours * 60)}м` : `${data.hours}ч`}</h3>
|
||||||
|
<span className="text-[11px] text-mk-mute sm:ml-auto">
|
||||||
|
{data.bins} бинов × {binMin} мин
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.devices.length === 0 ? (
|
||||||
|
<div className="text-sm text-mk-mute">Нет устройств.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 overflow-x-auto -mx-1 px-1">
|
||||||
|
{data.devices.map((d) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-2 sm:gap-3 min-w-[420px]">
|
||||||
|
<div className="w-24 sm:w-32 shrink-0 truncate text-xs">
|
||||||
|
<div className="font-medium truncate">{d.name}</div>
|
||||||
|
<div className="text-[10px] text-mk-mute font-mono truncate">{d.host}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 grid gap-[1px]" style={{ gridTemplateColumns: `repeat(${data.bins}, minmax(0,1fr))` }}>
|
||||||
|
{d.buckets.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-3.5 rounded-[2px] ${BUCKET_COLORS[b]}`}
|
||||||
|
title={`Бин ${i + 1}/${data.bins}: ${BUCKET_LABEL[b]}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={`badge-${d.status === 'up' ? 'up' : d.status === 'down' ? 'down' : 'unk'} text-[10px]`}>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 text-[11px] text-mk-mute pt-1 border-t border-mk-border">
|
||||||
|
{(['up', 'no-net', 'down', 'none'] as HeartbeatBucket[]).map((b) => (
|
||||||
|
<span key={b} className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[b]}`} /> {BUCKET_LABEL[b]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [hb, setHb] = useState<HeartbeatOut | null>(null);
|
||||||
|
const hours = useSettings((s) => s.settings?.ui?.heartbeat_hours ?? 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Device[]>('/devices').then((r) => setDevices(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// бины: ~120 ячеек, не меньше 60, не больше 180 (более мелкая сетка)
|
||||||
|
const bins = Math.max(60, Math.min(180, Math.round(Number(hours) * 24)));
|
||||||
|
const loadHb = () => api.get<HeartbeatOut>(`/heartbeat?hours=${hours}&bins=${bins}`)
|
||||||
|
.then((r) => setHb(r.data)).catch(() => {});
|
||||||
|
loadHb();
|
||||||
|
const t = setInterval(loadHb, 60000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [hours]);
|
||||||
|
|
||||||
|
const up = devices.filter((d) => d.status === 'up').length;
|
||||||
|
const down = devices.filter((d) => d.status === 'down').length;
|
||||||
|
const unknown = devices.filter((d) => d.status !== 'up' && d.status !== 'down').length;
|
||||||
|
const noNet = devices.filter((d) => d.internet_ok === false).length;
|
||||||
|
const abnormal = devices.filter((d) => d.abnormal_reboot).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
<StatCard icon={RouterIcon} label="Устройства" value={devices.length} accent="bg-mk-accent/15 text-mk-accent2" />
|
||||||
|
<StatCard icon={CheckCircle2} label="Online" value={up} accent="bg-mk-ok/15 text-mk-ok" />
|
||||||
|
<StatCard icon={AlertTriangle} label="Offline" value={down} accent="bg-mk-err/15 text-mk-err" />
|
||||||
|
<StatCard icon={WifiOff} label="Без интернета" value={noNet} accent="bg-mk-warn/15 text-mk-warn" />
|
||||||
|
<StatCard icon={Activity} label="Аварийные reboot" value={abnormal} accent="bg-mk-warn/15 text-mk-warn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hb && <HeartbeatGrid data={hb} />}
|
||||||
|
|
||||||
|
<ActivityWidget />
|
||||||
|
|
||||||
|
{unknown > 0 && (
|
||||||
|
<div className="card text-xs text-mk-mute">
|
||||||
|
Устройства без статуса: {unknown}. Откройте карточку устройства и нажмите «Опросить».
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityWidget() {
|
||||||
|
const [alerts, setAlerts] = useState<AlertT[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<AlertT[]>('/alerts', { params: { only_unack: false } })
|
||||||
|
.then((r) => setAlerts(r.data.slice(0, 30))).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const sevColor = (s: string) =>
|
||||||
|
s === 'critical' ? 'text-mk-err' :
|
||||||
|
s === 'error' ? 'text-mk-err' :
|
||||||
|
s === 'warning' ? 'text-mk-warn' :
|
||||||
|
'text-mk-mute';
|
||||||
|
return (
|
||||||
|
<div className="card !p-0 overflow-hidden">
|
||||||
|
<div className="flex border-b border-mk-border">
|
||||||
|
<div className="px-4 py-2 text-xs font-medium inline-flex items-center gap-1.5 border-b-2 -mb-px border-mk-accent2 text-mk-text">
|
||||||
|
<Bell size={13} /> Алерты <span className="text-[10px] opacity-70">({alerts.length})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto divide-y divide-mk-border">
|
||||||
|
{alerts.length === 0 && (
|
||||||
|
<div className="px-4 py-6 text-center text-mk-mute text-sm">Нет алертов</div>
|
||||||
|
)}
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<div key={a.id} className={`px-3 py-1.5 text-xs flex items-start gap-2 ${a.acknowledged ? 'opacity-60' : ''}`}>
|
||||||
|
<span className={`${sevColor(a.severity)} font-medium uppercase text-[10px] mt-0.5 w-14 shrink-0`}>{a.severity}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate">{a.title}</div>
|
||||||
|
{a.message && <div className="text-[11px] text-mk-mute truncate">{a.message}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-mk-mute font-mono shrink-0">{new Date(a.created_at).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,904 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft, RefreshCw, Power, ShieldAlert, Save, Download, Trash2, ArrowUpCircle,
|
||||||
|
Wifi, WifiOff, AlertTriangle, Activity as ActivityIcon, Network,
|
||||||
|
Globe, HardDrive, Pencil, Cloud, Package, Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AreaChart, Area, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
api, Device, DeviceBackup, DeviceResource, Firmware, MetricPoint,
|
||||||
|
InterfaceInfo, InterfaceTrafficOut, UplinkStatus, DhcpLease,
|
||||||
|
} from '@/api/client';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { latestStableVersion, isOutdated } from '@/utils/version';
|
||||||
|
import { EditDeviceModal } from './Devices';
|
||||||
|
import DeviceMockup from '@/components/DeviceMockup';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'about' | 'interfaces' | 'firmware' | 'backups' | 'ipmgmt';
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === 'up' ? 'bg-mk-ok' :
|
||||||
|
status === 'down' ? 'bg-mk-err' :
|
||||||
|
'bg-mk-mute';
|
||||||
|
return <span className={`inline-block w-4 h-4 rounded-full ${cls}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(b: number): string {
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(b / 1024 / 1024).toFixed(2)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBps(b: number): string {
|
||||||
|
if (b < 1000) return `${b.toFixed(0)} bps`;
|
||||||
|
if (b < 1_000_000) return `${(b / 1000).toFixed(1)} Kbps`;
|
||||||
|
if (b < 1_000_000_000) return `${(b / 1_000_000).toFixed(2)} Mbps`;
|
||||||
|
return `${(b / 1_000_000_000).toFixed(2)} Gbps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(s: string | null | undefined): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
return s.split(',').map((x) => x.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#ef4444', '#eab308'];
|
||||||
|
|
||||||
|
export default function DeviceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [d, setD] = useState<Device | null>(null);
|
||||||
|
const [tab, setTab] = useState<Tab>('about');
|
||||||
|
const [res, setRes] = useState<DeviceResource | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||||
|
const [backups, setBackups] = useState<DeviceBackup[]>([]);
|
||||||
|
const [firmwares, setFirmwares] = useState<Firmware[]>([]);
|
||||||
|
const [latestVer, setLatestVer] = useState<string | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [selectedFw, setSelectedFw] = useState<number | ''>('');
|
||||||
|
const [showAllFw, setShowAllFw] = useState(false);
|
||||||
|
const [upgradeChannel, setUpgradeChannel] = useState<'stable' | 'long-term' | 'testing' | 'development'>('stable');
|
||||||
|
const token = useAuth((s) => s.accessToken);
|
||||||
|
|
||||||
|
const load = () => api.get<Device>(`/devices/${id}`).then((r) => setD(r.data));
|
||||||
|
const loadBackups = () => api.get<DeviceBackup[]>(`/devices/${id}/backups`).then((r) => setBackups(r.data));
|
||||||
|
const loadMetrics = () => api.get<MetricPoint[]>(`/devices/${id}/metrics`, { params: { hours: 24 } }).then((r) => setMetrics(r.data));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
loadBackups();
|
||||||
|
loadMetrics();
|
||||||
|
api.get<Firmware[]>('/firmware').then((r) => {
|
||||||
|
setFirmwares(r.data);
|
||||||
|
setLatestVer(latestStableVersion(r.data));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const probe = async () => {
|
||||||
|
setBusy(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<DeviceResource>(`/devices/${id}/probe`);
|
||||||
|
setRes(data);
|
||||||
|
await load();
|
||||||
|
await loadMetrics();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка опроса');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const reboot = async () => {
|
||||||
|
if (!confirm('Перезагрузить устройство?')) return;
|
||||||
|
setActionBusy('reboot'); setActionMsg(null);
|
||||||
|
try { await api.post(`/devices/${id}/reboot`); setActionMsg('Команда reboot отправлена'); }
|
||||||
|
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка reboot'); }
|
||||||
|
finally { setActionBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeMode = async () => {
|
||||||
|
setActionBusy('safemode'); setActionMsg(null);
|
||||||
|
try { await api.post(`/devices/${id}/safe-mode`); setActionMsg('Safe mode переключён'); }
|
||||||
|
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка safe mode'); }
|
||||||
|
finally { setActionBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeBackup = async () => {
|
||||||
|
setActionBusy('backup'); setActionMsg(null);
|
||||||
|
try { await api.post(`/devices/${id}/backups`); await loadBackups(); setActionMsg('Бэкап создан'); }
|
||||||
|
catch (ex: any) { setActionMsg(ex?.response?.data?.detail ?? 'Ошибка бэкапа'); }
|
||||||
|
finally { setActionBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const upgradeFromInternet = async () => {
|
||||||
|
if (!confirm(`Обновить RouterOS из интернета (канал ${upgradeChannel})?\nУстройство будет перезагружено.`)) return;
|
||||||
|
setActionBusy('upgrade-net'); setActionMsg(null);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/devices/${id}/upgrade/internet`, null, {
|
||||||
|
params: { channel: upgradeChannel, install: true },
|
||||||
|
});
|
||||||
|
setActionMsg(`Обновление запущено: ${JSON.stringify(data)}`);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setActionMsg(ex?.response?.data?.detail ?? 'Ошибка обновления');
|
||||||
|
} finally { setActionBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const upgradeFromLocal = async () => {
|
||||||
|
if (!selectedFw) { setActionMsg('Сначала выберите прошивку из репозитория'); return; }
|
||||||
|
if (!confirm('Загрузить прошивку с контроллера и перезагрузить устройство для установки?')) return;
|
||||||
|
setActionBusy('upgrade-local'); setActionMsg(null);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/devices/${id}/upgrade/local`, null, {
|
||||||
|
params: { firmware_id: selectedFw, reboot: true },
|
||||||
|
});
|
||||||
|
setActionMsg(`Прошивка загружена и перезагрузка отправлена: ${JSON.stringify(data)}`);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setActionMsg(ex?.response?.data?.detail ?? 'Ошибка локального обновления');
|
||||||
|
} finally { setActionBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackup = (b: DeviceBackup) => {
|
||||||
|
fetch(`/api/v1/backups/${b.id}/download`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = b.filename; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBackup = async (b: DeviceBackup) => {
|
||||||
|
if (!confirm(`Удалить ${b.filename}?`)) return;
|
||||||
|
await api.delete(`/backups/${b.id}`);
|
||||||
|
await loadBackups();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Нормализация имени архитектуры (например, "x86-64" и "x86_64" — одно и то же)
|
||||||
|
const normArch = (s: string | null | undefined) =>
|
||||||
|
(s || '').toLowerCase().replace(/[-_]/g, '');
|
||||||
|
const deviceArch = normArch(d?.architecture);
|
||||||
|
const filteredFirmwares = useMemo(() => {
|
||||||
|
if (showAllFw || !deviceArch) return firmwares;
|
||||||
|
return firmwares.filter((f) => normArch(f.architecture) === deviceArch);
|
||||||
|
}, [firmwares, deviceArch, showAllFw]);
|
||||||
|
// Сбросить выбор, если текущая прошивка отфильтрована
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFw && !filteredFirmwares.some((f) => f.id === selectedFw)) {
|
||||||
|
setSelectedFw('');
|
||||||
|
}
|
||||||
|
}, [filteredFirmwares, selectedFw]);
|
||||||
|
|
||||||
|
if (!d) return <div className="text-mk-mute">Загрузка…</div>;
|
||||||
|
|
||||||
|
const memUsedPct = res?.total_memory && res?.free_memory
|
||||||
|
? Math.round(100 - (res.free_memory / res.total_memory) * 100) : null;
|
||||||
|
const chartData = metrics.map((m) => ({
|
||||||
|
...m,
|
||||||
|
t: new Date(m.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link to="/devices" className="inline-flex items-center gap-2 text-sm text-mk-mute hover:text-mk-text">
|
||||||
|
<ArrowLeft size={14} /> Назад
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="card !py-2 !px-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<StatusDot status={d.status} />
|
||||||
|
<h2 className="text-lg font-semibold leading-none">{d.identity || d.name}</h2>
|
||||||
|
<span className={`text-xs px-2 py-0.5 ${
|
||||||
|
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
|
||||||
|
}`}>{d.status.toUpperCase()}</span>
|
||||||
|
|
||||||
|
{/* Мета-блок: host, internet, модель, RouterOS, arch — одной строкой */}
|
||||||
|
<div className="text-xs text-mk-mute flex items-center gap-2 flex-wrap">
|
||||||
|
<span>{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</span>
|
||||||
|
{d.internet_ok === true && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-mk-ok"><Wifi size={11} /> ok</span>
|
||||||
|
)}
|
||||||
|
{d.internet_ok === false && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-mk-err"><WifiOff size={11} /> no internet</span>
|
||||||
|
)}
|
||||||
|
{d.abnormal_reboot && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-mk-warn"><AlertTriangle size={11} /> abnormal reboot</span>
|
||||||
|
)}
|
||||||
|
<span className="text-mk-mute/70">·</span>
|
||||||
|
<span>{d.model || '—'} · {d.ros_version || '—'}</span>
|
||||||
|
{d.architecture && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-mk-panel2 text-mk-text font-mono">
|
||||||
|
<HardDrive size={10} /> {d.architecture}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOutdated(d.ros_version, latestVer) && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-mk-warn/15 text-mk-warn font-medium">
|
||||||
|
<ArrowUpCircle size={11} /> {latestVer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки прижаты к правому краю */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap justify-end ml-auto">
|
||||||
|
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={() => setEditing(true)}>
|
||||||
|
<Pencil size={12} /> Изменить
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={safeMode} disabled={actionBusy !== null}>
|
||||||
|
<ShieldAlert size={12} className={actionBusy === 'safemode' ? 'animate-pulse' : ''} /> Safe Mode
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-1 !px-2 !text-xs" onClick={reboot} disabled={actionBusy !== null}>
|
||||||
|
<Power size={12} className={actionBusy === 'reboot' ? 'animate-pulse' : ''} /> Reboot
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary !py-1 !px-2 !text-xs" onClick={probe} disabled={busy}>
|
||||||
|
<RefreshCw size={12} className={busy ? 'animate-spin' : ''} /> {busy ? 'Опрос…' : 'Опросить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{d.last_error && (
|
||||||
|
<div className="text-xs text-mk-err mt-1.5" title={d.last_error}>
|
||||||
|
Последняя ошибка: {d.last_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="card text-mk-err text-sm">{err}</div>}
|
||||||
|
{actionMsg && <div className="card text-mk-ok text-sm whitespace-pre-wrap">{actionMsg}</div>}
|
||||||
|
|
||||||
|
<div className="flex border-b border-mk-border gap-1">
|
||||||
|
<TabBtn active={tab === 'about'} onClick={() => setTab('about')} icon={Info} label="Об устройстве" />
|
||||||
|
<TabBtn active={tab === 'overview'} onClick={() => setTab('overview')} icon={ActivityIcon} label="Обзор" />
|
||||||
|
<TabBtn active={tab === 'interfaces'} onClick={() => setTab('interfaces')} icon={Network} label="Интерфейсы" />
|
||||||
|
<TabBtn active={tab === 'ipmgmt'} onClick={() => setTab('ipmgmt')} icon={Globe} label="IP Management | DHCP" />
|
||||||
|
<TabBtn active={tab === 'backups'} onClick={() => setTab('backups')} icon={Save} label="Бэкапы" />
|
||||||
|
<TabBtn active={tab === 'firmware'} onClick={() => setTab('firmware')} icon={HardDrive} label="Прошивка" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{res && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">CPU load</div>
|
||||||
|
<div className="text-3xl font-semibold">{res.cpu_load ?? '—'}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">Memory</div>
|
||||||
|
<div className="text-3xl font-semibold">{memUsedPct ?? '—'}%</div>
|
||||||
|
<div className="text-xs text-mk-mute mt-1">
|
||||||
|
{res.free_memory != null && res.total_memory != null
|
||||||
|
? `${(res.free_memory / 1024 / 1024).toFixed(1)} / ${(res.total_memory / 1024 / 1024).toFixed(1)} MiB free`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">Uptime</div>
|
||||||
|
<div className="text-3xl font-semibold">{res.uptime ?? '—'}</div>
|
||||||
|
<div className="text-xs text-mk-mute mt-1">{res.architecture_name ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs uppercase text-mk-mute mb-2">CPU за 24ч</div>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gC" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.5} />
|
||||||
|
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
|
||||||
|
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={30} />
|
||||||
|
<YAxis stroke="#8b95a5" fontSize={10} domain={[0, 100]} unit="%" />
|
||||||
|
<Tooltip contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }} />
|
||||||
|
<Area type="monotone" dataKey="cpu_load" stroke="#22c55e" fill="url(#gC)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs uppercase text-mk-mute mb-2">Memory за 24ч</div>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gM" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.5} />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
|
||||||
|
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={30} />
|
||||||
|
<YAxis stroke="#8b95a5" fontSize={10} domain={[0, 100]} unit="%" />
|
||||||
|
<Tooltip contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }} />
|
||||||
|
<Area type="monotone" dataKey="mem_used_pct" stroke="#3b82f6" fill="url(#gM)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'backups' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="card flex items-center gap-3 flex-wrap">
|
||||||
|
<Save size={16} className="text-mk-accent2" />
|
||||||
|
<div className="text-sm">
|
||||||
|
Хранится максимум <b>10 пар</b> (binary <code>.backup</code> + text <code>.rsc</code>) с ротацией.
|
||||||
|
Доставка — через встроенный FTP контроллера (push с устройства).
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-primary !text-xs ml-auto"
|
||||||
|
onClick={makeBackup}
|
||||||
|
disabled={actionBusy !== null}
|
||||||
|
>
|
||||||
|
<Save size={14} /> {actionBusy === 'backup' ? 'Снимаем…' : 'Снять бэкап сейчас'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-mk-border">
|
||||||
|
<h3 className="text-sm font-semibold">Бэкапы конфигурации</h3>
|
||||||
|
<span className="text-[11px] text-mk-mute">{backups.length} файлов</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-1">Файл</th>
|
||||||
|
<th className="text-left px-3 py-1">Формат</th>
|
||||||
|
<th className="text-left px-3 py-1">Размер</th>
|
||||||
|
<th className="text-left px-3 py-1">Создан</th>
|
||||||
|
<th className="text-right px-3 py-1">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backups.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="px-3 py-3 text-center text-mk-mute">Нет бэкапов</td></tr>
|
||||||
|
)}
|
||||||
|
{backups.map((b) => (
|
||||||
|
<tr key={b.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
|
||||||
|
<td className="px-3 py-1 font-mono text-xs">{b.filename}</td>
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<span className={b.fmt === 'binary' ? 'badge-up' : 'badge-unk'}>{b.fmt}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1">{fmtSize(b.size)}</td>
|
||||||
|
<td className="px-3 py-1 text-mk-mute text-xs">{new Date(b.created_at).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-1 text-right whitespace-nowrap">
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => downloadBackup(b)} title="Скачать">
|
||||||
|
<Download size={12} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => deleteBackup(b)} title="Удалить">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'about' && <AboutTab device={d} resource={res} />}
|
||||||
|
|
||||||
|
{tab === 'interfaces' && <InterfacesTab device={d} onSaved={load} />}
|
||||||
|
|
||||||
|
{tab === 'firmware' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">Текущая версия</div>
|
||||||
|
<div className="text-2xl font-semibold mt-0.5">{d.ros_version ?? '—'}</div>
|
||||||
|
{latestVer && d.ros_version && isOutdated(d.ros_version, latestVer) && (
|
||||||
|
<div className="text-[11px] text-mk-warn mt-1">
|
||||||
|
доступна {latestVer} (stable)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">Архитектура</div>
|
||||||
|
<div className="text-2xl font-semibold mt-0.5 font-mono">
|
||||||
|
{d.architecture ?? <span className="text-mk-warn">неизвестна</span>}
|
||||||
|
</div>
|
||||||
|
{!d.architecture && (
|
||||||
|
<div className="text-[11px] text-mk-mute mt-1">
|
||||||
|
Нажмите «Опросить» в шапке карточки, чтобы определить.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs text-mk-mute uppercase">Stable в репозитории</div>
|
||||||
|
<div className="text-2xl font-semibold mt-0.5">{latestVer ?? '—'}</div>
|
||||||
|
<div className="text-[11px] text-mk-mute mt-1">
|
||||||
|
Всего файлов: {firmwares.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle size={16} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-base font-semibold">Обновление прошивки</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="border border-mk-border rounded p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Cloud size={14} className="text-mk-accent2" />
|
||||||
|
Из интернета (RouterOS update)
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-mk-mute">
|
||||||
|
Устройство загрузит обновление с серверов MikroTik самостоятельно. Требует исходящий доступ в интернет.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-mk-mute">Канал:</label>
|
||||||
|
<select
|
||||||
|
className="input !py-1 !text-xs !w-auto"
|
||||||
|
value={upgradeChannel}
|
||||||
|
onChange={(e) => setUpgradeChannel(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="stable">stable</option>
|
||||||
|
<option value="long-term">long-term</option>
|
||||||
|
<option value="testing">testing</option>
|
||||||
|
<option value="development">development</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-primary !text-xs"
|
||||||
|
onClick={upgradeFromInternet}
|
||||||
|
disabled={actionBusy !== null}
|
||||||
|
>
|
||||||
|
{actionBusy === 'upgrade-net' ? 'Запускается…' : 'Обновить из интернета'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-mk-border rounded p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Package size={14} className="text-mk-accent2" />
|
||||||
|
Из локального репозитория (через FTP)
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-mk-mute">
|
||||||
|
Контроллер запустит на устройстве <code>/tool fetch ftp</code>, чтобы скачать выбранный <code>.npk</code>, и отправит reboot для установки.
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-mk-mute">Платформа устройства:</span>
|
||||||
|
{d.architecture ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-mk-panel2 font-mono">{d.architecture}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-mk-warn">неизвестна — нажмите «Опросить»</span>
|
||||||
|
)}
|
||||||
|
<label className="ml-auto inline-flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAllFw}
|
||||||
|
onChange={(e) => setShowAllFw(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-mk-mute">показать все архитектуры</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="input !py-1 !text-xs"
|
||||||
|
value={selectedFw}
|
||||||
|
onChange={(e) => setSelectedFw(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">— выберите файл —</option>
|
||||||
|
{filteredFirmwares.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name} {f.version ? `(${f.version})` : ''} {f.architecture ? `· ${f.architecture}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{filteredFirmwares.length === 0 && (
|
||||||
|
<div className="text-[11px] text-mk-warn">
|
||||||
|
Нет прошивок для архитектуры <span className="font-mono">{d.architecture || '?'}</span>.
|
||||||
|
Загрузите подходящий <code>.npk</code> в разделе «Прошивки».
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-primary !text-xs"
|
||||||
|
onClick={upgradeFromLocal}
|
||||||
|
disabled={actionBusy !== null || !selectedFw}
|
||||||
|
>
|
||||||
|
{actionBusy === 'upgrade-local' ? 'Загрузка…' : 'Обновить из репозитория'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'ipmgmt' && <IpMgmtTab deviceId={Number(id)} />}
|
||||||
|
|
||||||
|
{editing && <EditDeviceModal device={d} onClose={() => setEditing(false)} onSaved={load} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBtn({
|
||||||
|
active, onClick, icon: Icon, label,
|
||||||
|
}: { active: boolean; onClick: () => void; icon: any; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-3 py-1.5 text-xs inline-flex items-center gap-1.5 border-b-2 -mb-px ${
|
||||||
|
active ? 'border-mk-accent2 text-mk-text font-medium' : 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} /> {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Физические типы RouterOS интерфейсов. Всё остальное — логические (vlan/bridge/ppp/vpn/...).
|
||||||
|
const PHYSICAL_TYPE_RE = /^(ether|wlan|wireless|sfp|qsfp)/i;
|
||||||
|
const isPhysicalIface = (it: InterfaceInfo): boolean =>
|
||||||
|
PHYSICAL_TYPE_RE.test((it.type || '').trim());
|
||||||
|
|
||||||
|
function InterfacesTab({ device, onSaved }: { device: Device; onSaved: () => void }) {
|
||||||
|
const [ifs, setIfs] = useState<InterfaceInfo[]>([]);
|
||||||
|
const [monitored, setMonitored] = useState<Set<string>>(new Set(parseList(device.monitored_interfaces)));
|
||||||
|
const [uplinks, setUplinks] = useState<Set<string>>(new Set(parseList(device.uplink_interfaces)));
|
||||||
|
const [hours, setHours] = useState<number>(device.interface_history_hours ?? 24);
|
||||||
|
const [traffic, setTraffic] = useState<InterfaceTrafficOut | null>(null);
|
||||||
|
const [uplinkStatus, setUplinkStatus] = useState<UplinkStatus[]>([]);
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false);
|
||||||
|
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||||
|
const [subTab, setSubTab] = useState<'physical' | 'ports'>('physical');
|
||||||
|
|
||||||
|
const loadIfs = () =>
|
||||||
|
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
|
||||||
|
const loadTraffic = () => {
|
||||||
|
if (monitored.size === 0) { setTraffic(null); return; }
|
||||||
|
api.get<InterfaceTrafficOut>(`/devices/${device.id}/interface-traffic`, {
|
||||||
|
params: { names: Array.from(monitored).join(','), hours },
|
||||||
|
}).then((r) => setTraffic(r.data)).catch(() => {});
|
||||||
|
};
|
||||||
|
const loadUplinkStatus = () => {
|
||||||
|
api.get<UplinkStatus[]>(`/devices/${device.id}/uplink-status`)
|
||||||
|
.then((r) => setUplinkStatus(r.data)).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadIfs(); loadTraffic(); loadUplinkStatus(); }, [device.id]);
|
||||||
|
useEffect(() => { loadTraffic(); }, [Array.from(monitored).join(','), hours]);
|
||||||
|
|
||||||
|
const toggle = (set: Set<string>, setSet: (s: Set<string>) => void, name: string) => {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (next.has(name)) next.delete(name); else next.add(name);
|
||||||
|
setSet(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaveBusy(true); setSaveMsg(null);
|
||||||
|
try {
|
||||||
|
await api.patch(`/devices/${device.id}`, {
|
||||||
|
monitored_interfaces: Array.from(monitored).join(','),
|
||||||
|
uplink_interfaces: Array.from(uplinks).join(','),
|
||||||
|
interface_history_hours: hours,
|
||||||
|
});
|
||||||
|
setSaveMsg('Сохранено. Данные начнут собираться в ближайшем цикле опроса.');
|
||||||
|
onSaved();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setSaveMsg(ex?.response?.data?.detail ?? 'Ошибка сохранения');
|
||||||
|
} finally { setSaveBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build chart data: rows = timestamps, columns = interfaces, values = rx_bps & tx_bps
|
||||||
|
const chart = useMemo(() => {
|
||||||
|
if (!traffic) return [];
|
||||||
|
const tsMap: Record<string, any> = {};
|
||||||
|
for (const [name, points] of Object.entries(traffic.series)) {
|
||||||
|
for (const p of points) {
|
||||||
|
const k = p.ts;
|
||||||
|
if (!tsMap[k]) tsMap[k] = { t: new Date(p.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), ts: k };
|
||||||
|
tsMap[k][`${name}_rx`] = p.rx_bps;
|
||||||
|
tsMap[k][`${name}_tx`] = p.tx_bps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(tsMap).sort((a: any, b: any) => a.ts.localeCompare(b.ts));
|
||||||
|
}, [traffic]);
|
||||||
|
|
||||||
|
const trafficNames = traffic ? Object.keys(traffic.series) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Network size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Конфигурация мониторинга</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-mk-mute">
|
||||||
|
Отметьте интерфейсы, нагрузку которых нужно сохранять, и uplink-интерфейсы (например <code>uztelecom</code>, <code>lte1</code>) для индикатора связи.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-mk-mute mb-1">Глубина истории, часов:</div>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={168}
|
||||||
|
className="input !py-1 !text-xs !w-32"
|
||||||
|
value={hours}
|
||||||
|
onChange={(e) => setHours(Math.max(1, Math.min(168, Number(e.target.value) || 24)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border-b border-mk-border -mx-3 px-3">
|
||||||
|
{([
|
||||||
|
{ key: 'physical' as const, label: 'Интерфейсы', count: ifs.filter(isPhysicalIface).length },
|
||||||
|
{ key: 'ports' as const, label: 'Порты', count: ifs.filter((it) => !isPhysicalIface(it)).length },
|
||||||
|
]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubTab(s.key)}
|
||||||
|
className={`px-3 py-1.5 text-xs inline-flex items-center gap-1.5 border-b-2 -mb-px ${
|
||||||
|
subTab === s.key
|
||||||
|
? 'border-mk-accent2 text-mk-text font-medium'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label} <span className="text-[10px] opacity-70">({s.count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-center px-2 py-1">Статус</th>
|
||||||
|
<th className="text-left px-2 py-1">Имя</th>
|
||||||
|
<th className="text-left px-2 py-1">Тип</th>
|
||||||
|
<th className="text-left px-2 py-1">Comment</th>
|
||||||
|
<th className="text-left px-2 py-1">MAC</th>
|
||||||
|
<th className="text-center px-2 py-1">Граф</th>
|
||||||
|
<th className="text-center px-2 py-1">Uplink</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(() => {
|
||||||
|
const filtered = ifs.filter((it) =>
|
||||||
|
subTab === 'physical' ? isPhysicalIface(it) : !isPhysicalIface(it)
|
||||||
|
);
|
||||||
|
if (ifs.length === 0) {
|
||||||
|
return (
|
||||||
|
<tr><td colSpan={7} className="px-2 py-3 text-center text-mk-mute">Нет данных. Опросите устройство.</td></tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<tr><td colSpan={7} className="px-2 py-3 text-center text-mk-mute">
|
||||||
|
{subTab === 'physical' ? 'Физических интерфейсов не найдено.' : 'Логических портов не найдено.'}
|
||||||
|
</td></tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const statusBadge = (it: InterfaceInfo) => {
|
||||||
|
if (it.disabled) return <span className="inline-flex items-center gap-1 text-mk-mute"><span>○</span> disabled</span>;
|
||||||
|
if (it.running) return <span className="inline-flex items-center gap-1 text-mk-ok"><span>●</span> running</span>;
|
||||||
|
return <span className="inline-flex items-center gap-1 text-mk-err"><span>●</span> down</span>;
|
||||||
|
};
|
||||||
|
return filtered.map((it) => (
|
||||||
|
<tr key={it.name} className="border-t border-mk-border hover:bg-mk-panel2/40">
|
||||||
|
<td className="px-2 py-1 text-center">{statusBadge(it)}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{it.name}</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute">{it.type || '—'}</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute truncate max-w-[200px]">{it.comment || ''}</td>
|
||||||
|
<td className="px-2 py-1 text-mk-mute font-mono text-[11px]">{it.mac_address || ''}</td>
|
||||||
|
<td className="px-2 py-1 text-center">
|
||||||
|
<input type="checkbox" checked={monitored.has(it.name)} onChange={() => toggle(monitored, setMonitored, it.name)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-center">
|
||||||
|
<input type="checkbox" checked={uplinks.has(it.name)} onChange={() => toggle(uplinks, setUplinks, it.name)} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button className="btn-primary !text-xs" onClick={save} disabled={saveBusy}>
|
||||||
|
<Save size={13} /> {saveBusy ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
{saveMsg && <span className="text-xs text-mk-mute">{saveMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wifi size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Состояние uplink</h3>
|
||||||
|
</div>
|
||||||
|
{uplinkStatus.length === 0 ? (
|
||||||
|
<div className="text-xs text-mk-mute">Не выбраны uplink-интерфейсы или нет данных.</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{uplinkStatus.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.name}
|
||||||
|
className={`px-3 py-2 rounded border ${
|
||||||
|
u.running
|
||||||
|
? 'border-mk-ok/40 bg-mk-ok/10 text-mk-ok'
|
||||||
|
: 'border-mk-err/40 bg-mk-err/10 text-mk-err'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{u.running ? <Wifi size={14} /> : <WifiOff size={14} />}
|
||||||
|
{u.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] opacity-70 mt-0.5">
|
||||||
|
{u.running ? 'CONNECTED' : 'DOWN'}{u.ts ? ` · ${new Date(u.ts).toLocaleTimeString()}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{traffic && trafficNames.length > 0 && (
|
||||||
|
<div className="card space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ActivityIcon size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Трафик за {hours}ч</h3>
|
||||||
|
<span className="text-[11px] text-mk-mute ml-auto">
|
||||||
|
шкала: бит/сек, отрицательные дельты после ребута пропускаются
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={chart}>
|
||||||
|
<CartesianGrid strokeDasharray="2 2" stroke="#2a2f36" />
|
||||||
|
<XAxis dataKey="t" stroke="#8b95a5" fontSize={10} minTickGap={40} />
|
||||||
|
<YAxis stroke="#8b95a5" fontSize={10} tickFormatter={fmtBps} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: '#1e242b', border: '1px solid #2a2f36', fontSize: 12 }}
|
||||||
|
formatter={(v: any) => fmtBps(Number(v))}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||||
|
{trafficNames.flatMap((name, idx) => [
|
||||||
|
<Line
|
||||||
|
key={`${name}_rx`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${name}_rx`}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
dot={false}
|
||||||
|
name={`${name} RX`}
|
||||||
|
/>,
|
||||||
|
<Line
|
||||||
|
key={`${name}_tx`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${name}_tx`}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
name={`${name} TX`}
|
||||||
|
/>,
|
||||||
|
])}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IpMgmtTab({ deviceId }: { deviceId: number }) {
|
||||||
|
const [leases, setLeases] = useState<DhcpLease[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setBusy(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<DhcpLease[]>(`/devices/${deviceId}/dhcp-leases`);
|
||||||
|
setLeases(data);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка получения leases');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [deviceId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-mk-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">DHCP Leases</h3>
|
||||||
|
<span className="text-[11px] text-mk-mute">всего: {leases.length}</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-ghost !py-1 !text-xs" onClick={load} disabled={busy}>
|
||||||
|
<RefreshCw size={13} className={busy ? 'animate-spin' : ''} /> Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{err && <div className="px-4 py-2 text-xs text-mk-err">{err}</div>}
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-1">Адрес</th>
|
||||||
|
<th className="text-left px-3 py-1">MAC</th>
|
||||||
|
<th className="text-left px-3 py-1">Hostname</th>
|
||||||
|
<th className="text-left px-3 py-1">Comment</th>
|
||||||
|
<th className="text-left px-3 py-1">Server</th>
|
||||||
|
<th className="text-left px-3 py-1">Status</th>
|
||||||
|
<th className="text-left px-3 py-1">Expires</th>
|
||||||
|
<th className="text-center px-3 py-1">Static</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{leases.length === 0 && !busy && (
|
||||||
|
<tr><td colSpan={8} className="px-3 py-3 text-center text-mk-mute">Нет lease</td></tr>
|
||||||
|
)}
|
||||||
|
{leases.map((l, i) => (
|
||||||
|
<tr key={i} className="border-t border-mk-border hover:bg-mk-panel2/40">
|
||||||
|
<td className="px-3 py-1 font-mono">{l.address}</td>
|
||||||
|
<td className="px-3 py-1 font-mono text-mk-mute">{l.mac_address}</td>
|
||||||
|
<td className="px-3 py-1">{l.host_name || '—'}</td>
|
||||||
|
<td className="px-3 py-1 text-mk-mute">{l.comment || ''}</td>
|
||||||
|
<td className="px-3 py-1 text-mk-mute">{l.server || '—'}</td>
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<span className={l.status === 'bound' ? 'text-mk-ok' : 'text-mk-mute'}>{l.status || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1 text-mk-mute text-[11px]">{l.expires_after || '—'}</td>
|
||||||
|
<td className="px-3 py-1 text-center">{l.dynamic === false ? '●' : ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AboutTab({ device, resource }: { device: Device; resource: DeviceResource | null }) {
|
||||||
|
const [ifs, setIfs] = useState<InterfaceInfo[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
|
||||||
|
const t = setInterval(() => {
|
||||||
|
api.get<InterfaceInfo[]>(`/devices/${device.id}/interfaces`).then((r) => setIfs(r.data)).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [device.id]);
|
||||||
|
|
||||||
|
const board = device.model || resource?.board_name || null;
|
||||||
|
|
||||||
|
const rows: [string, ReactNode][] = [
|
||||||
|
['Имя (identity)', device.identity || '—'],
|
||||||
|
['Модель', board || '—'],
|
||||||
|
['Архитектура', device.architecture || '—'],
|
||||||
|
['RouterOS', device.ros_version || '—'],
|
||||||
|
['Серийный', device.serial || '—'],
|
||||||
|
['Адрес', `${device.host}:${device.port}${device.use_tls ? ' (api-ssl)' : ''}`],
|
||||||
|
['Аптайм', resource?.uptime || '—'],
|
||||||
|
['Последний опрос', device.last_seen ? new Date(device.last_seen).toLocaleString() : '—'],
|
||||||
|
['Статус', device.status],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<DeviceMockup boardName={board} interfaces={ifs} />
|
||||||
|
|
||||||
|
<div className="card !py-2 !px-3 h-full flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Info size={13} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-mk-mute">Описание</h3>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-0 text-xs flex-1 content-start">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex items-baseline gap-2 leading-tight py-0.5">
|
||||||
|
<dt className="text-mk-mute min-w-[100px] shrink-0">{k}</dt>
|
||||||
|
<dd className="font-mono text-mk-text break-all">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Check, AlertCircle,
|
||||||
|
ArrowUpCircle, Wifi, WifiOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api, Device, Firmware } from '@/api/client';
|
||||||
|
import { latestStableVersion, isOutdated } from '@/utils/version';
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === 'up' ? 'bg-mk-ok' :
|
||||||
|
status === 'down' ? 'bg-mk-err' :
|
||||||
|
'bg-mk-mute' ;
|
||||||
|
return <span className={`inline-block w-2 h-2 rounded-full ${cls} flex-shrink-0`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon({ device }: { device: Device }) {
|
||||||
|
if (device.last_error || device.abnormal_reboot) {
|
||||||
|
const t = device.abnormal_reboot ? 'Аварийный reboot' : (device.last_error ?? 'ошибка');
|
||||||
|
return (
|
||||||
|
<span title={t} className="inline-flex items-center text-mk-err">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (device.status === 'up') {
|
||||||
|
return (
|
||||||
|
<span title="OK" className="inline-flex items-center text-mk-ok">
|
||||||
|
<Check size={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="inline-flex items-center text-mk-mute">·</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Devices() {
|
||||||
|
const [list, setList] = useState<Device[]>([]);
|
||||||
|
const [firmware, setFirmware] = useState<Firmware[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
api.get<Device[]>('/devices', { params: { kind: 'router' } }).then((r) => setList(r.data));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
api.get<Firmware[]>('/firmware').then((r) => setFirmware(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const latestVer = useMemo(() => latestStableVersion(firmware), [firmware]);
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!confirm('Удалить устройство?')) return;
|
||||||
|
await api.delete(`/devices/${id}`);
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end items-center">
|
||||||
|
<button className="btn-primary !py-1 !text-xs" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={13} /> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-2 py-1 w-8">#</th>
|
||||||
|
<th className="text-left px-2 py-1 w-6">✓</th>
|
||||||
|
<th className="text-left px-2 py-1 w-5"></th>
|
||||||
|
<th className="text-left px-2 py-1">Имя</th>
|
||||||
|
<th className="text-left px-2 py-1">Хост</th>
|
||||||
|
<th className="text-left px-2 py-1">Модель</th>
|
||||||
|
<th className="text-left px-2 py-1">RouterOS</th>
|
||||||
|
<th className="text-left px-2 py-1">Internet</th>
|
||||||
|
<th className="text-left px-2 py-1">Статус</th>
|
||||||
|
<th className="text-right px-2 py-1 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.length === 0 && (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-3 text-center text-mk-mute">Нет устройств</td></tr>
|
||||||
|
)}
|
||||||
|
{list.map((d, idx) => {
|
||||||
|
const outdated = isOutdated(d.ros_version, latestVer);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={d.id}
|
||||||
|
className={`border-t border-mk-border hover:bg-mk-panel2/40 ${
|
||||||
|
outdated ? 'bg-mk-warn/[0.06]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute text-xs">{idx + 1}</td>
|
||||||
|
<td className="px-2 py-0.5"><CheckIcon device={d} /></td>
|
||||||
|
<td className="px-2 py-0.5"><StatusDot status={d.status} /></td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
<Link to={`/devices/${d.id}`} className="text-mk-accent2 hover:underline">
|
||||||
|
{d.identity || d.name}
|
||||||
|
</Link>
|
||||||
|
{d.last_error && (
|
||||||
|
<div className="text-[10px] text-mk-err truncate max-w-[260px]" title={d.last_error}>
|
||||||
|
{d.last_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute">{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</td>
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute">{d.model || '—'}</td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{d.ros_version || '—'}
|
||||||
|
{outdated && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 text-mk-warn text-[10px]"
|
||||||
|
title={`Доступна: ${latestVer}`}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle size={11} /> {latestVer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
{d.internet_ok === true && <Wifi size={13} className="text-mk-ok" />}
|
||||||
|
{d.internet_ok === false && <WifiOff size={13} className="text-mk-warn" />}
|
||||||
|
{d.internet_ok === null && <span className="text-mk-mute">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 ${
|
||||||
|
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
|
||||||
|
}`}>
|
||||||
|
{d.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5 text-right">
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => remove(d.id)} title="Удалить">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && <AddDeviceModal onClose={() => setOpen(false)} onCreated={reload} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddDeviceModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '', host: '', port: 8729, use_tls: true, username: 'admin', password: '',
|
||||||
|
});
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
try {
|
||||||
|
await api.post('/devices', form);
|
||||||
|
onCreated(); onClose();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Новое устройство</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
{(['name', 'host', 'username', 'password'] as const).map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<label className="text-xs text-mk-mute">{k}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={k === 'password' ? 'password' : 'text'}
|
||||||
|
value={(form as any)[k]}
|
||||||
|
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">port</label>
|
||||||
|
<input
|
||||||
|
className="input" type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-end gap-2 text-sm pb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox" checked={form.use_tls}
|
||||||
|
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
|
||||||
|
/>
|
||||||
|
api-ssl
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn-primary" disabled={saving}>{saving ? 'Сохранение…' : 'Создать'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDeviceModal({ device, onClose, onSaved }: { device: Device; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: device.name,
|
||||||
|
host: device.host,
|
||||||
|
port: device.port,
|
||||||
|
use_tls: device.use_tls,
|
||||||
|
username: device.username,
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
const payload: Record<string, unknown> = { ...form };
|
||||||
|
if (!payload.password) delete payload.password;
|
||||||
|
try {
|
||||||
|
await api.patch(`/devices/${device.id}`, payload);
|
||||||
|
onSaved(); onClose();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Редактировать устройство</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
{(['name', 'host', 'username'] as const).map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<label className="text-xs text-mk-mute">{k}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={form[k]}
|
||||||
|
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">password (оставьте пустым — без изменений)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">port</label>
|
||||||
|
<input
|
||||||
|
className="input" type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-end gap-2 text-sm pb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox" checked={form.use_tls}
|
||||||
|
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
|
||||||
|
/>
|
||||||
|
api-ssl
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn-primary" disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Router as RouterIcon, Cpu, HardDrive } from 'lucide-react';
|
||||||
|
import Devices from './Devices';
|
||||||
|
import SwitchesPage from './Switches';
|
||||||
|
|
||||||
|
type TabKey = 'routers' | 'switches';
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
|
{ key: 'routers', label: 'Роутеры', icon: RouterIcon },
|
||||||
|
{ key: 'switches', label: 'Свичи', icon: Cpu },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseHash(h: string): TabKey {
|
||||||
|
const v = h.replace(/^#/, '');
|
||||||
|
return v === 'switches' ? 'switches' : 'routers';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesIndex() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
|
||||||
|
|
||||||
|
useEffect(() => { setTab(parseHash(location.hash)); }, [location.hash]);
|
||||||
|
|
||||||
|
const switchTab = (k: TabKey) => {
|
||||||
|
setTab(k);
|
||||||
|
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive size={16} className="text-mk-accent2" />
|
||||||
|
<h2 className="text-base font-semibold">Устройства</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border-b border-mk-border">
|
||||||
|
{TABS.map((tb) => {
|
||||||
|
const Icon = tb.icon;
|
||||||
|
const active = tb.key === tab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tb.key}
|
||||||
|
onClick={() => switchTab(tb.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-mk-accent text-mk-text'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{tb.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{tab === 'routers' && <Devices />}
|
||||||
|
{tab === 'switches' && <SwitchesPage />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { Download, HardDrive, Plus, Trash2, RefreshCw, Layers, CheckCircle2, AlertTriangle, Upload } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
api, Firmware, FirmwareBulkOut, FirmwareChannelsOut,
|
||||||
|
} from '@/api/client';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
|
||||||
|
function fmtSize(b: number): string {
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(b / 1024 / 1024).toFixed(2)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDt(s?: string): string {
|
||||||
|
if (!s) return '—';
|
||||||
|
try { return new Date(s).toLocaleString(); } catch { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelsWidget({ data, onRefresh, refreshing }: {
|
||||||
|
data: FirmwareChannelsOut | null; onRefresh: () => void; refreshing: boolean;
|
||||||
|
}) {
|
||||||
|
if (!data) return null;
|
||||||
|
const order = data.available_channels;
|
||||||
|
return (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Каналы RouterOS</h3>
|
||||||
|
<button className="ml-auto btn-ghost !py-1 !text-xs" onClick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} /> Проверить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{order.map((ch) => {
|
||||||
|
const info = data.channels[ch];
|
||||||
|
const ok = info?.last_check_ok !== false && info?.version;
|
||||||
|
return (
|
||||||
|
<div key={ch} className="border border-mk-border rounded-md p-3 bg-mk-panel2/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 size={14} className="text-mk-ok" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle size={14} className="text-mk-warn" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm">{ch}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold mt-1">{info?.version || '—'}</div>
|
||||||
|
<div className="text-[11px] text-mk-mute mt-1">
|
||||||
|
Выпущена: {fmtDt(info?.released_at)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-mk-mute">
|
||||||
|
Проверено: {fmtDt(info?.last_check)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirmwarePage({ embedded = false }: { embedded?: boolean } = {}) {
|
||||||
|
const [list, setList] = useState<Firmware[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [channels, setChannels] = useState<FirmwareChannelsOut | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const token = useAuth((s) => s.accessToken);
|
||||||
|
|
||||||
|
const reload = () => api.get<Firmware[]>('/firmware').then((r) => setList(r.data));
|
||||||
|
const reloadChannels = () => api.get<FirmwareChannelsOut>('/firmware/channels')
|
||||||
|
.then((r) => setChannels(r.data)).catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => { reload(); reloadChannels(); }, []);
|
||||||
|
|
||||||
|
const checkUpdates = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
await api.post('/firmware/check');
|
||||||
|
await reloadChannels();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setChecking(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!confirm('Удалить прошивку из репозитория?')) return;
|
||||||
|
await api.delete(`/firmware/${id}`);
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = (f: Firmware) => {
|
||||||
|
fetch(`/api/v1/firmware/${f.id}/download`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = f.name; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap justify-between items-center gap-2">
|
||||||
|
{!embedded && <h2 className="text-lg font-semibold">Внутренний репозиторий прошивок</h2>}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<button className="btn-ghost" onClick={() => setUploadOpen(true)}>
|
||||||
|
<Upload size={16} /> Загрузить файл
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost" onClick={() => setBulkOpen(true)}>
|
||||||
|
<Layers size={16} /> Загрузить по архитектурам
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={16} /> Загрузить с URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChannelsWidget data={channels} onRefresh={checkUpdates} refreshing={checking} />
|
||||||
|
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-1 w-8">#</th>
|
||||||
|
<th className="text-left px-3 py-1">Файл</th>
|
||||||
|
<th className="text-left px-3 py-1">Версия</th>
|
||||||
|
<th className="text-left px-3 py-1">Архитектура</th>
|
||||||
|
<th className="text-left px-3 py-1">Канал</th>
|
||||||
|
<th className="text-left px-3 py-1">Размер</th>
|
||||||
|
<th className="text-left px-3 py-1">Загружено</th>
|
||||||
|
<th className="text-right px-3 py-1">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.length === 0 && (
|
||||||
|
<tr><td colSpan={8} className="px-4 py-6 text-center text-mk-mute">
|
||||||
|
Нет прошивок. Загрузите по URL или массово по архитектурам.
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
{list.map((f, idx) => (
|
||||||
|
<tr key={f.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
|
||||||
|
<td className="px-3 py-1 text-mk-mute text-xs">{idx + 1}</td>
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive size={13} className="text-mk-mute" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1">{f.version || '—'}</td>
|
||||||
|
<td className="px-3 py-1">{f.architecture || '—'}</td>
|
||||||
|
<td className="px-3 py-1">{f.channel || '—'}</td>
|
||||||
|
<td className="px-3 py-1">{fmtSize(f.size)}</td>
|
||||||
|
<td className="px-3 py-1 text-mk-mute text-xs">
|
||||||
|
{new Date(f.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1 text-right whitespace-nowrap">
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => download(f)} title="Скачать">
|
||||||
|
<Download size={12} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(f.id)} title="Удалить">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && <ImportFirmwareModal onClose={() => setOpen(false)} onCreated={reload} />}
|
||||||
|
{uploadOpen && (
|
||||||
|
<UploadFirmwareModal
|
||||||
|
arches={channels?.architectures || []}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onDone={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkOpen && (
|
||||||
|
<BulkImportModal
|
||||||
|
arches={channels?.architectures || []}
|
||||||
|
channels={channels}
|
||||||
|
onClose={() => setBulkOpen(false)}
|
||||||
|
onDone={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadFirmwareModal({ arches, onClose, onDone }: {
|
||||||
|
arches: string[]; onClose: () => void; onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [architecture, setArchitecture] = useState('');
|
||||||
|
const [channel, setChannel] = useState('stable');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Авто-разбор имени файла routeros-<ver>-<arch>.npk
|
||||||
|
const onPick = (f: File | null) => {
|
||||||
|
setFile(f);
|
||||||
|
if (!f) return;
|
||||||
|
const m = f.name.toLowerCase().match(/^routeros-([\d.]+[a-z0-9.\-]*)-([a-z0-9_]+)\.npk$/);
|
||||||
|
if (m) {
|
||||||
|
if (!version) setVersion(m[1]);
|
||||||
|
if (!architecture) setArchitecture(m[2]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) { setErr('Выберите файл'); return; }
|
||||||
|
setBusy(true); setErr(null); setMsg(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (version) fd.append('version', version);
|
||||||
|
if (architecture) fd.append('architecture', architecture);
|
||||||
|
if (channel) fd.append('channel', channel);
|
||||||
|
try {
|
||||||
|
const r = await api.post<Firmware>('/firmware/upload', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 300000,
|
||||||
|
});
|
||||||
|
setMsg(`Загружено: ${r.data.name}` + (r.data.version ? ` (${r.data.version})` : ''));
|
||||||
|
onDone();
|
||||||
|
setTimeout(onClose, 800);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? String(ex?.message ?? 'Ошибка'));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Загрузить прошивку с диска</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Файл .npk</label>
|
||||||
|
<input
|
||||||
|
className="input" type="file" accept=".npk,application/octet-stream" required
|
||||||
|
onChange={(e) => onPick(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{file && (
|
||||||
|
<div className="text-[11px] text-mk-mute mt-1">
|
||||||
|
{file.name} · {fmtSize(file.size)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Версия (необязательно)</label>
|
||||||
|
<input className="input" type="text" placeholder="например 7.16.1"
|
||||||
|
value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Архитектура (необязательно)</label>
|
||||||
|
<input
|
||||||
|
className="input" type="text" placeholder="например arm64"
|
||||||
|
list="arch-list"
|
||||||
|
value={architecture} onChange={(e) => setArchitecture(e.target.value)}
|
||||||
|
/>
|
||||||
|
<datalist id="arch-list">
|
||||||
|
{arches.map((a) => <option key={a} value={a} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Канал</label>
|
||||||
|
<select className="input" value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||||
|
<option value="stable">stable</option>
|
||||||
|
<option value="long-term">long-term</option>
|
||||||
|
<option value="testing">testing</option>
|
||||||
|
<option value="development">development</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-mute">
|
||||||
|
Лимит: 200 MiB. Дубликаты определяются по sha256 и (версия+архитектура) — повторно не сохраняются.
|
||||||
|
</p>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
{msg && <div className="text-sm text-mk-ok">{msg}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Закрыть</button>
|
||||||
|
<button className="btn-primary" disabled={busy || !file}>
|
||||||
|
{busy ? 'Загрузка…' : 'Загрузить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportFirmwareModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
url: '', name: '', version: '', architecture: '', channel: 'stable',
|
||||||
|
});
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
const payload: Record<string, unknown> = { url: form.url };
|
||||||
|
if (form.name) payload.name = form.name;
|
||||||
|
if (form.version) payload.version = form.version;
|
||||||
|
if (form.architecture) payload.architecture = form.architecture;
|
||||||
|
if (form.channel) payload.channel = form.channel;
|
||||||
|
try {
|
||||||
|
await api.post('/firmware/import', payload);
|
||||||
|
onCreated(); onClose();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Загрузить прошивку с URL</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">URL .npk</label>
|
||||||
|
<input
|
||||||
|
className="input" type="url" required
|
||||||
|
placeholder="https://download.mikrotik.com/routeros/7.16.1/routeros-7.16.1-arm64.npk"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(['name', 'version', 'architecture'] as const).map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<label className="text-xs text-mk-mute">{k} (необязательно)</label>
|
||||||
|
<input
|
||||||
|
className="input" type="text"
|
||||||
|
value={form[k]}
|
||||||
|
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">channel</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.channel}
|
||||||
|
onChange={(e) => setForm({ ...form, channel: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="stable">stable</option>
|
||||||
|
<option value="long-term">long-term</option>
|
||||||
|
<option value="testing">testing</option>
|
||||||
|
<option value="development">development</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Загрузка…' : 'Загрузить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkImportModal({ arches, channels, onClose, onDone }: {
|
||||||
|
arches: string[]; channels: FirmwareChannelsOut | null; onClose: () => void; onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const available = channels?.available_channels || ['stable'];
|
||||||
|
const state = channels?.channels || {};
|
||||||
|
const [channel, setChannel] = useState(available[0]);
|
||||||
|
// Версия подставляется из обновления канала, но пользователь может перебить.
|
||||||
|
const channelVersion = state[channel]?.version || '';
|
||||||
|
const [version, setVersion] = useState(channelVersion);
|
||||||
|
const [overridden, setOverridden] = useState(false);
|
||||||
|
// При смене канала — подставить версию (если юзер её не правил вручную).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!overridden) setVersion(channelVersion);
|
||||||
|
}, [channelVersion, overridden]);
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set(['arm64', 'mipsbe', 'mmips']));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [result, setResult] = useState<FirmwareBulkOut | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggle = (a: string) => {
|
||||||
|
const n = new Set(picked);
|
||||||
|
n.has(a) ? n.delete(a) : n.add(a);
|
||||||
|
setPicked(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!version || picked.size === 0) return;
|
||||||
|
setBusy(true); setErr(null); setResult(null);
|
||||||
|
try {
|
||||||
|
const r = await api.post<FirmwareBulkOut>('/firmware/import-bulk', {
|
||||||
|
version, channel, architectures: Array.from(picked),
|
||||||
|
});
|
||||||
|
setResult(r.data);
|
||||||
|
onDone();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-xl max-h-[90vh] overflow-auto">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Массовая загрузка по архитектурам</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Канал</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={channel}
|
||||||
|
onChange={(e) => { setChannel(e.target.value); setOverridden(false); }}
|
||||||
|
>
|
||||||
|
{available.map((c) => {
|
||||||
|
const v = state[c]?.version;
|
||||||
|
return <option key={c} value={c}>{c}{v ? ` — ${v}` : ''}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Версия RouterOS {channelVersion && !overridden && <span className="text-mk-mute">(из канала)</span>}</label>
|
||||||
|
<input
|
||||||
|
className="input" required placeholder="7.16.1"
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => { setVersion(e.target.value); setOverridden(true); }}
|
||||||
|
/>
|
||||||
|
{!channelVersion && (
|
||||||
|
<p className="text-[11px] text-mk-warn mt-1">
|
||||||
|
Нет данных о версии канала — запустите «Проверить обновления».
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Архитектуры ({picked.size})</label>
|
||||||
|
<div className="grid grid-cols-3 md:grid-cols-4 gap-1 mt-1">
|
||||||
|
{arches.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-1.5 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
|
||||||
|
<input type="checkbox" checked={picked.has(a)} onChange={() => toggle(a)} />
|
||||||
|
{a}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-mute">
|
||||||
|
URL формируется как <code>https://download.mikrotik.com/routeros/<version>/routeros-<version>-<arch>.npk</code>.
|
||||||
|
</p>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
{result && (
|
||||||
|
<div className="card !p-2 text-xs space-y-1">
|
||||||
|
{result.results.map((r) => (
|
||||||
|
<div key={r.architecture} className="flex items-center gap-2">
|
||||||
|
{r.ok
|
||||||
|
? <CheckCircle2 size={12} className="text-mk-ok" />
|
||||||
|
: <AlertTriangle size={12} className="text-mk-err" />}
|
||||||
|
<span className="font-mono">{r.architecture}</span>
|
||||||
|
{r.ok && r.skipped && (
|
||||||
|
<span className="text-mk-mute">уже в репозитории — пропущено</span>
|
||||||
|
)}
|
||||||
|
{!r.ok && <span className="text-mk-mute truncate">{r.error}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Закрыть</button>
|
||||||
|
<button className="btn-primary" disabled={busy || !version || picked.size === 0}>
|
||||||
|
{busy ? 'Загрузка…' : `Загрузить ${picked.size}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Wifi } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setTokens = useAuth((s) => s.setTokens);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
|
setTokens(data.access_token, data.refresh_token, email);
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка входа');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center bg-mk-bg p-6">
|
||||||
|
<div className="w-full max-w-sm card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Wifi className="text-mk-accent2" size={28} />
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">ROSzetta</div>
|
||||||
|
<div className="text-xs text-mk-mute">Вход в панель управления</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-mk-mute mb-1">Логин</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-mk-mute mb-1">Пароль</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
|
||||||
|
<button className="btn-primary w-full" disabled={loading}>
|
||||||
|
{loading ? 'Входим…' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Bell, Inbox, Send, Sliders } from 'lucide-react';
|
||||||
|
import AlertsPage from './Alerts';
|
||||||
|
import TelegramBotPage from './TelegramBot';
|
||||||
|
import NotifySettingsPage from './NotifySettings';
|
||||||
|
|
||||||
|
type TabKey = 'alerts' | 'telegram' | 'settings';
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
|
{ key: 'alerts', label: 'Алерты', icon: Bell },
|
||||||
|
{ key: 'telegram', label: 'Telegram-бот', icon: Send },
|
||||||
|
{ key: 'settings', label: 'Настройки', icon: Sliders },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseHash(h: string): TabKey {
|
||||||
|
const v = h.replace(/^#/, '');
|
||||||
|
return (v === 'alerts' || v === 'telegram' || v === 'settings') ? v : 'alerts';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationCenter() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTab(parseHash(location.hash));
|
||||||
|
}, [location.hash]);
|
||||||
|
|
||||||
|
const switchTab = (k: TabKey) => {
|
||||||
|
setTab(k);
|
||||||
|
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Inbox size={16} className="text-mk-accent2" />
|
||||||
|
<h2 className="text-base font-semibold">Центр уведомлений</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border-b border-mk-border">
|
||||||
|
{TABS.map((tb) => {
|
||||||
|
const Icon = tb.icon;
|
||||||
|
const active = tb.key === tab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tb.key}
|
||||||
|
onClick={() => switchTab(tb.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-mk-accent text-mk-text'
|
||||||
|
: 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{tb.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{tab === 'alerts' && <AlertsPage />}
|
||||||
|
{tab === 'telegram' && <TelegramBotPage />}
|
||||||
|
{tab === 'settings' && <NotifySettingsPage />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { BellOff, Save } from 'lucide-react';
|
||||||
|
import { AppSettings } from '@/api/client';
|
||||||
|
import { useSettings } from '@/store/settings';
|
||||||
|
|
||||||
|
type NotifyBoolKey = Exclude<keyof AppSettings['notify'], 'style'>;
|
||||||
|
|
||||||
|
const NOTIFY_LABELS: Record<NotifyBoolKey, string> = {
|
||||||
|
device_status: 'Изменение статуса устройства (up/down)',
|
||||||
|
internet: 'Отсутствие интернета на устройстве',
|
||||||
|
abnormal_reboot: 'Аномальная перезагрузка устройства',
|
||||||
|
firmware: 'Появление новой версии RouterOS',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotifySettingsPage() {
|
||||||
|
const { settings, load, patch } = useSettings();
|
||||||
|
const [draft, setDraft] = useState<AppSettings['notify'] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
useEffect(() => { if (settings) setDraft({ ...settings.notify }); }, [settings]);
|
||||||
|
|
||||||
|
if (!draft) return <div className="text-mk-mute">Загрузка…</div>;
|
||||||
|
|
||||||
|
const upd = (k: NotifyBoolKey, v: boolean) => setDraft({ ...draft, [k]: v });
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true); setMsg(null);
|
||||||
|
try {
|
||||||
|
await patch({ notify: draft });
|
||||||
|
setMsg('Сохранено');
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BellOff size={14} className="text-mk-warn" />
|
||||||
|
<h3 className="text-sm font-semibold">Уведомления о проблемах</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">
|
||||||
|
Отключите категории, которые не должны генерировать алерты и попадать в global health.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(Object.keys(NOTIFY_LABELS) as Array<NotifyBoolKey>).map((k) => (
|
||||||
|
<label key={k} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft[k]}
|
||||||
|
onChange={(e) => upd(k, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{NOTIFY_LABELS[k]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-mk-border">
|
||||||
|
<div className="text-xs text-mk-mute mb-1.5">Стиль сообщения при полном благополучии:</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(['jokes', 'serious'] as const).map((s) => (
|
||||||
|
<label key={s} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="notify-style"
|
||||||
|
checked={draft.style === s}
|
||||||
|
onChange={() => setDraft({ ...draft, style: s })}
|
||||||
|
/>
|
||||||
|
<span>{s === 'jokes' ? 'С шутками' : 'Строго'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="btn-primary !py-1 !text-xs" onClick={save} disabled={busy}>
|
||||||
|
<Save size={13} /> {busy ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-xs text-mk-mute">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
|
||||||
|
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api, AppSettings } from '@/api/client';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { useSettings } from '@/store/settings';
|
||||||
|
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
|
||||||
|
|
||||||
|
const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
devices: 'Devices',
|
||||||
|
switches: 'Свичи',
|
||||||
|
firmware: 'Прошивки',
|
||||||
|
notif_center: 'Центр уведомлений',
|
||||||
|
cli: 'Автоматизация (CLI)',
|
||||||
|
settings: 'Настройки',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
|
||||||
|
|
||||||
|
function parseHash(h: string): TabKey {
|
||||||
|
const v = h.replace(/^#/, '');
|
||||||
|
if (v === 'users' || v === 'password' || v === 'user') return 'user';
|
||||||
|
if (v === 'menu') return 'menu';
|
||||||
|
if (v === 'backup') return 'backup';
|
||||||
|
if (v === 'probe') return 'probe';
|
||||||
|
// 'config' и любые другие → general
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
|
{ key: 'general', label: 'Общие', icon: SettingsIcon },
|
||||||
|
{ key: 'probe', label: 'Опрос', icon: Radar },
|
||||||
|
{ key: 'user', label: 'Пользователь', icon: UserIcon },
|
||||||
|
{ key: 'menu', label: 'Меню', icon: Eye },
|
||||||
|
{ key: 'backup', label: 'Бэкап', icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const token = useAuth((s) => s.accessToken);
|
||||||
|
const email = useAuth((s) => s.email);
|
||||||
|
const { settings, load, patch } = useSettings();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<AppSettings | null>(null);
|
||||||
|
const restoreInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const t = useT();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<TabKey>(() => parseHash(location.hash));
|
||||||
|
|
||||||
|
useEffect(() => { setTab(parseHash(location.hash)); }, [location.hash]);
|
||||||
|
|
||||||
|
const switchTab = (k: TabKey) => {
|
||||||
|
setTab(k);
|
||||||
|
navigate({ pathname: location.pathname, hash: `#${k}` }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
useEffect(() => { if (settings) setDraft(structuredClone(settings)); }, [settings]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setBusy('save'); setMsg(null);
|
||||||
|
try { await patch(draft); setMsg('Настройки сохранены'); }
|
||||||
|
catch (ex: any) { setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`); }
|
||||||
|
finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackup = async (kind: 'config' | 'full') => {
|
||||||
|
setBusy(kind); setMsg(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/controller/backup/${kind}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const cd = resp.headers.get('content-disposition') || '';
|
||||||
|
const m = /filename="([^"]+)"/.exec(cd);
|
||||||
|
const name = m ? m[1] : `controller-${kind}.tar.gz`;
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = name; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка: ${ex.message ?? ex}`);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkFirmware = async () => {
|
||||||
|
setBusy('check'); setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await api.post<{ latest_version: string; released_at: string }>('/firmware/check');
|
||||||
|
setMsg(`Последняя стабильная RouterOS: ${r.data.latest_version} (${new Date(r.data.released_at).toLocaleDateString()})`);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreBackup = async (file: File) => {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Развернуть бэкап «${file.name}»?\n\nВНИМАНИЕ: текущая БД будет полностью заменена. Продолжить?`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setBusy('restore'); setMsg(null);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const resp = await fetch('/api/v1/controller/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data?.detail || resp.statusText);
|
||||||
|
setMsg(data?.message || 'Бэкап развёрнут. Рекомендуется перезайти в систему.');
|
||||||
|
load();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка восстановления: ${ex?.message ?? ex}`);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!draft) return <div className="text-mk-mute">Загрузка настроек…</div>;
|
||||||
|
|
||||||
|
const updMenu = (k: keyof AppSettings['menu'], v: boolean) =>
|
||||||
|
setDraft({ ...draft, menu: { ...draft.menu, [k]: v } });
|
||||||
|
const ui = draft.ui ?? { instance_name: 'ROSzetta', locale: 'ru', theme: 'mk-dark', heartbeat_hours: 6, probe_interval_minutes: 5 };
|
||||||
|
const updUi = (k: keyof AppSettings['ui'], v: any) =>
|
||||||
|
setDraft({ ...draft, ui: { ...ui, [k]: v } });
|
||||||
|
|
||||||
|
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
|
||||||
|
const showSaveBtn = tab !== 'user';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
<h2 className="text-base font-semibold">{t('settings.title')}</h2>
|
||||||
|
{showSaveBtn && (
|
||||||
|
<button className="ml-auto btn-primary !py-1 !text-xs" onClick={save} disabled={busy === 'save'}>
|
||||||
|
<Save size={13} /> {t('common.save')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border-b border-mk-border overflow-x-auto">
|
||||||
|
{TABS.map((tb) => {
|
||||||
|
const Icon = tb.icon;
|
||||||
|
const active = tb.key === tab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tb.key}
|
||||||
|
onClick={() => switchTab(tb.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm border-b-2 -mb-px transition-colors whitespace-nowrap ${
|
||||||
|
active ? 'border-mk-accent text-mk-text' : 'border-transparent text-mk-mute hover:text-mk-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{tb.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'general' && (
|
||||||
|
<>
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.identity')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">{t('settings.identity.hint')}</p>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">{t('settings.instanceName')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
maxLength={64}
|
||||||
|
value={ui.instance_name}
|
||||||
|
onChange={(e) => updUi('instance_name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.locale')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{LOCALES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updUi('locale', l.code)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
|
||||||
|
ui.locale === l.code
|
||||||
|
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
|
||||||
|
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.theme')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{THEMES.map((th) => {
|
||||||
|
const active = ui.theme === th.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={th.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updUi('theme', th.id)}
|
||||||
|
className={`group flex items-center gap-3 p-2 rounded-md border text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-mk-accent2 bg-mk-accent/10'
|
||||||
|
: 'border-mk-border hover:bg-mk-panel2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-12 rounded overflow-hidden border border-mk-border shrink-0">
|
||||||
|
<span className="flex-1" style={{ background: th.swatch[0] }} />
|
||||||
|
<span className="flex-1" style={{ background: th.swatch[1] }} />
|
||||||
|
<span className="flex-1" style={{ background: th.swatch[2] }} />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">{th.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-mute">Тема применяется мгновенно после сохранения.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'probe' && (
|
||||||
|
<>
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radar size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.probe')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">{t('settings.probe.hint')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PROBE_INTERVALS.map((p) => {
|
||||||
|
const active = Number(ui.probe_interval_minutes) === p.minutes;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.minutes}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updUi('probe_interval_minutes', p.minutes)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
|
||||||
|
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.heartbeat')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">{t('settings.heartbeat.hint')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{HEARTBEAT_RANGES.map((r) => {
|
||||||
|
const active = Number(ui.heartbeat_hours) === r.hours;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.hours}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updUi('heartbeat_hours', r.hours)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-mk-accent/15 border-mk-accent2 text-mk-text'
|
||||||
|
: 'border-mk-border text-mk-mute hover:bg-mk-panel2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'user' && <UserTab email={email} />}
|
||||||
|
|
||||||
|
{tab === 'menu' && (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.menu')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">Скрыть ненужные пункты бокового меню.</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{(Object.keys(MENU_LABELS) as Array<keyof AppSettings['menu']>).map((k) => (
|
||||||
|
<label key={k} className="flex items-center gap-2 text-sm hover:bg-mk-panel2 px-2 py-1 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.menu[k]}
|
||||||
|
onChange={(e) => updMenu(k, e.target.checked)}
|
||||||
|
disabled={k === 'settings'}
|
||||||
|
/>
|
||||||
|
<span className={k === 'settings' ? 'text-mk-mute' : ''}>{MENU_LABELS[k]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-mute">Пункт «Настройки» нельзя скрыть.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'backup' && (
|
||||||
|
<>
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Бэкап контроллера</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">
|
||||||
|
<b>Полный</b> — дамп БД + настройки окружения. <b>Только конфиг</b> — без БД.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className="btn-primary !py-1 !text-xs" disabled={busy !== null} onClick={() => downloadBackup('full')}>
|
||||||
|
<Download size={13} /> Полный (БД + конфиг)
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-1 !text-xs" disabled={busy !== null} onClick={() => downloadBackup('config')}>
|
||||||
|
<Download size={13} /> Только конфиг
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-mk-border pt-3 mt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Upload size={13} className="text-mk-warn" />
|
||||||
|
<span className="text-sm font-semibold">Развернуть бэкап</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-warn flex items-start gap-1">
|
||||||
|
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
|
||||||
|
<span>Деструктивная операция: текущая БД будет полностью заменена.</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={restoreInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".tar.gz,.tgz,application/gzip"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) restoreBackup(f);
|
||||||
|
if (e.target) e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-ghost !py-1 !text-xs mt-2 border-mk-warn/50 text-mk-warn hover:bg-mk-warn/10"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => restoreInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload size={13} /> {busy === 'restore' ? 'Развёртывание…' : 'Выбрать файл бэкапа…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">Прошивки</h3>
|
||||||
|
<p className="text-xs text-mk-mute">Автопроверка раз в сутки. Можно запустить вручную.</p>
|
||||||
|
<button className="btn-ghost !py-1 !text-xs" disabled={busy !== null} onClick={checkFirmware}>
|
||||||
|
<RefreshCw size={13} className={busy === 'check' ? 'animate-spin' : ''} /> Проверить сейчас
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <div className="card text-sm">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Вкладка «Пользователь» ----------
|
||||||
|
|
||||||
|
function UserTab({ email }: { email: string | null }) {
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
if (next.length < 4) { setMsg({ kind: 'err', text: 'Новый пароль слишком короткий (мин. 4 символа)' }); return; }
|
||||||
|
if (next !== confirm) { setMsg({ kind: 'err', text: 'Пароли не совпадают' }); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/change-password', { current, new: next });
|
||||||
|
setMsg({ kind: 'ok', text: 'Пароль изменён' });
|
||||||
|
setCurrent(''); setNext(''); setConfirm('');
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка смены пароля' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="card space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Текущий пользователь</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-mk-mute">Логин:</span> <b>{email ?? '—'}</b>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-mk-mute">
|
||||||
|
Управление списком пользователей пока недоступно. Поддерживается только смена пароля
|
||||||
|
текущего пользователя.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<KeyRound size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Смена пароля</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Текущий пароль</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Новый пароль</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Повторите новый пароль</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary !text-xs" disabled={busy}>
|
||||||
|
{busy ? 'Меняем…' : 'Сменить пароль'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, Trash2, Pencil, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { api, Device } from '@/api/client';
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === 'up' ? 'bg-mk-ok' :
|
||||||
|
status === 'down' ? 'bg-mk-err' :
|
||||||
|
'bg-mk-mute' ;
|
||||||
|
return <span className={`inline-block w-2 h-2 rounded-full ${cls} flex-shrink-0`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SwitchesPage() {
|
||||||
|
const [list, setList] = useState<Device[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null);
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
api.get<Device[]>('/devices', { params: { kind: 'switch' } }).then((r) => setList(r.data));
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, []);
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!confirm('Удалить свич?')) return;
|
||||||
|
await api.delete(`/devices/${id}`);
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end items-center">
|
||||||
|
<button className="btn-primary !py-1 !text-xs" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={13} /> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="bg-mk-panel2 text-mk-mute text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-2 py-1 w-8">#</th>
|
||||||
|
<th className="text-left px-2 py-1 w-5"></th>
|
||||||
|
<th className="text-left px-2 py-1">Имя</th>
|
||||||
|
<th className="text-left px-2 py-1">Хост</th>
|
||||||
|
<th className="text-left px-2 py-1">Модель</th>
|
||||||
|
<th className="text-left px-2 py-1">RouterOS</th>
|
||||||
|
<th className="text-left px-2 py-1">Internet</th>
|
||||||
|
<th className="text-left px-2 py-1">Статус</th>
|
||||||
|
<th className="text-right px-2 py-1 w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.length === 0 && (
|
||||||
|
<tr><td colSpan={9} className="px-3 py-3 text-center text-mk-mute">Нет свичей</td></tr>
|
||||||
|
)}
|
||||||
|
{list.map((d, idx) => (
|
||||||
|
<tr key={d.id} className="border-t border-mk-border hover:bg-mk-panel2/40">
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute text-xs">{idx + 1}</td>
|
||||||
|
<td className="px-2 py-0.5"><StatusDot status={d.status} /></td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
<Link to={`/devices/${d.id}`} className="text-mk-accent2 hover:underline">
|
||||||
|
{d.identity || d.name}
|
||||||
|
</Link>
|
||||||
|
{d.last_error && (
|
||||||
|
<div className="text-[10px] text-mk-err truncate max-w-[260px]" title={d.last_error}>
|
||||||
|
{d.last_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute">{d.host}:{d.port}{d.use_tls ? ' (TLS)' : ''}</td>
|
||||||
|
<td className="px-2 py-0.5 text-mk-mute">{d.model || '—'}</td>
|
||||||
|
<td className="px-2 py-0.5">{d.ros_version || '—'}</td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
{d.internet_ok === true && <Wifi size={13} className="text-mk-ok" />}
|
||||||
|
{d.internet_ok === false && <WifiOff size={13} className="text-mk-warn" />}
|
||||||
|
{d.internet_ok === null && <span className="text-mk-mute">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 ${
|
||||||
|
d.status === 'up' ? 'badge-up' : d.status === 'down' ? 'badge-down' : 'badge-unk'
|
||||||
|
}`}>
|
||||||
|
{d.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-0.5 text-right">
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5" onClick={() => setEditing(d)} title="Редактировать">
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost !py-0.5 !px-1.5 ml-1" onClick={() => remove(d.id)} title="Удалить">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && <SwitchModal onClose={() => setOpen(false)} onSaved={reload} />}
|
||||||
|
{editing && (
|
||||||
|
<SwitchModal
|
||||||
|
device={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => { setEditing(null); reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchModal({
|
||||||
|
device, onClose, onSaved,
|
||||||
|
}: {
|
||||||
|
device?: Device;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const isEdit = !!device;
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: device?.name ?? '',
|
||||||
|
host: device?.host ?? '',
|
||||||
|
port: device?.port ?? 8729,
|
||||||
|
use_tls: device?.use_tls ?? true,
|
||||||
|
username: device?.username ?? 'admin',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
const payload: Record<string, unknown> = { ...form };
|
||||||
|
if (!payload.password) delete payload.password;
|
||||||
|
await api.patch(`/devices/${device!.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/devices', { ...form, kind: 'switch' });
|
||||||
|
}
|
||||||
|
onSaved(); onClose();
|
||||||
|
} catch (ex: any) {
|
||||||
|
setErr(ex?.response?.data?.detail ?? 'Ошибка');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<h3 className="text-base font-semibold mb-4">
|
||||||
|
{isEdit ? 'Редактировать свич' : 'Новый свич'}
|
||||||
|
</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
{(['name', 'host', 'username'] as const).map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<label className="text-xs text-mk-mute">{k}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={(form as any)[k]}
|
||||||
|
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">
|
||||||
|
password{isEdit ? ' (оставьте пустым — без изменений)' : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
required={!isEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">port</label>
|
||||||
|
<input
|
||||||
|
className="input" type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-end gap-2 text-sm pb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox" checked={form.use_tls}
|
||||||
|
onChange={(e) => setForm({ ...form, use_tls: e.target.checked })}
|
||||||
|
/>
|
||||||
|
api-ssl
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-sm text-mk-err">{err}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : isEdit ? 'Сохранить' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Send, Save } from 'lucide-react';
|
||||||
|
import { api, AppSettings } from '@/api/client';
|
||||||
|
import { useSettings } from '@/store/settings';
|
||||||
|
|
||||||
|
export default function TelegramBotPage() {
|
||||||
|
const { settings, load, patch } = useSettings();
|
||||||
|
const [draft, setDraft] = useState<AppSettings['telegram'] | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) setDraft({ ...settings.telegram });
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
if (!draft) return <div className="text-mk-mute">Загрузка…</div>;
|
||||||
|
|
||||||
|
const upd = (k: keyof AppSettings['telegram'], v: any) =>
|
||||||
|
setDraft({ ...draft, [k]: v });
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy('save'); setMsg(null);
|
||||||
|
try {
|
||||||
|
await patch({ telegram: draft });
|
||||||
|
setMsg('Настройки Telegram сохранены');
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setBusy('tg'); setMsg(null);
|
||||||
|
try {
|
||||||
|
await patch({ telegram: draft });
|
||||||
|
const r = await api.post<{ ok: boolean; message: string }>('/settings/telegram/test');
|
||||||
|
setMsg(r.data.ok ? 'Тестовое сообщение отправлено ✓' : `Ошибка TG: ${r.data.message}`);
|
||||||
|
} catch (ex: any) {
|
||||||
|
setMsg(`Ошибка: ${ex?.response?.data?.detail ?? ex.message}`);
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Send size={14} className="text-mk-accent2" />
|
||||||
|
<h3 className="text-sm font-semibold">Telegram-бот</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-mk-mute">
|
||||||
|
Опциональная отправка алертов в Telegram. Создайте бота через <code>@BotFather</code>,
|
||||||
|
получите <code>chat_id</code> через <code>@userinfobot</code>.
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox" checked={draft.enabled}
|
||||||
|
onChange={(e) => upd('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Включить отправку
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Bot token</label>
|
||||||
|
<input
|
||||||
|
className="input font-mono text-xs"
|
||||||
|
type="password"
|
||||||
|
placeholder="123456:ABC-DEF…"
|
||||||
|
value={draft.bot_token}
|
||||||
|
onChange={(e) => upd('bot_token', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Chat ID</label>
|
||||||
|
<input
|
||||||
|
className="input font-mono text-xs"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456789 или -100…"
|
||||||
|
value={draft.chat_id}
|
||||||
|
onChange={(e) => upd('chat_id', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-mk-mute">Минимальная серьёзность</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={draft.min_severity}
|
||||||
|
onChange={(e) => upd('min_severity', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button className="btn-primary !py-1 !text-xs" onClick={save} disabled={busy !== null}>
|
||||||
|
<Save size={13} /> {busy === 'save' ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-ghost !py-1 !text-xs"
|
||||||
|
onClick={test}
|
||||||
|
disabled={busy !== null || !draft.bot_token}
|
||||||
|
>
|
||||||
|
<Send size={13} /> {busy === 'tg' ? 'Отправка…' : 'Сохранить и отправить тест'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-xs text-mk-mute">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
email: string | null;
|
||||||
|
setTokens: (a: string, r: string, email?: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
email: null,
|
||||||
|
setTokens: (a, r, email) => set({ accessToken: a, refreshToken: r, email: email ?? null }),
|
||||||
|
logout: () => set({ accessToken: null, refreshToken: null, email: null }),
|
||||||
|
}),
|
||||||
|
{ name: 'mcc-auth' },
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api, AppSettings } from '@/api/client';
|
||||||
|
import { applyTheme, applyLocale, applyInstanceName } from '@/utils/theme';
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
settings: AppSettings | null;
|
||||||
|
loading: boolean;
|
||||||
|
load: () => Promise<void>;
|
||||||
|
patch: (p: Partial<AppSettings> | Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll(s: AppSettings | null) {
|
||||||
|
if (!s?.ui) return;
|
||||||
|
applyTheme(s.ui.theme);
|
||||||
|
applyLocale(s.ui.locale);
|
||||||
|
applyInstanceName(s.ui.instance_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettings = create<SettingsState>((set) => ({
|
||||||
|
settings: null,
|
||||||
|
loading: false,
|
||||||
|
load: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const r = await api.get<AppSettings>('/settings');
|
||||||
|
set({ settings: r.data });
|
||||||
|
applyAll(r.data);
|
||||||
|
} finally { set({ loading: false }); }
|
||||||
|
},
|
||||||
|
patch: async (p) => {
|
||||||
|
const r = await api.put<AppSettings>('/settings', p);
|
||||||
|
set({ settings: r.data });
|
||||||
|
applyAll(r.data);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** 10 шуточных «всё ОК» сообщений для GlobalHealth. */
|
||||||
|
export const OK_MESSAGES: string[] = [
|
||||||
|
'Всё чётко, бро. ✨',
|
||||||
|
'Полный релакс, без паники. 🧘',
|
||||||
|
'Полёт нормальный, чай заварен. ☕',
|
||||||
|
'Сервер дышит ровно, спи спокойно. 💤',
|
||||||
|
'Муха не пролетит, всё ок. 🪰',
|
||||||
|
'Данные на месте, никуда не сбежали. 💾',
|
||||||
|
'Ситуация под полным кайфом. 😎',
|
||||||
|
'Железо холодное, как сердце бывшей. ❄️',
|
||||||
|
'Ошибки ушли в отпуск навсегда. 🏖️',
|
||||||
|
'Всё идёт просто замечательно. 👍',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickOkMessage(): string {
|
||||||
|
return OK_MESSAGES[Math.floor(Math.random() * OK_MESSAGES.length)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Применяет тему и язык к документу при загрузке/смене настроек.
|
||||||
|
export function applyTheme(theme: string | undefined) {
|
||||||
|
const id = theme && typeof theme === 'string' ? theme : 'mk-dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLocale(locale: string | undefined) {
|
||||||
|
const id = locale && typeof locale === 'string' ? locale : 'ru';
|
||||||
|
document.documentElement.setAttribute('lang', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInstanceName(name: string | undefined) {
|
||||||
|
if (name) document.title = name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Простой компаратор версий вида "7.15.3" / "7.15rc4" / "stable"
|
||||||
|
function tokenize(v: string): number[] {
|
||||||
|
const m = v.match(/\d+/g);
|
||||||
|
return m ? m.map((x) => parseInt(x, 10)) : [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const A = tokenize(a);
|
||||||
|
const B = tokenize(b);
|
||||||
|
const n = Math.max(A.length, B.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = A[i] ?? 0;
|
||||||
|
const y = B[i] ?? 0;
|
||||||
|
if (x !== y) return x - y;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirmwareLike {
|
||||||
|
version: string | null;
|
||||||
|
channel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает максимальную версию из репозитория (только канал stable, либо null). */
|
||||||
|
export function latestStableVersion(firmware: FirmwareLike[]): string | null {
|
||||||
|
const versions = firmware
|
||||||
|
.filter((f) => !!f.version && (!f.channel || f.channel === 'stable'))
|
||||||
|
.map((f) => f.version as string);
|
||||||
|
if (versions.length === 0) return null;
|
||||||
|
return versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOutdated(deviceVersion: string | null, latest: string | null): boolean {
|
||||||
|
if (!deviceVersion || !latest) return false;
|
||||||
|
return compareVersions(deviceVersion, latest) < 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Палитра завязана на CSS-переменные (см. index.css [data-theme=...]).
|
||||||
|
// Значения переменных — raw "R G B" (без rgb()), чтобы работали opacity-модификаторы Tailwind: bg-unifi-ok/15.
|
||||||
|
unifi: {
|
||||||
|
bg: 'rgb(var(--c-bg) / <alpha-value>)',
|
||||||
|
panel: 'rgb(var(--c-panel) / <alpha-value>)',
|
||||||
|
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
|
||||||
|
border: 'rgb(var(--c-border) / <alpha-value>)',
|
||||||
|
text: 'rgb(var(--c-text) / <alpha-value>)',
|
||||||
|
mute: 'rgb(var(--c-mute) / <alpha-value>)',
|
||||||
|
accent: 'rgb(var(--c-accent) / <alpha-value>)',
|
||||||
|
accent2: 'rgb(var(--c-accent2) / <alpha-value>)',
|
||||||
|
ok: 'rgb(var(--c-ok) / <alpha-value>)',
|
||||||
|
warn: 'rgb(var(--c-warn) / <alpha-value>)',
|
||||||
|
err: 'rgb(var(--c-err) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
// Алиас mk-* → те же CSS-переменные, что и unifi-*.
|
||||||
|
// Нужен для совместимости со старыми компонентами (CLI.tsx, ChatBot, AboutModal, index.css),
|
||||||
|
// где ещё используются классы вида text-mk-mute, border-mk-border и т.п.
|
||||||
|
mk: {
|
||||||
|
bg: 'rgb(var(--c-bg) / <alpha-value>)',
|
||||||
|
panel: 'rgb(var(--c-panel) / <alpha-value>)',
|
||||||
|
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
|
||||||
|
border: 'rgb(var(--c-border) / <alpha-value>)',
|
||||||
|
text: 'rgb(var(--c-text) / <alpha-value>)',
|
||||||
|
mute: 'rgb(var(--c-mute) / <alpha-value>)',
|
||||||
|
accent: 'rgb(var(--c-accent) / <alpha-value>)',
|
||||||
|
accent2: 'rgb(var(--c-accent2) / <alpha-value>)',
|
||||||
|
ok: 'rgb(var(--c-ok) / <alpha-value>)',
|
||||||
|
warn: 'rgb(var(--c-warn) / <alpha-value>)',
|
||||||
|
err: 'rgb(var(--c-err) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Плагин: на все ответы dev-сервера ставит Cache-Control: no-store.
|
||||||
|
// Решает проблему, когда браузер/прокси кэшируют HMR-обновлённые файлы.
|
||||||
|
const noCache = () => ({
|
||||||
|
name: 'roszetta-no-cache',
|
||||||
|
configureServer(server: any) {
|
||||||
|
server.middlewares.use((_req: any, res: any, next: any) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), noCache()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, 'src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
watch: {
|
||||||
|
// Под Docker bind-mount inotify иногда не отрабатывает — fallback на polling.
|
||||||
|
usePolling: true,
|
||||||
|
interval: 500,
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user