diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index a8b38f6..1d01bfa 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,14 +1,12 @@ from fastapi import APIRouter -from app.api.routes import auth, backups, dashboard, health, logs, routers, settings, swos_beta +from app.api.routes import auth, backups, dashboard, health, logs, routers, settings api_router = APIRouter() -api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) -api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) -api_router.include_router(routers.router, prefix="/routers", tags=["routers"]) -api_router.include_router(backups.router, prefix="/backups", tags=["backups"]) -api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) -api_router.include_router(logs.router, prefix="/logs", tags=["logs"]) -api_router.include_router(health.router, tags=["health"]) - -api_router.include_router(swos_beta.router, prefix='/swos-beta', tags=['swos-beta']) +api_router.include_router(auth.router, prefix='/auth', tags=['auth']) +api_router.include_router(dashboard.router, prefix='/dashboard', tags=['dashboard']) +api_router.include_router(routers.router, prefix='/routers', tags=['routers']) +api_router.include_router(backups.router, prefix='/backups', tags=['backups']) +api_router.include_router(settings.router, prefix='/settings', tags=['settings']) +api_router.include_router(logs.router, prefix='/logs', tags=['logs']) +api_router.include_router(health.router, tags=['health']) diff --git a/backend/app/api/routes/routers.py b/backend/app/api/routes/routers.py index 95421e7..fa58cfc 100644 --- a/backend/app/api/routes/routers.py +++ b/backend/app/api/routes/routers.py @@ -13,73 +13,108 @@ from app.services.settings_service import settings_service router = APIRouter() -def serialize_router(router: Router, global_ssh_key: str | None = None) -> RouterResponse: +def serialize_router(router: Router, global_settings) -> RouterResponse: has_router_key = bool((router.ssh_key or '').strip()) - has_global_key = bool((global_ssh_key or '').strip()) + has_global_key = bool((global_settings.global_ssh_key or '').strip()) + router_user = (router.ssh_user or '').strip() or None + router_password = (router.ssh_password or '').strip() or None + default_swos_user = (global_settings.default_switchos_username or '').strip() or None + default_swos_password = (global_settings.default_switchos_password or '').strip() or None + effective_username = router_user + uses_global_switchos_credentials = False + has_effective_password = bool(router_password) + + if router.device_type == 'switchos': + effective_username = router_user or default_swos_user + uses_global_switchos_credentials = bool( + (not router_user and default_swos_user) or (not router_password and default_swos_password) + ) + has_effective_password = bool(router_password or default_swos_password) + payload = RouterResponse.model_validate(router, from_attributes=True).model_dump() - payload['uses_global_ssh_key'] = has_global_key and not has_router_key - payload['has_effective_ssh_key'] = has_router_key or has_global_key + payload['effective_username'] = effective_username + payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key + payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key) + payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials + payload['has_effective_password'] = has_effective_password + payload['supports_export'] = router.device_type == 'routeros' + payload['supports_restore_upload'] = router.device_type == 'routeros' return RouterResponse.model_validate(payload) -@router.get("", response_model=list[RouterResponse]) +@router.get('', response_model=list[RouterResponse]) def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - settings = settings_service.get_or_create(db) + global_settings = settings_service.get_or_create(db) routers = db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all() - return [serialize_router(router, settings.global_ssh_key) for router in routers] + return [serialize_router(router, global_settings) for router in routers] -@router.post("", response_model=RouterResponse) +@router.post('', response_model=RouterResponse) def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - router = Router(**payload.model_dump(), owner_id=current_user.id) + router_data = payload.model_dump() + if router_data.get('device_type') == 'switchos' and router_data.get('ssh_user') is None: + router_data['ssh_user'] = '' + router = Router(**router_data, owner_id=current_user.id) db.add(router) db.commit() db.refresh(router) - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.get("/{router_id}", response_model=RouterResponse) +@router.get('/{router_id}', response_model=RouterResponse) def get_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + raise HTTPException(status_code=404, detail='Device not found') + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.put("/{router_id}", response_model=RouterResponse) +@router.put('/{router_id}', response_model=RouterResponse) def update_router(router_id: int, payload: RouterUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - for key, value in payload.model_dump(exclude_unset=True).items(): + raise HTTPException(status_code=404, detail='Device not found') + changes = payload.model_dump(exclude_unset=True) + target_device_type = changes.get('device_type', router.device_type) + if target_device_type == 'switchos': + changes['ssh_key'] = None + if 'port' not in changes: + changes['port'] = 80 + if changes.get('ssh_user') is None: + changes['ssh_user'] = '' + elif target_device_type == 'routeros' and 'port' not in changes and router.device_type != 'routeros': + changes['port'] = 22 + if not changes.get('ssh_user'): + changes['ssh_user'] = router.ssh_user or 'admin' + for key, value in changes.items(): setattr(router, key, value) db.add(router) db.commit() db.refresh(router) - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.delete("/{router_id}") +@router.delete('/{router_id}') def delete_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") + raise HTTPException(status_code=404, detail='Device not found') for backup in list(router.backups): path = Path(backup.file_path) if path.exists(): path.unlink() db.delete(router) db.commit() - return {"message": "Router deleted"} + return {'message': 'Device deleted'} -@router.get("/{router_id}/test-connection", response_model=RouterTestConnection) +@router.get('/{router_id}/test-connection', response_model=RouterTestConnection) def test_connection(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - settings = settings_service.get_or_create(db) - return router_service.test_connection(db, router, settings.global_ssh_key) + raise HTTPException(status_code=404, detail='Device not found') + global_settings = settings_service.get_or_create(db) + return router_service.test_connection(db, router, global_settings) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 99ca8a3..0cf1d15 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -23,6 +23,9 @@ def serialize_settings(settings: GlobalSettings) -> SettingsResponse: payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump() payload['global_ssh_key'] = None payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip()) + payload['has_default_switchos_credentials'] = bool( + (settings.default_switchos_username or '').strip() or (settings.default_switchos_password or '').strip() + ) return SettingsResponse.model_validate(payload) diff --git a/backend/app/db/session.py b/backend/app/db/session.py index ea29271..843b738 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -42,10 +42,13 @@ def _run_lightweight_migrations() -> None: tables = set(inspect(engine).get_table_names()) if 'global_settings' in tables: _ensure_column('global_settings', 'connection_test_interval_minutes', 'INTEGER DEFAULT 0') + _ensure_column('global_settings', 'default_switchos_username', 'VARCHAR(120)') + _ensure_column('global_settings', 'default_switchos_password', 'VARCHAR(255)') if 'users' in tables: _ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL") _ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL") if 'routers' in tables: + _ensure_column('routers', 'device_type', "VARCHAR(32) DEFAULT 'routeros' NOT NULL") _ensure_column('routers', 'last_connection_status', 'BOOLEAN') _ensure_column('routers', 'last_connection_tested_at', 'DATETIME') _ensure_column('routers', 'last_connection_error', 'TEXT') @@ -53,6 +56,11 @@ def _run_lightweight_migrations() -> None: _ensure_column('routers', 'last_connection_model', 'VARCHAR(255)') _ensure_column('routers', 'last_connection_version', 'VARCHAR(255)') _ensure_column('routers', 'last_connection_uptime', 'VARCHAR(255)') + _ensure_column('routers', 'last_connection_transport', 'VARCHAR(32)') + _ensure_column('routers', 'last_connection_server', 'VARCHAR(255)') + _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') def init_db(): diff --git a/backend/app/models/router.py b/backend/app/models/router.py index ab44dc2..d998e5b 100644 --- a/backend/app/models/router.py +++ b/backend/app/models/router.py @@ -11,6 +11,7 @@ class Router(Base): id = Column(Integer, primary_key=True, index=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) name = Column(String(120), nullable=False) + device_type = Column(String(32), nullable=False, default="routeros") host = Column(String(255), nullable=False) port = Column(Integer, nullable=False, default=22) ssh_user = Column(String(120), nullable=False, default="admin") @@ -23,6 +24,11 @@ class Router(Base): last_connection_model = Column(String(255), nullable=True) last_connection_version = Column(String(255), nullable=True) last_connection_uptime = Column(String(255), nullable=True) + last_connection_transport = Column(String(32), nullable=True) + last_connection_server = Column(String(255), nullable=True) + 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) 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/models/settings.py b/backend/app/models/settings.py index 9a96dab..29e71fe 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -15,6 +15,8 @@ class GlobalSettings(Base): enable_auto_export = Column(Boolean, default=False) connection_test_interval_minutes = Column(Integer, default=0) global_ssh_key = Column(Text, nullable=True) + default_switchos_username = Column(String(120), nullable=True) + default_switchos_password = Column(String(255), nullable=True) pushover_token = Column(String(255), nullable=True) pushover_userkey = Column(String(255), nullable=True) notify_failures_only = Column(Boolean, default=True) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 7240f75..85368ca 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -8,6 +8,7 @@ class BackupResponse(BaseModel): id: int router_id: int router_name: str | None = None + device_type: str = "routeros" file_path: str file_name: str backup_type: str diff --git a/backend/app/schemas/router.py b/backend/app/schemas/router.py index 4ded433..66c1809 100644 --- a/backend/app/schemas/router.py +++ b/backend/app/schemas/router.py @@ -1,16 +1,19 @@ import re from datetime import datetime +from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$") +DeviceType = Literal["routeros", "switchos"] class RouterBase(BaseModel): name: str = Field(min_length=1, max_length=120) + device_type: DeviceType = "routeros" host: str = Field(min_length=1, max_length=255) - port: int = Field(default=22, ge=1, le=65535) - ssh_user: str = Field(default="admin", min_length=1, max_length=120) + port: int | None = Field(default=None, ge=1, le=65535) + ssh_user: str | None = Field(default=None, max_length=120) ssh_key: str | None = None ssh_password: str | None = None @@ -21,6 +24,23 @@ class RouterBase(BaseModel): raise ValueError("Only letters, digits, dashes and underscores are allowed") return value + @field_validator("host", "ssh_user", "ssh_key", "ssh_password", mode="before") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + normalized = (value or "").strip() + return normalized or None + + @model_validator(mode="after") + def apply_device_defaults(self): + if self.device_type == "routeros": + self.port = self.port or 22 + self.ssh_user = self.ssh_user or "admin" + return self + + self.port = self.port or 80 + self.ssh_key = None + return self + class RouterCreate(RouterBase): pass @@ -28,18 +48,30 @@ class RouterCreate(RouterBase): class RouterUpdate(BaseModel): name: str | None = None + device_type: DeviceType | None = None host: str | None = None port: int | None = Field(default=None, ge=1, le=65535) ssh_user: str | None = None ssh_key: str | None = None ssh_password: str | None = None + @field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + normalized = (value or "").strip() + return normalized or None + class RouterResponse(RouterBase): id: int owner_id: int + effective_username: str | None = None uses_global_ssh_key: bool = False has_effective_ssh_key: bool = False + uses_global_switchos_credentials: bool = False + has_effective_password: bool = False + supports_export: bool = False + supports_restore_upload: bool = False last_connection_status: bool | None = None last_connection_tested_at: datetime | None = None last_connection_error: str | None = None @@ -47,6 +79,11 @@ class RouterResponse(RouterBase): last_connection_model: str | None = None last_connection_version: str | None = None last_connection_uptime: str | None = None + last_connection_transport: str | None = None + last_connection_server: str | None = None + last_connection_auth_mode: str | None = None + last_connection_http_status: str | None = None + last_connection_backup_available: bool | None = None created_at: datetime | None = None model_config = {"from_attributes": True} @@ -60,3 +97,8 @@ class RouterTestConnection(BaseModel): hostname: str version: str | None = None error: str | None = None + transport: str | None = None + server: str | None = None + auth_mode: str | None = None + http_status: str | None = None + backup_available: bool | None = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 36348b5..a2a9908 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -15,6 +15,8 @@ class SettingsBase(BaseModel): enable_auto_export: bool = False connection_test_interval_minutes: int = Field(default=0, ge=0, le=1440) global_ssh_key: str | None = None + default_switchos_username: str | None = None + default_switchos_password: str | None = None pushover_token: str | None = None pushover_userkey: str | None = None notify_failures_only: bool = True @@ -30,9 +32,9 @@ class SettingsBase(BaseModel): def normalize_cron(cls, value: str | None) -> str: return (value or '').strip() - @field_validator('global_ssh_key', mode='before') + @field_validator('global_ssh_key', 'default_switchos_username', 'default_switchos_password', mode='before') @classmethod - def normalize_key(cls, value: str | None) -> str | None: + def normalize_secret_text(cls, value: str | None) -> str | None: normalized = (value or '').strip() return normalized or None @@ -55,6 +57,7 @@ class SettingsUpdate(SettingsBase): class SettingsResponse(SettingsBase): id: int has_global_ssh_key: bool = False + has_default_switchos_credentials: bool = False model_config = {'from_attributes': True} diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 377f695..745ce7e 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -19,7 +19,7 @@ class BackupService: def _router_for_user(self, db: Session, user: User, router_id: int) -> Router: router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first() if not router: - raise HTTPException(status_code=404, detail='Router not found') + raise HTTPException(status_code=404, detail='Device not found') return router def _serialize_backup(self, backup: Backup): @@ -28,6 +28,7 @@ class BackupService: 'id': backup.id, 'router_id': backup.router_id, 'router_name': backup.router.name if backup.router else None, + 'device_type': backup.router.device_type if backup.router else 'routeros', 'file_path': backup.file_path, 'file_name': backup.file_name, 'backup_type': backup.backup_type, @@ -179,6 +180,8 @@ class BackupService: def export_router(self, db: Session, user: User, router_id: int) -> Backup: 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') 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' @@ -190,12 +193,14 @@ class BackupService: db.add(backup) db.commit() db.refresh(backup) - log_service.add(db, f'Export OK for router {router.name}') + log_service.add(db, f'Export OK for device {router.name}') notification_service.notify(settings, f'Export {router.name} OK', True) return backup + except HTTPException: + raise except Exception as exc: notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False) - log_service.add(db, f'Export FAILED for router {router.name}: {exc}') + log_service.add(db, f'Export FAILED for device {router.name}: {exc}') raise HTTPException(status_code=500, detail=str(exc)) from exc def binary_backup(self, db: Session, user: User, router_id: int) -> Backup: @@ -203,34 +208,41 @@ class BackupService: 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}' - name = f'{base_name}.backup' + extension = '.swb' if router.device_type == 'switchos' else '.backup' + name = f'{base_name}{extension}' file_path = ensure_data_dir() / name try: - router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key) + router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key, settings) checksum = compute_checksum(str(file_path)) backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum) db.add(backup) db.commit() db.refresh(backup) - log_service.add(db, f'Binary backup OK for router {router.name}') + log_service.add(db, f'Binary backup OK for device {router.name}') notification_service.notify(settings, f'Backup {router.name} OK', True) return backup + except HTTPException: + raise except Exception as exc: notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False) - log_service.add(db, f'Binary backup FAILED for router {router.name}: {exc}') + log_service.add(db, f'Binary backup FAILED for device {router.name}: {exc}') raise HTTPException(status_code=500, detail=str(exc)) from exc def upload_backup_to_router(self, db: Session, user: User, router_id: int, backup_id: int): router = self._router_for_user(db, user, router_id) + if router.device_type != 'routeros': + raise HTTPException(status_code=400, detail='Restore upload is available only for RouterOS devices') backup = self.get_backup_for_user(db, user, backup_id) if backup.backup_type != 'binary': raise HTTPException(status_code=400, detail='Only binary backups can be uploaded') + if backup.router and backup.router.device_type != 'routeros': + raise HTTPException(status_code=400, detail='SwitchOS backup files cannot be restored over SSH upload') checksum = compute_checksum(backup.file_path) if backup.checksum and checksum != backup.checksum: raise HTTPException(status_code=400, detail='Checksum mismatch') settings = settings_service.get_or_create(db) router_service.upload_backup(router, backup.file_path, settings.global_ssh_key) - log_service.add(db, f'Upload backup OK for router {router.name}') + log_service.add(db, f'Upload backup OK for device {router.name}') def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True): backup = self.get_backup_for_user(db, user, backup_id) @@ -274,9 +286,10 @@ class BackupService: def email_backup(self, db: Session, user: User, backup_id: int): backup = self.get_backup_for_user(db, user, backup_id) settings = settings_service.get_or_create(db) + platform_name = 'SwitchOS' if backup.router and backup.router.device_type == 'switchos' else 'RouterOS' noun = 'Export' if backup.backup_type == 'export' else 'Backup' - subject = f'RouterOS {noun}: {backup.file_name}' - body = f'Sending {backup.file_name} from router {backup.router.name}.' + subject = f'{platform_name} {noun}: {backup.file_name}' + body = f'Sending {backup.file_name} from device {backup.router.name}.' notification_service.send_email(settings, subject, body, backup.file_path) log_service.add(db, f'Email sent for backup {backup.file_name}') @@ -284,6 +297,9 @@ class BackupService: routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: + if router.device_type != 'routeros': + result.append({'router': router.name, 'status': 'skipped', 'message': 'SwitchOS devices do not support text export'}) + continue try: backup = self.export_router(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 36eb450..80f1bc9 100644 --- a/backend/app/services/router_service.py +++ b/backend/app/services/router_service.py @@ -6,6 +6,7 @@ import paramiko from sqlalchemy.orm import Session from app.models.router import Router +from app.services.swos_beta_service import swos_beta_service class RouterService: @@ -47,18 +48,25 @@ class RouterService: return client def export(self, router: Router, global_ssh_key: str | None = None) -> str: + if router.device_type != 'routeros': + raise ValueError('Export tekstowy jest dostępny tylko dla RouterOS.') client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command("/export") - output = stdout.read().decode("utf-8", errors="ignore") + _, stdout, _ = client.exec_command('/export') + output = stdout.read().decode('utf-8', errors='ignore') client.close() return output - def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None) -> str: + def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None, global_settings=None) -> str: + if router.device_type == 'switchos': + downloaded = swos_beta_service.download_backup_for_router(router, global_settings) + Path(local_path).write_bytes(downloaded.content) + return local_path + client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command(f"/system backup save name={backup_name}") + _, stdout, _ = client.exec_command(f'/system backup save name={backup_name}') stdout.channel.recv_exit_status() sftp = client.open_sftp() - remote_file = f"{backup_name}.backup" + remote_file = f'{backup_name}.backup' sftp.get(remote_file, local_path) try: sftp.remove(remote_file) @@ -69,6 +77,8 @@ class RouterService: return local_path def upload_backup(self, router: Router, local_backup_path: str, global_ssh_key: str | None = None): + if router.device_type != 'routeros': + raise ValueError('Przywracanie plików jest dostępne tylko dla RouterOS.') client = self._connect(router, global_ssh_key) sftp = client.open_sftp() target_name = Path(local_backup_path).name @@ -76,64 +86,84 @@ class RouterService: sftp.close() client.close() - def probe_connection(self, router: Router, global_ssh_key: str | None = None): + def _probe_routeros_connection(self, router: Router, global_ssh_key: str | None = None): tested_at = datetime.utcnow() try: client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command("/system resource print without-paging") - resource_output = stdout.read().decode("utf-8", errors="ignore") - _, stdout, _ = client.exec_command("/system identity print") - identity_output = stdout.read().decode("utf-8", errors="ignore") + _, stdout, _ = client.exec_command('/system resource print without-paging') + resource_output = stdout.read().decode('utf-8', errors='ignore') + _, stdout, _ = client.exec_command('/system identity print') + identity_output = stdout.read().decode('utf-8', errors='ignore') client.close() - model = "Unknown" - uptime = "Unknown" - hostname = "Unknown" - version = "Unknown" + model = 'Unknown' + uptime = 'Unknown' + hostname = 'Unknown' + version = 'Unknown' for line in resource_output.splitlines(): - if "board-name" in line: - model = line.split(":", 1)[1].strip() - if "uptime" in line: - uptime = line.split(":", 1)[1].strip() - if "version" in line: - version = line.split(":", 1)[1].strip() + if 'board-name' in line: + model = line.split(':', 1)[1].strip() + if 'uptime' in line: + uptime = line.split(':', 1)[1].strip() + if 'version' in line: + version = line.split(':', 1)[1].strip() for line in identity_output.splitlines(): - if "name" in line: - hostname = line.split(":", 1)[1].strip() + if 'name' in line: + hostname = line.split(':', 1)[1].strip() return { - "success": True, - "tested_at": tested_at, - "model": model, - "uptime": uptime, - "hostname": hostname, - "version": version, - "error": None, + 'success': True, + 'tested_at': tested_at, + 'model': model, + 'uptime': uptime, + 'hostname': hostname, + 'version': version, + 'error': None, + 'transport': 'ssh', + 'server': None, + 'auth_mode': 'ssh', + 'http_status': None, + 'backup_available': None, } except Exception as exc: return { - "success": False, - "tested_at": tested_at, - "model": "Unknown", - "uptime": "Unknown", - "hostname": router.name, - "version": None, - "error": str(exc), + 'success': False, + 'tested_at': tested_at, + 'model': 'Unknown', + 'uptime': 'Unknown', + 'hostname': router.name, + 'version': None, + 'error': str(exc), + 'transport': 'ssh', + 'server': None, + 'auth_mode': 'ssh', + 'http_status': None, + 'backup_available': None, } + def probe_connection(self, router: Router, global_ssh_key: str | None = None, global_settings=None): + if router.device_type == 'switchos': + return swos_beta_service.probe_router(router, global_settings) + return self._probe_routeros_connection(router, global_ssh_key) + def _store_connection_result(self, db: Session, router: Router, result: dict): - router.last_connection_status = result["success"] - router.last_connection_tested_at = result["tested_at"] - router.last_connection_error = result.get("error") - router.last_connection_hostname = result.get("hostname") - router.last_connection_model = result.get("model") - router.last_connection_version = result.get("version") - router.last_connection_uptime = result.get("uptime") + router.last_connection_status = result['success'] + router.last_connection_tested_at = result['tested_at'] + router.last_connection_error = result.get('error') + router.last_connection_hostname = result.get('hostname') + router.last_connection_model = result.get('model') + router.last_connection_version = result.get('version') + router.last_connection_uptime = result.get('uptime') + router.last_connection_transport = result.get('transport') + router.last_connection_server = result.get('server') + router.last_connection_auth_mode = result.get('auth_mode') + router.last_connection_http_status = result.get('http_status') + router.last_connection_backup_available = result.get('backup_available') db.add(router) db.commit() db.refresh(router) return result - def test_connection(self, db: Session, router: Router, global_ssh_key: str | None = None): - result = self.probe_connection(router, global_ssh_key) + def test_connection(self, db: Session, router: Router, global_settings): + result = self.probe_connection(router, global_settings.global_ssh_key, global_settings) return self._store_connection_result(db, router, result) diff --git a/backend/app/services/swos_beta_service.py b/backend/app/services/swos_beta_service.py index 18cf704..97273c3 100644 --- a/backend/app/services/swos_beta_service.py +++ b/backend/app/services/swos_beta_service.py @@ -1,7 +1,6 @@ import re from dataclasses import dataclass from datetime import datetime -from pathlib import Path from urllib.parse import urlparse import requests @@ -41,7 +40,55 @@ class SwosBetaService: server=response.headers.get('server'), save_backup_visible='save backup' in html.lower(), backup_endpoint_ok=backup_ok, - note='Moduł działa osobno i nie zapisuje kopii do głównego repozytorium.' + note='SwitchOS jest obsługiwany bezpośrednio w liście urządzeń.' + ) + + def probe_router(self, router, global_settings) -> dict: + payload = self.credentials_from_router(router, global_settings) + tested_at = datetime.utcnow() + try: + result = self.probe(payload) + return { + 'success': result.success, + 'tested_at': tested_at, + 'model': 'SwitchOS', + 'uptime': f'HTTP {result.status_code}', + 'hostname': result.page_title or router.name, + 'version': None, + 'error': None, + 'transport': 'http', + 'server': result.server, + 'auth_mode': result.auth_mode, + 'http_status': str(result.status_code), + 'backup_available': result.backup_endpoint_ok, + } + except Exception as exc: + return { + 'success': False, + 'tested_at': tested_at, + 'model': 'SwitchOS', + 'uptime': 'HTTP', + 'hostname': router.name, + 'version': None, + 'error': str(exc), + 'transport': 'http', + 'server': None, + 'auth_mode': None, + 'http_status': None, + 'backup_available': None, + } + + def credentials_from_router(self, router, global_settings) -> SwosBetaCredentials: + username = (getattr(router, 'ssh_user', None) or '').strip() or (getattr(global_settings, 'default_switchos_username', None) or '').strip() + password = (getattr(router, 'ssh_password', None) or '').strip() or (getattr(global_settings, 'default_switchos_password', None) or '').strip() + if not username: + raise ValueError('Brak użytkownika SwitchOS. Ustaw dane w urządzeniu albo w ustawieniach globalnych.') + return SwosBetaCredentials( + host=router.host, + port=router.port or 80, + username=username, + password=password, + label=router.name, ) def download_backup(self, payload: SwosBetaCredentials) -> DownloadedSwosBackup: @@ -62,6 +109,9 @@ class SwosBetaService: base_url=base_url, ) + def download_backup_for_router(self, router, global_settings) -> DownloadedSwosBackup: + return self.download_backup(self.credentials_from_router(router, global_settings)) + def _request_with_fallback(self, method: str, url: str, payload: SwosBetaCredentials, allow_text_fallback: bool = True): attempts = [] auth_variants = [ @@ -89,8 +139,8 @@ class SwosBetaService: attempts.append(f'{label}:{exc.__class__.__name__}') if last_response is not None: - raise ValueError(f'Nie udało się połączyć ze SwOS ({", ".join(attempts)}).') - raise ValueError('Nie udało się połączyć ze SwOS.') + raise ValueError(f'Nie udało się połączyć ze SwitchOS ({", ".join(attempts)}).') + raise ValueError('Nie udało się połączyć ze SwitchOS.') def _build_base_url(self, host: str, port: int) -> str: raw = host.strip() @@ -118,7 +168,7 @@ class SwosBetaService: label = payload.label or payload.host safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos' timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') - return f'{safe}-swos-{timestamp}.swb' + return f'{safe}-switchos-{timestamp}.swb' swos_beta_service = SwosBetaService() diff --git a/backend/tests/test_swos_beta.py b/backend/tests/test_swos_beta.py index 4f858e8..dd09bb4 100644 --- a/backend/tests/test_swos_beta.py +++ b/backend/tests/test_swos_beta.py @@ -1,74 +1,151 @@ +from pathlib import Path + from fastapi.testclient import TestClient from app.main import app -from app.schemas.swos_beta import SwosBetaProbeResponse -def _login(client: TestClient) -> str: +def _login(client: TestClient) -> tuple[str, dict[str, str]]: response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'}) - return response.json()['access_token'] + token = response.json()['access_token'] + return token, {'Authorization': f'Bearer {token}'} -def test_swos_probe_endpoint(monkeypatch, tmp_path): - monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_probe.db"}') - monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data')) - monkeypatch.setenv('SECRET_KEY', 'test-secret') - monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin') - monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin') - - from app.api.routes import swos_beta - - monkeypatch.setattr( - swos_beta.swos_beta_service, - 'probe', - lambda payload: SwosBetaProbeResponse( - success=True, - base_url='http://192.168.88.1', - status_code=200, - auth_mode='digest', - page_title='SwOS', - content_type='text/html', - server='MikroTik', - save_backup_visible=True, - backup_endpoint_ok=True, - note='beta', - ), - ) +def test_switchos_list_marks_global_credentials_usage(monkeypatch, tmp_path): + from app.api.routes import settings as settings_route with TestClient(app) as client: - token = _login(client) - response = client.post( - '/api/swos-beta/probe', - json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, - headers={'Authorization': f'Bearer {token}'}, + _, headers = _login(client) + settings_response = client.put( + '/api/settings', + json={ + 'backup_retention_days': 7, + 'log_retention_days': 7, + 'export_cron': '', + 'binary_cron': '', + 'retention_cron': '', + 'enable_auto_export': False, + 'connection_test_interval_minutes': 0, + 'global_ssh_key': None, + 'default_switchos_username': 'sw-admin', + 'default_switchos_password': 'sw-pass', + 'pushover_token': None, + 'pushover_userkey': None, + 'notify_failures_only': True, + 'smtp_host': None, + 'smtp_port': 587, + 'smtp_login': None, + 'smtp_password': None, + 'smtp_notifications_enabled': False, + 'recipient_email': None, + 'clear_global_ssh_key': False, + }, + headers=headers, ) - assert response.status_code == 200 - assert response.json()['backup_endpoint_ok'] is True + assert settings_response.status_code == 200 + assert settings_response.json()['has_default_switchos_credentials'] is True + + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch01', + 'device_type': 'switchos', + 'host': '192.168.88.2', + 'port': 80, + 'ssh_user': '', + 'ssh_password': '', + 'ssh_key': None, + }, + headers=headers, + ) + assert create_response.status_code == 200 + + list_response = client.get('/api/routers', headers=headers) + assert list_response.status_code == 200 + payload = next(item for item in list_response.json() if item['name'] == 'switch01') + assert payload['device_type'] == 'switchos' + assert payload['uses_global_switchos_credentials'] is True + assert payload['effective_username'] == 'sw-admin' -def test_swos_download_endpoint(monkeypatch, tmp_path): - monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_download.db"}') - monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data')) - monkeypatch.setenv('SECRET_KEY', 'test-secret') - monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin') - monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin') - - from app.api.routes import swos_beta - - class FakeBackup: - filename = 'switch.swb' - content = b'binary-data' - content_type = 'application/octet-stream' - - monkeypatch.setattr(swos_beta.swos_beta_service, 'download_backup', lambda payload: FakeBackup()) +def test_switchos_connection_probe_is_exposed_in_device_route(monkeypatch): + from app.api.routes import routers as routers_route with TestClient(app) as client: - token = _login(client) - response = client.post( - '/api/swos-beta/download', - json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, - headers={'Authorization': f'Bearer {token}'}, + _, headers = _login(client) + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch02', + 'device_type': 'switchos', + 'host': '192.168.88.3', + 'port': 80, + 'ssh_user': 'admin', + 'ssh_password': 'secret', + 'ssh_key': None, + }, + headers=headers, ) + device_id = create_response.json()['id'] + + monkeypatch.setattr( + routers_route.router_service, + 'test_connection', + lambda db, router, global_settings: { + 'success': True, + 'tested_at': '2026-04-13T10:00:00', + 'model': 'SwitchOS', + 'uptime': 'HTTP 200', + 'hostname': 'MikroTik SwitchOS', + 'version': None, + 'error': None, + 'transport': 'http', + 'server': 'MikroTik', + 'auth_mode': 'digest', + 'http_status': '200', + 'backup_available': True, + }, + ) + + response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers) assert response.status_code == 200 - assert response.content == b'binary-data' - assert 'attachment; filename="switch.swb"' == response.headers['content-disposition'] + assert response.json()['transport'] == 'http' + assert response.json()['backup_available'] is True + + +def test_switchos_binary_backup_is_saved_as_swb(monkeypatch, tmp_path): + from app.services import backup_service as backup_service_module + from app.services import router_service as router_service_module + + data_dir = tmp_path / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(backup_service_module, 'ensure_data_dir', lambda: data_dir) + + def fake_binary_backup(router, backup_name, local_path, global_ssh_key=None, global_settings=None): + Path(local_path).write_bytes(b'switchos-binary') + return local_path + + monkeypatch.setattr(router_service_module.router_service, 'binary_backup', fake_binary_backup) + + with TestClient(app) as client: + _, headers = _login(client) + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch03', + 'device_type': 'switchos', + 'host': '192.168.88.4', + 'port': 80, + 'ssh_user': 'admin', + 'ssh_password': 'secret', + 'ssh_key': None, + }, + headers=headers, + ) + device_id = create_response.json()['id'] + + backup_response = client.post(f'/api/backups/router/{device_id}/binary', headers=headers) + assert backup_response.status_code == 200 + assert backup_response.json()['backup_type'] == 'binary' + assert backup_response.json()['file_name'].endswith('.swb') diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 3a65368..9a6d2ef 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -56,10 +56,9 @@ export class AppComponent { { label: 'nav.routers', link: '/routers', icon: 'pi pi-server', exact: false }, { label: 'nav.files', link: '/files', icon: 'pi pi-folder-open', exact: false }, { label: 'nav.diffConfigs', link: '/diff-configs', icon: 'pi pi-code', exact: false }, - { label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false }, { label: 'nav.logs', link: '/logs', icon: 'pi pi-history', exact: false }, - { label: 'nav.switchosBeta', link: '/switchos-beta', icon: 'pi pi-sitemap', exact: false }, - { label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false } + { label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false }, + { label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false } ]; get currentPageTitle(): string { @@ -133,10 +132,6 @@ export class AppComponent { this.pageLabel = 'logs.title'; return; } - if (url.startsWith('/switchos-beta')) { - this.pageLabel = 'switchosBeta.title'; - return; - } if (url.startsWith('/change-password')) { this.pageLabel = 'auth.changePassword'; return; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index f427a65..b516eda 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -11,7 +11,6 @@ import { LogsPageComponent } from './features/logs/logs-page.component'; import { RouterDetailPageComponent } from './features/routers/router-detail-page.component'; import { RoutersPageComponent } from './features/routers/routers-page.component'; import { SettingsPageComponent } from './features/settings/settings-page.component'; -import { SwosBetaPageComponent } from './features/swos-beta/swos-beta-page.component'; export const routes: Routes = [ { path: 'login', component: LoginPageComponent }, @@ -24,6 +23,5 @@ export const routes: Routes = [ { path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent }, { path: 'settings', canActivate: [authGuard], component: SettingsPageComponent }, { path: 'logs', canActivate: [authGuard], component: LogsPageComponent }, - { path: 'switchos-beta', canActivate: [authGuard], component: SwosBetaPageComponent }, { path: '**', redirectTo: '' } ]; diff --git a/frontend/src/app/features/files/files-page.component.html b/frontend/src/app/features/files/files-page.component.html index 93bf98f..62942d9 100644 --- a/frontend/src/app/features/files/files-page.component.html +++ b/frontend/src/app/features/files/files-page.component.html @@ -104,7 +104,7 @@
{{ item.router_name || item.router_id }}
- ID {{ item.router_id }} + {{ deviceLabel(item) }} · ID {{ item.router_id }} @@ -113,7 +113,7 @@
{{ formatBytes(item.file_size) }}
- {{ item.backup_type === 'export' ? '.rsc' : '.backup' }} + {{ fileExtension(item) }}
@@ -130,7 +130,7 @@ - +
diff --git a/frontend/src/app/features/files/files-page.component.ts b/frontend/src/app/features/files/files-page.component.ts index 3cc51a9..2ce7046 100644 --- a/frontend/src/app/features/files/files-page.component.ts +++ b/frontend/src/app/features/files/files-page.component.ts @@ -16,10 +16,13 @@ import { PageHeaderComponent } from '../../shared/ui/page-header.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component'; import { StatCardComponent } from '../../shared/ui/stat-card.component'; +type DeviceType = 'routeros' | 'switchos'; + interface BackupFile { id: number; router_id: number; router_name?: string; + device_type: DeviceType; file_name: string; backup_type: 'export' | 'binary'; created_at: string; @@ -233,6 +236,9 @@ export class FilesPageComponent implements OnInit { } upload(item: BackupFile) { + if (item.device_type !== 'routeros') { + return; + } this.api.http.post(`${this.api.baseUrl}/backups/router/${item.router_id}/upload/${item.id}`, {}).subscribe(() => { this.ui.success('toast.binaryUploaded'); }); @@ -410,6 +416,15 @@ export class FilesPageComponent implements OnInit { return `${value.slice(0, 8)}…${value.slice(-6)}`; } + deviceLabel(item: BackupFile): string { + return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros'); + } + + fileExtension(item: BackupFile): string { + const parts = item.file_name.split('.'); + return parts.length > 1 ? `.${parts[parts.length - 1]}` : '—'; + } + private setComparePair(firstId: number, secondId: number) { const [left, right] = this.sortPairByDate(firstId, secondId); this.compareLeftId = left; 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 9805278..fa1d7c0 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.html +++ b/frontend/src/app/features/routers/router-detail-page.component.html @@ -1,21 +1,21 @@
- - + +
- + - +
@@ -28,6 +28,10 @@
{{ 'routers.model' | translate }}{{ connection.model }}
{{ 'routers.version' | translate }}{{ connection.version || 'n/a' }}
{{ 'routers.uptime' | translate }}{{ connection.uptime }}
+
{{ 'routers.httpStatus' | translate }}{{ connection.http_status || '—' }}
+
{{ 'routers.serverHeader' | translate }}{{ connection.server || '—' }}
+
{{ 'routers.authMode' | translate }}{{ connection.auth_mode || '—' }}
+
{{ 'routers.backupEndpoint' | translate }}{{ connection.backup_available ? ('routers.backupAvailable' | translate) : ('routers.backupUnavailable' | translate) }}
{{ 'routers.lastError' | translate }} @@ -42,7 +46,7 @@ -
+
@@ -81,7 +85,7 @@
-
+
@@ -107,7 +111,9 @@ +
+
@@ -123,7 +129,7 @@
- +
diff --git a/frontend/src/app/features/routers/router-detail-page.component.ts b/frontend/src/app/features/routers/router-detail-page.component.ts index 1377fba..2f4a9ac 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.ts +++ b/frontend/src/app/features/routers/router-detail-page.component.ts @@ -14,11 +14,37 @@ import { PageHeaderComponent } from '../../shared/ui/page-header.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component'; import { StatCardComponent } from '../../shared/ui/stat-card.component'; +type DeviceType = 'routeros' | 'switchos'; + +interface DeviceItem { + id: number; + name: string; + host: string; + port: number; + device_type: DeviceType; + effective_username?: string | null; + supports_export: boolean; + supports_restore_upload: boolean; + last_connection_status?: boolean | null; + last_connection_tested_at?: string | null; + last_connection_error?: string | null; + last_connection_hostname?: string | null; + last_connection_model?: string | null; + last_connection_version?: string | null; + last_connection_uptime?: string | null; + last_connection_transport?: string | null; + last_connection_server?: string | null; + last_connection_auth_mode?: string | null; + last_connection_http_status?: string | null; + last_connection_backup_available?: boolean | null; +} + interface BackupItem { id: number; file_name: string; backup_type: 'export' | 'binary'; created_at: string; + device_type: DeviceType; } interface ConnectionSnapshot { @@ -29,6 +55,11 @@ interface ConnectionSnapshot { version?: string | null; uptime: string; error?: string | null; + transport?: string | null; + server?: string | null; + auth_mode?: string | null; + http_status?: string | null; + backup_available?: boolean | null; } interface BackupDiffStats { @@ -59,7 +90,7 @@ export class RouterDetailPageComponent implements OnInit { private readonly ui = inject(UiService); routerId!: number; - routerItem: any; + routerItem: DeviceItem | null = null; backups: BackupItem[] = []; connection: ConnectionSnapshot | null = null; exportContent = ''; @@ -73,6 +104,10 @@ export class RouterDetailPageComponent implements OnInit { testing = false; deletingRouter = false; + get isSwitchos(): boolean { + return this.routerItem?.device_type === 'switchos'; + } + get exportBackups(): BackupItem[] { return this.backups.filter((item) => item.backup_type === 'export'); } @@ -96,13 +131,25 @@ export class RouterDetailPageComponent implements OnInit { return !!this.diffText; } + get subtitle(): string { + if (!this.routerItem) { + return this.ui.instant('routers.detailSubtitle'); + } + const suffix = this.routerItem.effective_username ? ` · ${this.routerItem.effective_username}` : ''; + return `${this.routerItem.host}:${this.routerItem.port}${suffix}`; + } + + get deviceTypeLabel(): string { + return this.ui.instant(this.isSwitchos ? 'routers.switchos' : 'routers.routeros'); + } + ngOnInit() { this.routerId = Number(this.route.snapshot.paramMap.get('id')); this.load(); } load() { - this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem: any) => { + this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => { this.routerItem = routerItem; this.connection = this.mapStoredConnection(routerItem); }); @@ -110,7 +157,7 @@ export class RouterDetailPageComponent implements OnInit { } runExport() { - if (this.exporting) { + if (this.exporting || this.isSwitchos) { return; } this.exporting = true; @@ -187,6 +234,9 @@ export class RouterDetailPageComponent implements OnInit { } upload(id: number) { + if (this.isSwitchos) { + return; + } this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/upload/${id}`, {}).subscribe(() => { this.ui.success('toast.binaryUploaded'); }); @@ -217,7 +267,7 @@ export class RouterDetailPageComponent implements OnInit { viewExport(id: number) { const backup = this.exportBackups.find((item) => item.id === id); - this.api.http.get(`${this.api.baseUrl}/backups/${id}/view`).subscribe((r) => { + this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${id}/view`).subscribe((r) => { this.exportContent = r.content; this.previewTitle = backup?.file_name || this.ui.instant('routers.previewTitle'); this.ui.clear(); @@ -241,7 +291,7 @@ export class RouterDetailPageComponent implements OnInit { this.diffVisible = true; } - private mapStoredConnection(routerItem: any): ConnectionSnapshot | null { + private mapStoredConnection(routerItem: DeviceItem): ConnectionSnapshot | null { if (!routerItem?.last_connection_tested_at) { return null; } @@ -252,7 +302,12 @@ export class RouterDetailPageComponent implements OnInit { model: routerItem.last_connection_model || 'Unknown', version: routerItem.last_connection_version, uptime: routerItem.last_connection_uptime || 'Unknown', - error: routerItem.last_connection_error || null + error: routerItem.last_connection_error || null, + transport: routerItem.last_connection_transport || null, + server: routerItem.last_connection_server || null, + auth_mode: routerItem.last_connection_auth_mode || null, + http_status: routerItem.last_connection_http_status || null, + backup_available: routerItem.last_connection_backup_available ?? null }; } @@ -269,6 +324,11 @@ export class RouterDetailPageComponent implements OnInit { last_connection_version: result.version, last_connection_uptime: result.uptime, last_connection_error: result.error, + last_connection_transport: result.transport, + last_connection_server: result.server, + last_connection_auth_mode: result.auth_mode, + last_connection_http_status: result.http_status, + last_connection_backup_available: result.backup_available }; } diff --git a/frontend/src/app/features/routers/routers-page.component.html b/frontend/src/app/features/routers/routers-page.component.html index 7c84f51..581130c 100644 --- a/frontend/src/app/features/routers/routers-page.component.html +++ b/frontend/src/app/features/routers/routers-page.component.html @@ -11,13 +11,13 @@
- {{ keyCount }} - {{ 'routers.summaryKeyAccess' | translate }} + {{ routerOsCount }} + {{ 'routers.routeros' | translate }}
- {{ passwordCount }} - {{ 'routers.summaryPasswordAccess' | translate }} + {{ switchOsCount }} + {{ 'routers.switchos' | translate }}
@@ -30,16 +30,16 @@
{{ routerItem.name }}
- {{ 'routers.routerOsTarget' | translate }} + {{ deviceTypeLabel(routerItem) }}
{{ routerItem.host }}:{{ routerItem.port }}
- {{ routerItem.ssh_user }} + {{ accessUser(routerItem) }}
- - + +
@@ -54,33 +54,101 @@
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + +
+
+ +
+
+
+ {{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }} +
+
{{ dialogTitle }}
+ + {{ + selectedDeviceType === 'switchos' + ? ('routers.switchDialogSubtitle' | translate) + : ('routers.routerDialogSubtitle' | translate) + }} + +
+
+
+ + +
+
+
+ {{ 'routers.connectionSectionTitle' | translate }} +

{{ 'routers.connectionSectionHint' | translate }}

+
+ + + {{ selectedDeviceType === 'switchos' ? 'HTTP' : 'SSH' }} + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ +
+
+
+ {{ 'routers.credentialsSectionTitle' | translate }} +

+ {{ + selectedDeviceType === 'switchos' + ? ('routers.switchDialogSubtitle' | translate) + : ('routers.routerDialogSubtitle' | translate) + }} +

+
+ + + {{ selectedDeviceType === 'switchos' ? ('routers.defaultCredentials' | translate) : 'SSH' }} + +
+ +
+ + + + + + + + + + + + +
+ +
+ + {{ 'routers.switchDefaultsHint' | translate }} +
+
+ +
diff --git a/frontend/src/app/features/routers/routers-page.component.ts b/frontend/src/app/features/routers/routers-page.component.ts index 6d1f2a5..acd86ba 100644 --- a/frontend/src/app/features/routers/routers-page.component.ts +++ b/frontend/src/app/features/routers/routers-page.component.ts @@ -5,6 +5,7 @@ import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; +import { DropdownModule } from 'primeng/dropdown'; import { InputTextareaModule } from 'primeng/inputtextarea'; import { InputTextModule } from 'primeng/inputtext'; import { TableModule } from 'primeng/table'; @@ -15,21 +16,40 @@ import { UiService } from '../../core/services/ui.service'; import { PageHeaderComponent } from '../../shared/ui/page-header.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component'; +type DeviceType = 'routeros' | 'switchos'; + interface RouterItem { id: number; name: string; host: string; port: number; - ssh_user: string; - ssh_password?: string; - ssh_key?: string; + device_type: DeviceType; + ssh_user?: string | null; + ssh_password?: string | null; + ssh_key?: string | null; + effective_username?: string | null; uses_global_ssh_key?: boolean; has_effective_ssh_key?: boolean; + uses_global_switchos_credentials?: boolean; + has_effective_password?: boolean; } @Component({ standalone: true, - imports: [CommonModule, ReactiveFormsModule, TranslateModule, ButtonModule, DialogModule, InputTextModule, InputTextareaModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + ButtonModule, + DialogModule, + DropdownModule, + InputTextModule, + InputTextareaModule, + TableModule, + TagModule, + PageHeaderComponent, + SectionCardComponent + ], templateUrl: './routers-page.component.html' }) export class RoutersPageComponent implements OnInit { @@ -42,11 +62,16 @@ export class RoutersPageComponent implements OnInit { editingId: number | null = null; saving = false; routers: RouterItem[] = []; + readonly deviceTypeOptions = [ + { label: 'RouterOS', value: 'routeros' }, + { label: 'SwitchOS', value: 'switchos' } + ]; readonly form = this.fb.nonNullable.group({ name: ['', Validators.required], + device_type: ['routeros' as DeviceType, Validators.required], host: ['', Validators.required], port: [22, Validators.required], - ssh_user: ['admin', Validators.required], + ssh_user: ['admin'], ssh_password: '', ssh_key: '' }); @@ -55,24 +80,22 @@ export class RoutersPageComponent implements OnInit { return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle'); } - get passwordCount(): number { - return this.routers.filter((item) => !!item.ssh_password).length; + get selectedDeviceType(): DeviceType { + return this.form.controls.device_type.value; } - get keyCount(): number { - return this.routers.filter((item) => this.hasEffectiveSshKey(item)).length; + get routerOsCount(): number { + return this.routers.filter((item) => item.device_type === 'routeros').length; } - hasEffectiveSshKey(item: RouterItem): boolean { - return !!item.has_effective_ssh_key; + get switchOsCount(): number { + return this.routers.filter((item) => item.device_type === 'switchos').length; } - usesGlobalSshKey(item: RouterItem): boolean { - return !!item.uses_global_ssh_key; - } - - ngOnInit() { + this.form.controls.device_type.valueChanges.subscribe((deviceType) => { + this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType); + }); this.load(); } @@ -82,7 +105,7 @@ export class RoutersPageComponent implements OnInit { openCreate() { this.editingId = null; - this.form.reset({ name: '', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' }); + this.form.reset({ name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' }); this.visible = true; } @@ -90,9 +113,10 @@ export class RoutersPageComponent implements OnInit { this.editingId = item.id; this.form.reset({ name: item.name, + device_type: item.device_type, host: item.host, port: item.port, - ssh_user: item.ssh_user, + ssh_user: item.ssh_user ?? '', ssh_password: item.ssh_password ?? '', ssh_key: item.ssh_key ?? '' }); @@ -104,9 +128,13 @@ export class RoutersPageComponent implements OnInit { return; } this.saving = true; + const payload = this.form.getRawValue(); + if (payload.device_type === 'switchos') { + payload.ssh_key = ''; + } const request$ = this.editingId - ? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, this.form.getRawValue()) - : this.api.http.post(`${this.api.baseUrl}/routers`, this.form.getRawValue()); + ? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, payload) + : this.api.http.post(`${this.api.baseUrl}/routers`, payload); request$.subscribe({ next: () => { @@ -134,8 +162,56 @@ export class RoutersPageComponent implements OnInit { }); } - open(id: number) { this.router.navigate(['/routers', id]); } + + deviceTypeLabel(item: RouterItem): string { + return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros'); + } + + accessUser(item: RouterItem): string { + return item.effective_username || item.ssh_user || '—'; + } + + primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warning' | 'secondary' | 'info' } { + if (item.device_type === 'switchos') { + if (item.uses_global_switchos_credentials) { + return { value: this.ui.instant('routers.defaultCredentials'), severity: 'info' }; + } + if (item.has_effective_password) { + return { value: this.ui.instant('routers.localCredentials'), severity: 'success' }; + } + return { value: this.ui.instant('routers.noCredentials'), severity: 'secondary' }; + } + + return { + value: item.ssh_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'), + severity: item.ssh_password ? 'warning' : 'secondary' + }; + } + + secondaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warning' | 'secondary' | 'info' } { + if (item.device_type === 'switchos') { + return { + value: item.has_effective_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'), + severity: item.has_effective_password ? 'warning' : 'secondary' + }; + } + + return { + value: item.has_effective_ssh_key + ? this.ui.instant(item.uses_global_ssh_key ? 'routers.globalKeyMode' : 'routers.keyMode') + : this.ui.instant('routers.noKey'), + severity: item.has_effective_ssh_key ? 'success' : 'secondary' + }; + } + + private applyDeviceDefaults(deviceType: DeviceType) { + if (deviceType === 'switchos') { + this.form.patchValue({ port: 80, ssh_key: '', ssh_user: this.form.controls.ssh_user.value || '' }, { emitEvent: false }); + return; + } + this.form.patchValue({ port: 22, ssh_user: this.form.controls.ssh_user.value || 'admin' }, { emitEvent: false }); + } } diff --git a/frontend/src/app/features/settings/settings-page.component.html b/frontend/src/app/features/settings/settings-page.component.html index 283f6c1..ae22d94 100644 --- a/frontend/src/app/features/settings/settings-page.component.html +++ b/frontend/src/app/features/settings/settings-page.component.html @@ -253,7 +253,7 @@
-
+
{{ 'settings.sshDefaultsTitle' | translate }} {{ 'settings.sshDefaultsSubtitle' | translate }} @@ -297,6 +297,25 @@ {{ 'settings.sshKeyClearNotice' | translate }}
+ +
+
+
+ {{ 'settings.switchosDefaultsTitle' | translate }} +

{{ 'settings.switchosDefaultsHint' | translate }}

+
+
+
+ + + + + + + + +
+
diff --git a/frontend/src/app/features/settings/settings-page.component.ts b/frontend/src/app/features/settings/settings-page.component.ts index 3a02351..8491e2b 100644 --- a/frontend/src/app/features/settings/settings-page.component.ts +++ b/frontend/src/app/features/settings/settings-page.component.ts @@ -55,6 +55,9 @@ interface SettingsResponse { connection_test_interval_minutes: number; global_ssh_key: string | null; has_global_ssh_key: boolean; + default_switchos_username: string | null; + default_switchos_password: string | null; + has_default_switchos_credentials: boolean; pushover_token: string | null; pushover_userkey: string | null; notify_failures_only: boolean; @@ -104,6 +107,8 @@ export class SettingsPageComponent implements OnInit, OnDestroy { enable_auto_export: false, connection_test_interval_minutes: [0, Validators.min(0)], global_ssh_key: '', + default_switchos_username: '', + default_switchos_password: '', pushover_token: '', pushover_userkey: '', notify_failures_only: true, @@ -376,6 +381,8 @@ export class SettingsPageComponent implements OnInit, OnDestroy { enable_auto_export: response.enable_auto_export, connection_test_interval_minutes: Number(response.connection_test_interval_minutes || 0), global_ssh_key: '', + default_switchos_username: response.default_switchos_username || '', + default_switchos_password: response.default_switchos_password || '', pushover_token: response.pushover_token || '', pushover_userkey: response.pushover_userkey || '', notify_failures_only: response.notify_failures_only, @@ -404,6 +411,8 @@ export class SettingsPageComponent implements OnInit, OnDestroy { enable_auto_export: Boolean(raw.enable_auto_export), connection_test_interval_minutes: Number(raw.connection_test_interval_minutes || 0), global_ssh_key: normalizedKey || null, + default_switchos_username: this.normalizeOptionalText(raw.default_switchos_username), + default_switchos_password: this.normalizeOptionalText(raw.default_switchos_password), pushover_token: this.normalizeOptionalText(raw.pushover_token), pushover_userkey: this.normalizeOptionalText(raw.pushover_userkey), notify_failures_only: Boolean(raw.notify_failures_only), diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 457a187..e9e942c 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -35,7 +35,7 @@ }, "nav": { "dashboard": "Dashboard", - "routers": "Routers", + "routers": "Devices", "files": "Repository", "settings": "Settings", "logs": "Logs", @@ -81,7 +81,7 @@ "subtitle": "Overview of backups, exports and operational activity in one place.", "exportAll": "Export all", "binaryAll": "Binary backup", - "managedRouters": "Routers", + "managedRouters": "Devices", "managedRoutersHint": "All managed devices", "inventoryTag": "Fleet", "exportsCard": "Exports", @@ -134,14 +134,14 @@ "storageSnapshotHint": "Quick snapshot of the most important storage and backup indicators." }, "routers": { - "title": "Routers", - "detailTitle": "Router details", - "add": "Add router", + "title": "Devices", + "detailTitle": "Device details", + "add": "Add device", "eyebrow": "device inventory", - "subtitle": "Manage RouterOS endpoints, credentials and fleet-wide backup jobs.", + "subtitle": "Manage RouterOS and SwitchOS devices plus their backups.", "registeredDevices": "Registered devices", "fleetTag": "Fleet", - "sshPassword": "SSH password", + "sshPassword": "Password", "passwordHint": "Password-based access", "credsTag": "Creds", "sshKey": "SSH key", @@ -150,8 +150,8 @@ "defaultPort": "Port 22", "defaultPortHint": "Standard SSH endpoints", "portTag": "Port", - "listTitle": "Router list", - "listSubtitle": "Compact operational view of every managed device.", + "listTitle": "Device list", + "listSubtitle": "Unified view for RouterOS and SwitchOS devices.", "name": "Name", "endpoint": "Endpoint", "access": "Access", @@ -161,15 +161,15 @@ "keyMode": "Key", "globalKeyMode": "Global key", "noKey": "No key", - "createDialogTitle": "Add router", - "editDialogTitle": "Edit router", + "createDialogTitle": "Add device", + "editDialogTitle": "Edit device", "host": "Host", "port": "Port", - "sshUser": "SSH user", + "sshUser": "Username", "sshPrivateKey": "SSH private key", "optionalPassword": "Optional password", "optionalPrivateKey": "Optional private key", - "saveRouter": "Save router", + "saveRouter": "Save device", "profileEyebrow": "router profile", "detailSubtitle": "Device operations and backup history", "exportOne": "Export", @@ -184,7 +184,7 @@ "connectionLabelHint": "Status from the latest automatic or manual connection test", "probeTag": "Probe", "accessTag": "Access", - "sshUserHint": "Current SSH user", + "sshUserHint": "Effective device login", "deviceStatusTitle": "Device status", "deviceStatusSubtitle": "Stored metadata from the latest automatic or manual connection test.", "hostname": "Hostname", @@ -200,7 +200,7 @@ "exportsTableTitle": "Exports", "exportsTableSubtitle": "Readable RouterOS snapshots.", "binaryTableTitle": "Binary backups", - "binaryTableSubtitle": "Files ready for device restore.", + "binaryTableSubtitle": "Binary files and SwitchOS backups.", "summaryKeyAccess": "with key-based access", "summaryPasswordAccess": "with password access", "connectionStateTitle": "Connection state", @@ -211,7 +211,28 @@ "openPreviewModal": "Open preview", "diffModalHint": "The last loaded diff is available in a modal.", "openDiffModal": "Open diff", - "noDiff": "Choose an export and run a diff to see the latest comparison." + "noDiff": "Choose an export and run a diff to see the latest comparison.", + "routeros": "RouterOS", + "switchos": "SwitchOS", + "deviceType": "Device type", + "defaultCredentials": "Default credentials", + "localCredentials": "Local credentials", + "noCredentials": "No credentials", + "switchUserPlaceholder": "Empty = use settings default", + "switchPasswordPlaceholder": "Empty = use settings default", + "switchDefaultsHint": "For SwitchOS you can leave username and password empty to use the defaults from settings.", + "downloadSwitchBackup": "Download backup", + "httpStatus": "HTTP status", + "serverHeader": "Server header", + "authMode": "Auth mode", + "backupEndpoint": "Backup endpoint", + "backupAvailable": "Available", + "backupUnavailable": "Unavailable", + "connectionSectionTitle": "Connection profile", + "connectionSectionHint": "Basic device identity and endpoint used to reach it.", + "credentialsSectionTitle": "Access and credentials", + "routerDialogSubtitle": "Set the device endpoint, SSH access data and your preferred login method.", + "switchDialogSubtitle": "Set the SwitchOS endpoint and optional local or shared credentials from settings." }, "files": { "title": "Repository", @@ -233,14 +254,14 @@ "searchLabel": "Search", "searchPlaceholder": "Search by file or router", "typeLabel": "Type", - "routerLabel": "Router", + "routerLabel": "Device", "sortLabel": "Sort by", "orderLabel": "Order", "allTypes": "All types", - "allRouters": "All routers", + "allRouters": "All devices", "sortNewest": "Newest", "sortName": "Name", - "sortRouter": "Router", + "sortRouter": "Device", "sortType": "Type", "tableTitle": "Repository table", "tableSubtitle": "Artifacts available for download, e-mail and restore.", @@ -248,7 +269,7 @@ "compareSelected": "Compare selected exports", "fileColumn": "File", "typeColumn": "Type", - "routerColumn": "Router", + "routerColumn": "Device", "createdColumn": "Created", "actionsColumn": "Actions", "checksum": "Checksum", @@ -311,8 +332,8 @@ "pushoverUserKey": "Pushover user key", "pushoverTokenPlaceholder": "Application token", "pushoverUserKeyPlaceholder": "User key", - "sshDefaultsTitle": "SSH defaults", - "sshDefaultsSubtitle": "Optional shared private key used across managed routers.", + "sshDefaultsTitle": "Default Credentials", + "sshDefaultsSubtitle": "Shared SSH key and default SwitchOS login used across managed devices.", "globalSshPrivateKey": "Global SSH private key", "globalSshPrivateKeyPlaceholder": "Paste PEM or OpenSSH private key", "save": "Save settings", @@ -377,7 +398,11 @@ "interfacePreferencesHint": "Choose the default language and font family for the whole application.", "interfacePreferencesTag": "Per-user", "fontFamily": "Font family", - "fontDefault": "Default" + "fontDefault": "Default", + "switchosDefaultsTitle": "Default SwitchOS credentials", + "switchosDefaultsHint": "Used when a SwitchOS device has no local username or password.", + "defaultSwitchosUsername": "Default SwitchOS username", + "defaultSwitchosPassword": "Default SwitchOS password" }, "logs": { "title": "Logs", diff --git a/frontend/src/assets/i18n/es.json b/frontend/src/assets/i18n/es.json index 47b261c..3d7e6c5 100644 --- a/frontend/src/assets/i18n/es.json +++ b/frontend/src/assets/i18n/es.json @@ -35,7 +35,7 @@ }, "nav": { "dashboard": "Panel", - "routers": "Routers", + "routers": "Dispositivos", "files": "Repositorio", "settings": "Ajustes", "logs": "Registros", @@ -81,7 +81,7 @@ "subtitle": "Resumen de copias, exportaciones y actividad operativa en un solo lugar.", "exportAll": "Exportar todo", "binaryAll": "Copia binaria", - "managedRouters": "Routers", + "managedRouters": "Dispositivos", "managedRoutersHint": "Todos los dispositivos gestionados", "inventoryTag": "Flota", "exportsCard": "Exportaciones", @@ -134,14 +134,14 @@ "storageSnapshotHint": "Vista rápida de los indicadores más importantes de almacenamiento y copias." }, "routers": { - "title": "Routers", - "detailTitle": "Detalles del router", - "add": "Añadir router", + "title": "Dispositivos", + "detailTitle": "Detalles del dispositivo", + "add": "Agregar dispositivo", "eyebrow": "inventario de dispositivos", - "subtitle": "Gestiona endpoints de RouterOS, credenciales y tareas de copia para toda la flota.", + "subtitle": "Administra dispositivos RouterOS y SwitchOS y sus copias.", "registeredDevices": "Dispositivos registrados", "fleetTag": "Flota", - "sshPassword": "Contraseña SSH", + "sshPassword": "Contraseña", "passwordHint": "Acceso con contraseña", "credsTag": "Credenciales", "sshKey": "Clave SSH", @@ -150,8 +150,8 @@ "defaultPort": "Puerto 22", "defaultPortHint": "Endpoints SSH estándar", "portTag": "Puerto", - "listTitle": "Lista de routers", - "listSubtitle": "Vista operativa compacta de todos los dispositivos gestionados.", + "listTitle": "Lista de dispositivos", + "listSubtitle": "Vista unificada para RouterOS y SwitchOS.", "name": "Nombre", "endpoint": "Endpoint", "access": "Acceso", @@ -161,15 +161,15 @@ "keyMode": "Clave", "globalKeyMode": "Clave global", "noKey": "Sin clave", - "createDialogTitle": "Añadir router", - "editDialogTitle": "Editar router", + "createDialogTitle": "Agregar dispositivo", + "editDialogTitle": "Editar dispositivo", "host": "Host", "port": "Puerto", - "sshUser": "Usuario SSH", + "sshUser": "Usuario", "sshPrivateKey": "Clave privada SSH", "optionalPassword": "Contraseña opcional", "optionalPrivateKey": "Clave privada opcional", - "saveRouter": "Guardar router", + "saveRouter": "Guardar dispositivo", "profileEyebrow": "perfil del router", "detailSubtitle": "Operaciones del dispositivo e historial de copias", "exportOne": "Exportar", @@ -211,7 +211,28 @@ "openPreviewModal": "Abrir vista previa", "diffModalHint": "El último diff cargado está disponible en un modal.", "openDiffModal": "Abrir diff", - "noDiff": "Elige una exportación y ejecuta un diff para ver la última comparación." + "noDiff": "Elige una exportación y ejecuta un diff para ver la última comparación.", + "routeros": "RouterOS", + "switchos": "SwitchOS", + "deviceType": "Tipo de dispositivo", + "defaultCredentials": "Credenciales por defecto", + "localCredentials": "Credenciales locales", + "noCredentials": "Sin credenciales", + "switchUserPlaceholder": "Vacío = usar ajustes", + "switchPasswordPlaceholder": "Vacío = usar ajustes", + "switchDefaultsHint": "Para SwitchOS puedes dejar usuario y contraseña vacíos para usar los valores por defecto.", + "downloadSwitchBackup": "Descargar copia", + "httpStatus": "Estado HTTP", + "serverHeader": "Cabecera Server", + "authMode": "Modo de autenticación", + "backupEndpoint": "Endpoint de copia", + "backupAvailable": "Disponible", + "backupUnavailable": "No disponible", + "connectionSectionTitle": "Perfil de conexión", + "connectionSectionHint": "Identidad básica del dispositivo y endpoint usado para alcanzarlo.", + "credentialsSectionTitle": "Acceso y credenciales", + "routerDialogSubtitle": "Configura el endpoint del dispositivo, los datos SSH y el método de acceso preferido.", + "switchDialogSubtitle": "Configura el endpoint de SwitchOS y las credenciales locales u opcionales compartidas desde ajustes." }, "files": { "title": "Repositorio", @@ -233,14 +254,14 @@ "searchLabel": "Buscar", "searchPlaceholder": "Buscar por archivo o router", "typeLabel": "Tipo", - "routerLabel": "Router", + "routerLabel": "Dispositivo", "sortLabel": "Ordenar por", "orderLabel": "Orden", "allTypes": "Todos los tipos", - "allRouters": "Todos los routers", + "allRouters": "Todos los dispositivos", "sortNewest": "Más nuevo", "sortName": "Nombre", - "sortRouter": "Router", + "sortRouter": "Dispositivo", "sortType": "Tipo", "tableTitle": "Tabla del repositorio", "tableSubtitle": "Artefactos disponibles para descarga, correo y restauración.", @@ -248,7 +269,7 @@ "compareSelected": "Comparar exportaciones seleccionadas", "fileColumn": "Archivo", "typeColumn": "Tipo", - "routerColumn": "Router", + "routerColumn": "Dispositivo", "createdColumn": "Creado", "actionsColumn": "Acciones", "checksum": "Checksum", @@ -311,8 +332,8 @@ "pushoverUserKey": "Clave de usuario de Pushover", "pushoverTokenPlaceholder": "Token de la aplicación", "pushoverUserKeyPlaceholder": "Clave de usuario", - "sshDefaultsTitle": "Valores SSH por defecto", - "sshDefaultsSubtitle": "Clave privada compartida opcional usada en todos los routers gestionados.", + "sshDefaultsTitle": "Credenciales predeterminadas", + "sshDefaultsSubtitle": "Clave SSH compartida y acceso por defecto de SwitchOS usados por los dispositivos gestionados.", "globalSshPrivateKey": "Clave privada SSH global", "globalSshPrivateKeyPlaceholder": "Pega la clave privada PEM u OpenSSH", "globalSshPrivateKeyHiddenPlaceholder": "La clave guardada está oculta. Introduce la contraseña arriba para verla o pega aquí una nueva clave para reemplazarla.", @@ -377,7 +398,11 @@ "interfacePreferencesHint": "Elige el idioma predeterminado y la familia tipográfica para toda la aplicación.", "interfacePreferencesTag": "Por usuario", "fontFamily": "Familia tipográfica", - "fontDefault": "Predeterminada" + "fontDefault": "Predeterminada", + "switchosDefaultsTitle": "Credenciales por defecto de SwitchOS", + "switchosDefaultsHint": "Se usan cuando un dispositivo SwitchOS no tiene usuario o contraseña local.", + "defaultSwitchosUsername": "Usuario SwitchOS por defecto", + "defaultSwitchosPassword": "Contraseña SwitchOS por defecto" }, "logs": { "title": "Registros", diff --git a/frontend/src/assets/i18n/no.json b/frontend/src/assets/i18n/no.json index 5c38170..9578d6b 100644 --- a/frontend/src/assets/i18n/no.json +++ b/frontend/src/assets/i18n/no.json @@ -35,7 +35,7 @@ }, "nav": { "dashboard": "Dashbord", - "routers": "Rutere", + "routers": "Enheter", "files": "Repository", "settings": "Innstillinger", "logs": "Logger", @@ -81,7 +81,7 @@ "subtitle": "Oversikt over backuper, eksportfiler og operativ aktivitet på ett sted.", "exportAll": "Eksporter alle", "binaryAll": "Binær backup", - "managedRouters": "Rutere", + "managedRouters": "Enheter", "managedRoutersHint": "Alle administrerte enheter", "inventoryTag": "Flåte", "exportsCard": "Eksporter", @@ -134,14 +134,14 @@ "storageSnapshotHint": "Rask oversikt over de viktigste lagrings- og backupindikatorene." }, "routers": { - "title": "Rutere", - "detailTitle": "Ruterdetaljer", - "add": "Legg til ruter", + "title": "Enheter", + "detailTitle": "Enhetsdetaljer", + "add": "Legg til enhet", "eyebrow": "enhetsinventar", - "subtitle": "Administrer RouterOS-endepunkter, legitimasjon og backupjobber for hele flåten.", + "subtitle": "Administrer RouterOS- og SwitchOS-enheter og sikkerhetskopier.", "registeredDevices": "Registrerte enheter", "fleetTag": "Flåte", - "sshPassword": "SSH-passord", + "sshPassword": "Passord", "passwordHint": "Passordbasert tilgang", "credsTag": "Tilgang", "sshKey": "SSH-nøkkel", @@ -150,8 +150,8 @@ "defaultPort": "Port 22", "defaultPortHint": "Standard SSH-endepunkter", "portTag": "Port", - "listTitle": "Ruterliste", - "listSubtitle": "Kompakt driftsvisning av alle administrerte enheter.", + "listTitle": "Enhetsliste", + "listSubtitle": "Felles visning for RouterOS og SwitchOS.", "name": "Navn", "endpoint": "Endepunkt", "access": "Tilgang", @@ -161,15 +161,15 @@ "keyMode": "Nøkkel", "globalKeyMode": "Global nøkkel", "noKey": "Ingen nøkkel", - "createDialogTitle": "Legg til ruter", - "editDialogTitle": "Rediger ruter", + "createDialogTitle": "Legg til enhet", + "editDialogTitle": "Rediger enhet", "host": "Vert", "port": "Port", - "sshUser": "SSH-bruker", + "sshUser": "Bruker", "sshPrivateKey": "SSH privat nøkkel", "optionalPassword": "Valgfritt passord", "optionalPrivateKey": "Valgfri privat nøkkel", - "saveRouter": "Lagre ruter", + "saveRouter": "Lagre enhet", "profileEyebrow": "ruterprofil", "detailSubtitle": "Enhetsoperasjoner og backuphistorikk", "exportOne": "Eksport", @@ -211,7 +211,28 @@ "openPreviewModal": "Åpne forhåndsvisning", "diffModalHint": "Sist lastede diff er tilgjengelig i en modal.", "openDiffModal": "Åpne diff", - "noDiff": "Velg en eksport og kjør diff for å se siste sammenligning." + "noDiff": "Velg en eksport og kjør diff for å se siste sammenligning.", + "routeros": "RouterOS", + "switchos": "SwitchOS", + "deviceType": "Enhetstype", + "defaultCredentials": "Standard legitimasjon", + "localCredentials": "Lokal legitimasjon", + "noCredentials": "Ingen legitimasjon", + "switchUserPlaceholder": "Tom = bruk innstillinger", + "switchPasswordPlaceholder": "Tom = bruk innstillinger", + "switchDefaultsHint": "For SwitchOS kan du la bruker og passord være tomme for å bruke standardverdier fra innstillinger.", + "downloadSwitchBackup": "Last ned backup", + "httpStatus": "HTTP-status", + "serverHeader": "Server-header", + "authMode": "Autentiseringsmodus", + "backupEndpoint": "Backup-endepunkt", + "backupAvailable": "Tilgjengelig", + "backupUnavailable": "Utilgjengelig", + "connectionSectionTitle": "Tilkoblingsprofil", + "connectionSectionHint": "Grunnleggende enhetsidentitet og endpoint som brukes for å nå den.", + "credentialsSectionTitle": "Tilgang og legitimasjon", + "routerDialogSubtitle": "Sett enhetens endpoint, SSH-data og foretrukket innloggingsmetode.", + "switchDialogSubtitle": "Sett SwitchOS-endpoint og valgfrie lokale eller delte standarddata fra innstillinger." }, "files": { "title": "Repository", @@ -233,14 +254,14 @@ "searchLabel": "Søk", "searchPlaceholder": "Søk etter fil eller ruter", "typeLabel": "Type", - "routerLabel": "Ruter", + "routerLabel": "Enhet", "sortLabel": "Sorter etter", "orderLabel": "Rekkefølge", "allTypes": "Alle typer", - "allRouters": "Alle rutere", + "allRouters": "Alle enheter", "sortNewest": "Nyeste", "sortName": "Navn", - "sortRouter": "Ruter", + "sortRouter": "Enhet", "sortType": "Type", "tableTitle": "Repositorytabell", "tableSubtitle": "Artefakter tilgjengelige for nedlasting, e-post og gjenoppretting.", @@ -248,7 +269,7 @@ "compareSelected": "Sammenlign valgte eksporter", "fileColumn": "Fil", "typeColumn": "Type", - "routerColumn": "Ruter", + "routerColumn": "Enhet", "createdColumn": "Opprettet", "actionsColumn": "Handlinger", "checksum": "Checksum", @@ -311,8 +332,8 @@ "pushoverUserKey": "Pushover-brukernøkkel", "pushoverTokenPlaceholder": "Applikasjonstoken", "pushoverUserKeyPlaceholder": "Brukernøkkel", - "sshDefaultsTitle": "SSH-standarder", - "sshDefaultsSubtitle": "Valgfri delt privat nøkkel som brukes på tvers av administrerte rutere.", + "sshDefaultsTitle": "Standard legitimasjon", + "sshDefaultsSubtitle": "Delt SSH-nøkkel og standard innlogging for SwitchOS brukt på administrerte enheter.", "globalSshPrivateKey": "Global SSH privat nøkkel", "globalSshPrivateKeyPlaceholder": "Lim inn PEM- eller OpenSSH-privat nøkkel", "globalSshPrivateKeyHiddenPlaceholder": "Den lagrede nøkkelen er skjult. Skriv inn passordet over for å se den, eller lim inn en ny nøkkel her for å erstatte den.", @@ -377,7 +398,11 @@ "interfacePreferencesHint": "Velg standardspråk og skriftfamilie for hele applikasjonen.", "interfacePreferencesTag": "Per bruker", "fontFamily": "Skriftfamilie", - "fontDefault": "Standard" + "fontDefault": "Standard", + "switchosDefaultsTitle": "Standard SwitchOS-legitimasjon", + "switchosDefaultsHint": "Brukes når en SwitchOS-enhet ikke har lokalt brukernavn eller passord.", + "defaultSwitchosUsername": "Standard SwitchOS-bruker", + "defaultSwitchosPassword": "Standard SwitchOS-passord" }, "logs": { "title": "Logger", diff --git a/frontend/src/assets/i18n/pl.json b/frontend/src/assets/i18n/pl.json index 5637a9b..b8e0811 100644 --- a/frontend/src/assets/i18n/pl.json +++ b/frontend/src/assets/i18n/pl.json @@ -35,7 +35,7 @@ }, "nav": { "dashboard": "Dashboard", - "routers": "Routery", + "routers": "Urządzenia", "files": "Repozytorium", "settings": "Ustawienia", "logs": "Logi", @@ -81,7 +81,7 @@ "subtitle": "Przegląd backupów, eksportów i aktywności operacyjnej w jednym miejscu.", "exportAll": "Eksportuj wszystko", "binaryAll": "Backup binarny", - "managedRouters": "Routery", + "managedRouters": "Urządzenia", "managedRoutersHint": "Wszystkie zarządzane urządzenia", "inventoryTag": "Flota", "exportsCard": "Eksporty", @@ -134,14 +134,14 @@ "storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów." }, "routers": { - "title": "Routery", - "detailTitle": "Szczegóły routera", - "add": "Dodaj router", + "title": "Urządzenia", + "detailTitle": "Szczegóły urządzenia", + "add": "Dodaj urządzenie", "eyebrow": "inwentaryzacja urządzeń", - "subtitle": "Zarządzaj endpointami RouterOS, poświadczeniami i zadaniami backupu dla całej floty.", + "subtitle": "Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.", "registeredDevices": "Zarejestrowane urządzenia", "fleetTag": "Flota", - "sshPassword": "Hasło SSH", + "sshPassword": "Hasło", "passwordHint": "Dostęp hasłem", "credsTag": "Dostęp", "sshKey": "Klucz SSH", @@ -150,8 +150,8 @@ "defaultPort": "Port 22", "defaultPortHint": "Standardowe endpointy SSH", "portTag": "Port", - "listTitle": "Lista routerów", - "listSubtitle": "Zwięzły widok operacyjny wszystkich zarządzanych urządzeń.", + "listTitle": "Lista urządzeń", + "listSubtitle": "Wspólny widok RouterOS i SwitchOS.", "name": "Nazwa", "endpoint": "Endpoint", "access": "Dostęp", @@ -161,15 +161,15 @@ "keyMode": "Klucz", "globalKeyMode": "Klucz globalny", "noKey": "Bez klucza", - "createDialogTitle": "Dodaj router", - "editDialogTitle": "Edytuj router", + "createDialogTitle": "Dodaj urządzenie", + "editDialogTitle": "Edytuj urządzenie", "host": "Host", "port": "Port", - "sshUser": "Użytkownik SSH", + "sshUser": "Użytkownik", "sshPrivateKey": "Klucz prywatny SSH", "optionalPassword": "Opcjonalne hasło", "optionalPrivateKey": "Opcjonalny klucz prywatny", - "saveRouter": "Zapisz router", + "saveRouter": "Zapisz urządzenie", "profileEyebrow": "profil routera", "detailSubtitle": "Operacje urządzenia i historia backupów", "exportOne": "Eksport", @@ -184,7 +184,7 @@ "connectionLabelHint": "Status z ostatniego automatycznego lub ręcznego testu połączenia", "probeTag": "Test", "accessTag": "Dostęp", - "sshUserHint": "Bieżący użytkownik SSH", + "sshUserHint": "Efektywny login urządzenia", "deviceStatusTitle": "Status urządzenia", "deviceStatusSubtitle": "Zapisane metadane z ostatniego automatycznego lub ręcznego testu połączenia.", "hostname": "Hostname", @@ -200,7 +200,7 @@ "exportsTableTitle": "Eksporty", "exportsTableSubtitle": "Czytelne snapshoty RouterOS.", "binaryTableTitle": "Backupy binarne", - "binaryTableSubtitle": "Pliki do odtworzenia urządzenia.", + "binaryTableSubtitle": "Pliki binarne i kopie SwitchOS.", "summaryKeyAccess": "z dostępem kluczem", "summaryPasswordAccess": "z dostępem hasłem", "connectionStateTitle": "Stan połączenia", @@ -211,7 +211,28 @@ "openPreviewModal": "Otwórz podgląd", "diffModalHint": "Ostatnio załadowany diff jest dostępny w modalu.", "openDiffModal": "Otwórz diff", - "noDiff": "Wybierz eksport i uruchom diff, aby zobaczyć ostatnie porównanie." + "noDiff": "Wybierz eksport i uruchom diff, aby zobaczyć ostatnie porównanie.", + "routeros": "RouterOS", + "switchos": "SwitchOS", + "deviceType": "Typ urządzenia", + "defaultCredentials": "Domyślne dane", + "localCredentials": "Lokalne dane", + "noCredentials": "Brak danych", + "switchUserPlaceholder": "Puste = z ustawień", + "switchPasswordPlaceholder": "Puste = z ustawień", + "switchDefaultsHint": "Dla SwitchOS możesz zostawić użytkownika i hasło puste, aby użyć wartości domyślnych z ustawień.", + "downloadSwitchBackup": "Pobierz backup", + "httpStatus": "Status HTTP", + "serverHeader": "Nagłówek Server", + "authMode": "Tryb autoryzacji", + "backupEndpoint": "Endpoint backupu", + "backupAvailable": "Dostępny", + "backupUnavailable": "Niedostępny", + "connectionSectionTitle": "Profil połączenia", + "connectionSectionHint": "Podstawowa tożsamość urządzenia i endpoint używany do połączenia.", + "credentialsSectionTitle": "Dostęp i poświadczenia", + "routerDialogSubtitle": "Ustaw adres urządzenia, dane dostępu SSH i preferowaną metodę logowania.", + "switchDialogSubtitle": "Ustaw endpoint SwitchOS i opcjonalne poświadczenia lokalne lub domyślne z ustawień." }, "files": { "title": "Repozytorium", @@ -233,14 +254,14 @@ "searchLabel": "Szukaj", "searchPlaceholder": "Szukaj po pliku lub routerze", "typeLabel": "Typ", - "routerLabel": "Router", + "routerLabel": "Urządzenie", "sortLabel": "Sortowanie", "orderLabel": "Kolejność", "allTypes": "Wszystkie typy", - "allRouters": "Wszystkie routery", + "allRouters": "Wszystkie urządzenia", "sortNewest": "Najnowsze", "sortName": "Nazwa", - "sortRouter": "Router", + "sortRouter": "Urządzenie", "sortType": "Typ", "tableTitle": "Tabela repozytorium", "tableSubtitle": "Artefakty dostępne do pobrania, wysyłki e-mail i przywracania.", @@ -248,7 +269,7 @@ "compareSelected": "Porównaj zaznaczone eksporty", "fileColumn": "Plik", "typeColumn": "Typ", - "routerColumn": "Router", + "routerColumn": "Urządzenie", "createdColumn": "Utworzono", "actionsColumn": "Akcje", "checksum": "Checksum", @@ -311,8 +332,8 @@ "pushoverUserKey": "Klucz użytkownika Pushover", "pushoverTokenPlaceholder": "Token aplikacji", "pushoverUserKeyPlaceholder": "Klucz użytkownika", - "sshDefaultsTitle": "Domyślne SSH", - "sshDefaultsSubtitle": "Opcjonalny współdzielony klucz prywatny używany przez zarządzane routery.", + "sshDefaultsTitle": "Domyślne Poświadczenia", + "sshDefaultsSubtitle": "Wspólny klucz SSH oraz domyślne logowanie SwitchOS używane przez urządzenia.", "globalSshPrivateKey": "Globalny klucz prywatny SSH", "globalSshPrivateKeyPlaceholder": "Wklej klucz prywatny PEM lub OpenSSH", "save": "Zapisz ustawienia", @@ -377,7 +398,11 @@ "interfacePreferencesHint": "Wybierz domyślny język i rodzinę fontów dla całej aplikacji.", "interfacePreferencesTag": "Per-user", "fontFamily": "Rodzina fontów", - "fontDefault": "Domyślna" + "fontDefault": "Domyślna", + "switchosDefaultsTitle": "Domyślne dane SwitchOS", + "switchosDefaultsHint": "Używane, gdy urządzenie SwitchOS nie ma własnego loginu lub hasła.", + "defaultSwitchosUsername": "Domyślny użytkownik SwitchOS", + "defaultSwitchosPassword": "Domyślne hasło SwitchOS" }, "logs": { "title": "Logi", diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index dfe2e62..9e568e3 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -3389,3 +3389,198 @@ body.dark-theme .p-confirm-dialog .p-confirm-dialog-icon{ @media (max-width: 991px) { } + +.router-dialog .p-dialog-header{ + padding: 1.15rem 1.2rem 1rem; + align-items: flex-start; + background: + linear-gradient(135deg, rgba(75, 144, 217, 0.16), rgba(79, 181, 147, 0.1)), + linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)), + var(--surface-1); + border-bottom: 1px solid rgba(75, 144, 217, 0.18); +} + +.router-dialog .p-dialog-header-icons{ + align-self: flex-start; + margin-top: 0.2rem; +} + +.router-dialog .p-dialog-content{ + padding: 0 1.2rem 1.2rem; + background: linear-gradient(180deg, rgba(75, 144, 217, 0.06) 0%, rgba(75, 144, 217, 0) 180px), var(--surface-1); +} + +.router-dialog-header{ + display: flex; + align-items: center; + gap: 0.9rem; + width: calc(100% - 0.5rem); +} + +.router-dialog-header__icon{ + width: 3rem; + height: 3rem; + border-radius: 18px; + display: grid; + place-items: center; + flex-shrink: 0; + background: linear-gradient(135deg, rgba(75, 144, 217, 0.24), rgba(79, 181, 147, 0.14)); + border: 1px solid rgba(75, 144, 217, 0.2); + color: var(--primary); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.router-dialog-header__icon .pi{ + font-size: 1.05rem; +} + +.router-dialog-header__text{ + min-width: 0; +} + +.router-dialog-header__eyebrow{ + font-family: var(--font-title); + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-soft); +} + +.router-dialog-header__title{ + margin-top: 0.2rem; + font-family: var(--font-title); + font-size: 1.18rem; + line-height: 1.25; +} + +.router-dialog-header__text small{ + display: block; + margin-top: 0.3rem; + max-width: 42rem; + line-height: 1.55; +} + +.router-dialog-form{ + display: grid; + gap: 1rem; +} + +.router-dialog-panel{ + padding: 1rem; + border-radius: 22px; + border: 1px solid var(--border-color); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)), var(--surface-0); + box-shadow: var(--shadow-md); +} + +.router-dialog-panel:first-child{ + margin-top: 1rem; +} + +.router-dialog-panel__header{ + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 0.95rem; +} + +.router-dialog-panel__header strong{ + display: block; + font-family: var(--font-title); + font-size: 0.88rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.router-dialog-panel__header p{ + margin: 0.35rem 0 0; + color: var(--text-soft); + line-height: 1.55; +} + +.router-dialog-pill{ + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 0.85rem; + border-radius: 999px; + border: 1px solid rgba(75, 144, 217, 0.18); + background: rgba(75, 144, 217, 0.08); + color: var(--text-main); + font-family: var(--font-title); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.router-dialog-grid{ + gap: 0.95rem 1rem; +} + +.router-dialog-note{ + margin-top: 0.9rem; + padding: 0.85rem 0.95rem; + border-radius: 16px; + border: 1px solid rgba(75, 144, 217, 0.18); + background: rgba(75, 144, 217, 0.08); + color: var(--text-soft); + display: flex; + align-items: flex-start; + gap: 0.65rem; + line-height: 1.55; +} + +.router-dialog-note .pi{ + margin-top: 0.1rem; + color: var(--accent); +} + +.router-dialog .p-inputtext, +.router-dialog .p-dropdown, +.router-dialog .p-inputtextarea{ + background: rgba(255, 255, 255, 0.02); +} + +.router-dialog .p-inputtextarea{ + min-height: 11rem; +} + +.router-dialog-actions{ + justify-content: space-between; + gap: 0.75rem; + padding-top: 0.1rem; +} + +.router-dialog-actions .p-button{ + min-width: 11rem; +} + +@media (max-width: 720px) { + .router-dialog .p-dialog-header{ + padding: 1rem 0.85rem 0.85rem; + } + + .router-dialog .p-dialog-content{ + padding: 0 0.85rem 0.95rem; + } + + .router-dialog-header{ + align-items: flex-start; + } + + .router-dialog-panel__header{ + flex-direction: column; + } + + .router-dialog-actions{ + flex-direction: column-reverse; + align-items: stretch; + } + + .router-dialog-actions .p-button{ + width: 100%; + min-width: 0; + } +}