diff --git a/backend/app/api/routes/routers.py b/backend/app/api/routes/routers.py index fa58cfc..5a30bbc 100644 --- a/backend/app/api/routes/routers.py +++ b/backend/app/api/routes/routers.py @@ -1,18 +1,23 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db from app.models.router import Router from app.models.user import User -from app.schemas.router import RouterCreate, RouterResponse, RouterTestConnection, RouterUpdate +from app.schemas.router import RouterCreate, RouterPingStatus, RouterResponse, RouterTestConnection, RouterUpdate from app.services.router_service import router_service from app.services.settings_service import settings_service router = APIRouter() +class RouterPingBulkResponse(BaseModel): + items: list[RouterPingStatus] + + def serialize_router(router: Router, global_settings) -> RouterResponse: has_router_key = bool((router.ssh_key or '').strip()) has_global_key = bool((global_settings.global_ssh_key or '').strip()) @@ -49,6 +54,12 @@ def list_routers(current_user: User = Depends(get_current_user), db: Session = D return [serialize_router(router, global_settings) for router in routers] +@router.get('/ping-statuses', response_model=RouterPingBulkResponse) +def list_ping_statuses(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + routers = db.query(Router).filter(Router.owner_id == current_user.id).all() + return RouterPingBulkResponse(items=router_service.ping_many(routers)) + + @router.post('', response_model=RouterResponse) def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router_data = payload.model_dump() diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 843b738..034dffa 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -61,6 +61,10 @@ def _run_lightweight_migrations() -> None: _ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)') _ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)') _ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN') + _ensure_column('routers', 'disable_all_backups', 'BOOLEAN DEFAULT 0 NOT NULL') + _ensure_column('routers', 'disable_export_backups', 'BOOLEAN DEFAULT 0 NOT NULL') + _ensure_column('routers', 'disable_binary_backups', 'BOOLEAN DEFAULT 0 NOT NULL') + _ensure_column('routers', 'disable_ping', 'BOOLEAN DEFAULT 0 NOT NULL') def init_db(): diff --git a/backend/app/models/router.py b/backend/app/models/router.py index d998e5b..713b13a 100644 --- a/backend/app/models/router.py +++ b/backend/app/models/router.py @@ -29,6 +29,10 @@ class Router(Base): last_connection_auth_mode = Column(String(64), nullable=True) last_connection_http_status = Column(String(32), nullable=True) last_connection_backup_available = Column(Boolean, nullable=True) + disable_all_backups = Column(Boolean, nullable=False, default=False) + disable_export_backups = Column(Boolean, nullable=False, default=False) + disable_binary_backups = Column(Boolean, nullable=False, default=False) + disable_ping = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, server_default=func.now(), nullable=False) backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan") diff --git a/backend/app/schemas/router.py b/backend/app/schemas/router.py index 66c1809..61aba08 100644 --- a/backend/app/schemas/router.py +++ b/backend/app/schemas/router.py @@ -16,6 +16,10 @@ class RouterBase(BaseModel): ssh_user: str | None = Field(default=None, max_length=120) ssh_key: str | None = None ssh_password: str | None = None + disable_all_backups: bool = False + disable_export_backups: bool = False + disable_binary_backups: bool = False + disable_ping: bool = False @field_validator("name") @classmethod @@ -54,6 +58,10 @@ class RouterUpdate(BaseModel): ssh_user: str | None = None ssh_key: str | None = None ssh_password: str | None = None + disable_all_backups: bool | None = None + disable_export_backups: bool | None = None + disable_binary_backups: bool | None = None + disable_ping: bool | None = None @field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before") @classmethod @@ -72,6 +80,10 @@ class RouterResponse(RouterBase): has_effective_password: bool = False supports_export: bool = False supports_restore_upload: bool = False + disable_all_backups: bool = False + disable_export_backups: bool = False + disable_binary_backups: bool = False + disable_ping: bool = False last_connection_status: bool | None = None last_connection_tested_at: datetime | None = None last_connection_error: str | None = None @@ -102,3 +114,10 @@ class RouterTestConnection(BaseModel): auth_mode: str | None = None http_status: str | None = None backup_available: bool | None = None + + +class RouterPingStatus(BaseModel): + router_id: int + reachable: bool + latency_ms: float | None = None + disabled: bool = False diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 56fe4fe..a92b123 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -191,6 +191,8 @@ class BackupService: router = self._router_for_user(db, user, router_id) if router.device_type != 'routeros': raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices') + if router.disable_all_backups or router.disable_export_backups: + raise HTTPException(status_code=400, detail='Exports are disabled for this device') settings = settings_service.get_or_create(db) stamp = datetime.now().strftime('%Y%m%d_%H%M%S') name = f'{router.name}_{router.id}_{stamp}.rsc' @@ -214,6 +216,8 @@ class BackupService: def binary_backup(self, db: Session, user: User, router_id: int) -> Backup: router = self._router_for_user(db, user, router_id) + if router.disable_all_backups or router.disable_binary_backups: + raise HTTPException(status_code=400, detail='Binary backups are disabled for this device') settings = settings_service.get_or_create(db) stamp = datetime.now().strftime('%Y%m%d_%H%M%S') base_name = f'{router.name}_{router.id}_{stamp}' @@ -306,6 +310,13 @@ class BackupService: routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: + if router.disable_all_backups or router.disable_export_backups: + result.append({ + 'router': router.name, + 'status': 'skipped', + 'message': 'Exports are disabled for this device', + }) + continue if (router.device_type or 'routeros').lower() != 'routeros': result.append({ 'router': router.name, @@ -324,6 +335,13 @@ class BackupService: routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: + if router.disable_all_backups or router.disable_binary_backups: + result.append({ + 'router': router.name, + 'status': 'skipped', + 'message': 'Binary backups are disabled for this device', + }) + continue try: backup = self.binary_backup(db, user, router.id) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) diff --git a/backend/app/services/router_service.py b/backend/app/services/router_service.py index c682b8d..5bd17ee 100644 --- a/backend/app/services/router_service.py +++ b/backend/app/services/router_service.py @@ -1,6 +1,10 @@ +from concurrent.futures import ThreadPoolExecutor from datetime import datetime import io from pathlib import Path +import platform +import re +import subprocess import paramiko from sqlalchemy.orm import Session @@ -11,6 +15,30 @@ from app.services.swos_beta_service import swos_beta_service class RouterService: + def ping(self, router: Router): + if getattr(router, 'disable_ping', False): + return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': True} + count_flag = '-n' if platform.system().lower().startswith('win') else '-c' + timeout_flag = '-w' if platform.system().lower().startswith('win') else '-W' + command = ['ping', count_flag, '1', timeout_flag, '1', router.host] + try: + completed = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False) + output = completed.stdout + "\n" + completed.stderr + if completed.returncode != 0: + return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': False} + match = re.search(r'time[=<]\s*([0-9]+(?:[.,][0-9]+)?)\s*ms', output, re.IGNORECASE) + latency = float(match.group(1).replace(',', '.')) if match else None + return {'router_id': router.id, 'reachable': True, 'latency_ms': latency, 'disabled': False} + except Exception: + return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': False} + + def ping_many(self, routers: list[Router]): + if not routers: + return [] + max_workers = min(8, max(1, len(routers))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + return list(executor.map(self.ping, routers)) + def _load_pkey(self, ssh_key_str: str): key_str = (ssh_key_str or "").strip() key_buffer = io.StringIO(key_str) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 7394790..a4076ea 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -212,6 +212,8 @@ class SchedulerService: with SessionLocal() as db: routers = db.query(Router).all() for router in routers: + if router.disable_all_backups or router.disable_export_backups: + continue try: backup_service.export_router(db, type('U', (), {'id': router.owner_id})(), router.id) except Exception as exc: @@ -221,6 +223,8 @@ class SchedulerService: with SessionLocal() as db: routers = db.query(Router).all() for router in routers: + if router.disable_all_backups or router.disable_binary_backups: + continue try: backup_service.binary_backup(db, type('U', (), {'id': router.owner_id})(), router.id) except Exception as exc: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a90f23..aafe7db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5193,9 +5193,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5213,9 +5210,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5233,9 +5227,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5253,9 +5244,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5273,9 +5261,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5293,9 +5278,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5313,9 +5295,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5851,9 +5830,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5875,9 +5851,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5899,9 +5872,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5923,9 +5893,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5947,9 +5914,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5971,9 +5935,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6189,9 +6150,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6206,9 +6164,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6223,9 +6178,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6240,9 +6192,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6257,9 +6206,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6274,9 +6220,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6291,9 +6234,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6308,9 +6248,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6325,9 +6262,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6342,9 +6276,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6359,9 +6290,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6376,9 +6304,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6393,9 +6318,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/app/features/routers/router-detail-page.component.html b/frontend/src/app/features/routers/router-detail-page.component.html index 79a47c9..516ee58 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.html +++ b/frontend/src/app/features/routers/router-detail-page.component.html @@ -4,10 +4,11 @@ [subtitle]="subtitle" >
{{ 'routers.noDiff' | translate }}
-{{ 'routers.noPreview' | translate }}
+{{ 'routers.noDiff' | translate }}
+{{ diffText }}
+
+
+