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

+48
View File
@@ -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>
);
}
+234
View File
@@ -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[];
}
+49
View File
@@ -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>
);
}
+457
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+926
View File
@@ -0,0 +1,926 @@
// SVG-мокапы лицевых панелей MikroTik. Подсвечивают живые порты по InterfaceInfo[].
// Сейчас реализован hAP ac lite (RB952Ui-5ac2nD): синий корпус, 5 ethernet,
// первый — PoE in (Internet), 24 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>
);
}
+165
View File
@@ -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 мин' },
];
+131
View File
@@ -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; }
}
+13
View File
@@ -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>,
);
+101
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+904
View File
@@ -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>
);
}
+292
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+484
View File
@@ -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/&lt;version&gt;/routeros-&lt;version&gt;-&lt;arch&gt;.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>
);
}
+76
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+483
View File
@@ -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>
);
}
+207
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+23
View File
@@ -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' },
),
);
+35
View File
@@ -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);
},
}));
+17
View File
@@ -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)];
}
+14
View File
@@ -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;
}
+36
View File
@@ -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;
}
+45
View File
@@ -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: [],
};
+22
View File
@@ -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"]
}
+39
View File
@@ -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,
},
},
},
});