switchos support

This commit is contained in:
Mateusz Gruszczyński
2026-04-13 11:59:17 +02:00
parent 5163704b59
commit 4d2356f60b
28 changed files with 1142 additions and 330 deletions

View File

@@ -1,14 +1,12 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix='/auth', tags=['auth'])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(dashboard.router, prefix='/dashboard', tags=['dashboard'])
api_router.include_router(routers.router, prefix="/routers", tags=["routers"]) api_router.include_router(routers.router, prefix='/routers', tags=['routers'])
api_router.include_router(backups.router, prefix="/backups", tags=["backups"]) api_router.include_router(backups.router, prefix='/backups', tags=['backups'])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(settings.router, prefix='/settings', tags=['settings'])
api_router.include_router(logs.router, prefix="/logs", tags=["logs"]) api_router.include_router(logs.router, prefix='/logs', tags=['logs'])
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=['health'])
api_router.include_router(swos_beta.router, prefix='/swos-beta', tags=['swos-beta'])

View File

@@ -13,73 +13,108 @@ from app.services.settings_service import settings_service
router = APIRouter() 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_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 = RouterResponse.model_validate(router, from_attributes=True).model_dump()
payload['uses_global_ssh_key'] = has_global_key and not has_router_key payload['effective_username'] = effective_username
payload['has_effective_ssh_key'] = has_router_key or has_global_key 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) 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)): 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() 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)): 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.add(router)
db.commit() db.commit()
db.refresh(router) db.refresh(router)
settings = settings_service.get_or_create(db) global_settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key) 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)): 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() router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router: if not router:
raise HTTPException(status_code=404, detail="Router not found") raise HTTPException(status_code=404, detail='Device not found')
settings = settings_service.get_or_create(db) global_settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key) 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)): 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() router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router: if not router:
raise HTTPException(status_code=404, detail="Router not found") raise HTTPException(status_code=404, detail='Device not found')
for key, value in payload.model_dump(exclude_unset=True).items(): 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) setattr(router, key, value)
db.add(router) db.add(router)
db.commit() db.commit()
db.refresh(router) db.refresh(router)
settings = settings_service.get_or_create(db) global_settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key) 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)): 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() router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router: 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): for backup in list(router.backups):
path = Path(backup.file_path) path = Path(backup.file_path)
if path.exists(): if path.exists():
path.unlink() path.unlink()
db.delete(router) db.delete(router)
db.commit() 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)): 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() router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router: if not router:
raise HTTPException(status_code=404, detail="Router not found") raise HTTPException(status_code=404, detail='Device not found')
settings = settings_service.get_or_create(db) global_settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, settings.global_ssh_key) return router_service.test_connection(db, router, global_settings)

View File

@@ -23,6 +23,9 @@ def serialize_settings(settings: GlobalSettings) -> SettingsResponse:
payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump() payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump()
payload['global_ssh_key'] = None payload['global_ssh_key'] = None
payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip()) 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) return SettingsResponse.model_validate(payload)

View File

@@ -42,10 +42,13 @@ def _run_lightweight_migrations() -> None:
tables = set(inspect(engine).get_table_names()) tables = set(inspect(engine).get_table_names())
if 'global_settings' in tables: if 'global_settings' in tables:
_ensure_column('global_settings', 'connection_test_interval_minutes', 'INTEGER DEFAULT 0') _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: if 'users' in tables:
_ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL") _ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL")
_ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL") _ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL")
if 'routers' in tables: 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_status', 'BOOLEAN')
_ensure_column('routers', 'last_connection_tested_at', 'DATETIME') _ensure_column('routers', 'last_connection_tested_at', 'DATETIME')
_ensure_column('routers', 'last_connection_error', 'TEXT') _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_model', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_version', 'VARCHAR(255)') _ensure_column('routers', 'last_connection_version', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_uptime', '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(): def init_db():

View File

@@ -11,6 +11,7 @@ class Router(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(120), nullable=False) name = Column(String(120), nullable=False)
device_type = Column(String(32), nullable=False, default="routeros")
host = Column(String(255), nullable=False) host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False, default=22) port = Column(Integer, nullable=False, default=22)
ssh_user = Column(String(120), nullable=False, default="admin") 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_model = Column(String(255), nullable=True)
last_connection_version = Column(String(255), nullable=True) last_connection_version = Column(String(255), nullable=True)
last_connection_uptime = 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) created_at = Column(DateTime, server_default=func.now(), nullable=False)
backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan") backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan")

View File

@@ -15,6 +15,8 @@ class GlobalSettings(Base):
enable_auto_export = Column(Boolean, default=False) enable_auto_export = Column(Boolean, default=False)
connection_test_interval_minutes = Column(Integer, default=0) connection_test_interval_minutes = Column(Integer, default=0)
global_ssh_key = Column(Text, nullable=True) 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_token = Column(String(255), nullable=True)
pushover_userkey = Column(String(255), nullable=True) pushover_userkey = Column(String(255), nullable=True)
notify_failures_only = Column(Boolean, default=True) notify_failures_only = Column(Boolean, default=True)

View File

@@ -8,6 +8,7 @@ class BackupResponse(BaseModel):
id: int id: int
router_id: int router_id: int
router_name: str | None = None router_name: str | None = None
device_type: str = "routeros"
file_path: str file_path: str
file_name: str file_name: str
backup_type: str backup_type: str

View File

@@ -1,16 +1,19 @@
import re import re
from datetime import datetime 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_-]+$") ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
DeviceType = Literal["routeros", "switchos"]
class RouterBase(BaseModel): class RouterBase(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
device_type: DeviceType = "routeros"
host: str = Field(min_length=1, max_length=255) host: str = Field(min_length=1, max_length=255)
port: int = Field(default=22, ge=1, le=65535) port: int | None = Field(default=None, ge=1, le=65535)
ssh_user: str = Field(default="admin", min_length=1, max_length=120) ssh_user: str | None = Field(default=None, max_length=120)
ssh_key: str | None = None ssh_key: str | None = None
ssh_password: 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") raise ValueError("Only letters, digits, dashes and underscores are allowed")
return value 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): class RouterCreate(RouterBase):
pass pass
@@ -28,18 +48,30 @@ class RouterCreate(RouterBase):
class RouterUpdate(BaseModel): class RouterUpdate(BaseModel):
name: str | None = None name: str | None = None
device_type: DeviceType | None = None
host: str | None = None host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535) port: int | None = Field(default=None, ge=1, le=65535)
ssh_user: str | None = None ssh_user: str | None = None
ssh_key: str | None = None ssh_key: str | None = None
ssh_password: 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): class RouterResponse(RouterBase):
id: int id: int
owner_id: int owner_id: int
effective_username: str | None = None
uses_global_ssh_key: bool = False uses_global_ssh_key: bool = False
has_effective_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_status: bool | None = None
last_connection_tested_at: datetime | None = None last_connection_tested_at: datetime | None = None
last_connection_error: str | None = None last_connection_error: str | None = None
@@ -47,6 +79,11 @@ class RouterResponse(RouterBase):
last_connection_model: str | None = None last_connection_model: str | None = None
last_connection_version: str | None = None last_connection_version: str | None = None
last_connection_uptime: 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 created_at: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -60,3 +97,8 @@ class RouterTestConnection(BaseModel):
hostname: str hostname: str
version: str | None = None version: str | None = None
error: 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

View File

@@ -15,6 +15,8 @@ class SettingsBase(BaseModel):
enable_auto_export: bool = False enable_auto_export: bool = False
connection_test_interval_minutes: int = Field(default=0, ge=0, le=1440) connection_test_interval_minutes: int = Field(default=0, ge=0, le=1440)
global_ssh_key: str | None = None global_ssh_key: str | None = None
default_switchos_username: str | None = None
default_switchos_password: str | None = None
pushover_token: str | None = None pushover_token: str | None = None
pushover_userkey: str | None = None pushover_userkey: str | None = None
notify_failures_only: bool = True notify_failures_only: bool = True
@@ -30,9 +32,9 @@ class SettingsBase(BaseModel):
def normalize_cron(cls, value: str | None) -> str: def normalize_cron(cls, value: str | None) -> str:
return (value or '').strip() 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 @classmethod
def normalize_key(cls, value: str | None) -> str | None: def normalize_secret_text(cls, value: str | None) -> str | None:
normalized = (value or '').strip() normalized = (value or '').strip()
return normalized or None return normalized or None
@@ -55,6 +57,7 @@ class SettingsUpdate(SettingsBase):
class SettingsResponse(SettingsBase): class SettingsResponse(SettingsBase):
id: int id: int
has_global_ssh_key: bool = False has_global_ssh_key: bool = False
has_default_switchos_credentials: bool = False
model_config = {'from_attributes': True} model_config = {'from_attributes': True}

View File

@@ -19,7 +19,7 @@ class BackupService:
def _router_for_user(self, db: Session, user: User, router_id: int) -> Router: 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() router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first()
if not router: if not router:
raise HTTPException(status_code=404, detail='Router not found') raise HTTPException(status_code=404, detail='Device not found')
return router return router
def _serialize_backup(self, backup: Backup): def _serialize_backup(self, backup: Backup):
@@ -28,6 +28,7 @@ class BackupService:
'id': backup.id, 'id': backup.id,
'router_id': backup.router_id, 'router_id': backup.router_id,
'router_name': backup.router.name if backup.router else None, '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_path': backup.file_path,
'file_name': backup.file_name, 'file_name': backup.file_name,
'backup_type': backup.backup_type, 'backup_type': backup.backup_type,
@@ -179,6 +180,8 @@ class BackupService:
def export_router(self, db: Session, user: User, router_id: int) -> Backup: def export_router(self, db: Session, user: User, router_id: int) -> Backup:
router = self._router_for_user(db, user, router_id) 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) settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S') stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
name = f'{router.name}_{router.id}_{stamp}.rsc' name = f'{router.name}_{router.id}_{stamp}.rsc'
@@ -190,12 +193,14 @@ class BackupService:
db.add(backup) db.add(backup)
db.commit() db.commit()
db.refresh(backup) 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) notification_service.notify(settings, f'Export {router.name} OK', True)
return backup return backup
except HTTPException:
raise
except Exception as exc: except Exception as exc:
notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False) 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 raise HTTPException(status_code=500, detail=str(exc)) from exc
def binary_backup(self, db: Session, user: User, router_id: int) -> Backup: 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) settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S') stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
base_name = f'{router.name}_{router.id}_{stamp}' 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 file_path = ensure_data_dir() / name
try: 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)) 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) backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum)
db.add(backup) db.add(backup)
db.commit() db.commit()
db.refresh(backup) 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) notification_service.notify(settings, f'Backup {router.name} OK', True)
return backup return backup
except HTTPException:
raise
except Exception as exc: except Exception as exc:
notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False) 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 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): 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) 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) backup = self.get_backup_for_user(db, user, backup_id)
if backup.backup_type != 'binary': if backup.backup_type != 'binary':
raise HTTPException(status_code=400, detail='Only binary backups can be uploaded') 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) checksum = compute_checksum(backup.file_path)
if backup.checksum and checksum != backup.checksum: if backup.checksum and checksum != backup.checksum:
raise HTTPException(status_code=400, detail='Checksum mismatch') raise HTTPException(status_code=400, detail='Checksum mismatch')
settings = settings_service.get_or_create(db) settings = settings_service.get_or_create(db)
router_service.upload_backup(router, backup.file_path, settings.global_ssh_key) 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): def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True):
backup = self.get_backup_for_user(db, user, backup_id) 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): def email_backup(self, db: Session, user: User, backup_id: int):
backup = self.get_backup_for_user(db, user, backup_id) backup = self.get_backup_for_user(db, user, backup_id)
settings = settings_service.get_or_create(db) 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' noun = 'Export' if backup.backup_type == 'export' else 'Backup'
subject = f'RouterOS {noun}: {backup.file_name}' subject = f'{platform_name} {noun}: {backup.file_name}'
body = f'Sending {backup.file_name} from router {backup.router.name}.' body = f'Sending {backup.file_name} from device {backup.router.name}.'
notification_service.send_email(settings, subject, body, backup.file_path) notification_service.send_email(settings, subject, body, backup.file_path)
log_service.add(db, f'Email sent for backup {backup.file_name}') 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() routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = [] result = []
for router in routers: 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: try:
backup = self.export_router(db, user, router.id) backup = self.export_router(db, user, router.id)
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})

View File

@@ -6,6 +6,7 @@ import paramiko
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.router import Router from app.models.router import Router
from app.services.swos_beta_service import swos_beta_service
class RouterService: class RouterService:
@@ -47,18 +48,25 @@ class RouterService:
return client return client
def export(self, router: Router, global_ssh_key: str | None = None) -> str: 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) client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command("/export") _, stdout, _ = client.exec_command('/export')
output = stdout.read().decode("utf-8", errors="ignore") output = stdout.read().decode('utf-8', errors='ignore')
client.close() client.close()
return output 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) 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() stdout.channel.recv_exit_status()
sftp = client.open_sftp() sftp = client.open_sftp()
remote_file = f"{backup_name}.backup" remote_file = f'{backup_name}.backup'
sftp.get(remote_file, local_path) sftp.get(remote_file, local_path)
try: try:
sftp.remove(remote_file) sftp.remove(remote_file)
@@ -69,6 +77,8 @@ class RouterService:
return local_path return local_path
def upload_backup(self, router: Router, local_backup_path: str, global_ssh_key: str | None = None): 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) client = self._connect(router, global_ssh_key)
sftp = client.open_sftp() sftp = client.open_sftp()
target_name = Path(local_backup_path).name target_name = Path(local_backup_path).name
@@ -76,64 +86,84 @@ class RouterService:
sftp.close() sftp.close()
client.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() tested_at = datetime.utcnow()
try: try:
client = self._connect(router, global_ssh_key) client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command("/system resource print without-paging") _, stdout, _ = client.exec_command('/system resource print without-paging')
resource_output = stdout.read().decode("utf-8", errors="ignore") resource_output = stdout.read().decode('utf-8', errors='ignore')
_, stdout, _ = client.exec_command("/system identity print") _, stdout, _ = client.exec_command('/system identity print')
identity_output = stdout.read().decode("utf-8", errors="ignore") identity_output = stdout.read().decode('utf-8', errors='ignore')
client.close() client.close()
model = "Unknown" model = 'Unknown'
uptime = "Unknown" uptime = 'Unknown'
hostname = "Unknown" hostname = 'Unknown'
version = "Unknown" version = 'Unknown'
for line in resource_output.splitlines(): for line in resource_output.splitlines():
if "board-name" in line: if 'board-name' in line:
model = line.split(":", 1)[1].strip() model = line.split(':', 1)[1].strip()
if "uptime" in line: if 'uptime' in line:
uptime = line.split(":", 1)[1].strip() uptime = line.split(':', 1)[1].strip()
if "version" in line: if 'version' in line:
version = line.split(":", 1)[1].strip() version = line.split(':', 1)[1].strip()
for line in identity_output.splitlines(): for line in identity_output.splitlines():
if "name" in line: if 'name' in line:
hostname = line.split(":", 1)[1].strip() hostname = line.split(':', 1)[1].strip()
return { return {
"success": True, 'success': True,
"tested_at": tested_at, 'tested_at': tested_at,
"model": model, 'model': model,
"uptime": uptime, 'uptime': uptime,
"hostname": hostname, 'hostname': hostname,
"version": version, 'version': version,
"error": None, 'error': None,
'transport': 'ssh',
'server': None,
'auth_mode': 'ssh',
'http_status': None,
'backup_available': None,
} }
except Exception as exc: except Exception as exc:
return { return {
"success": False, 'success': False,
"tested_at": tested_at, 'tested_at': tested_at,
"model": "Unknown", 'model': 'Unknown',
"uptime": "Unknown", 'uptime': 'Unknown',
"hostname": router.name, 'hostname': router.name,
"version": None, 'version': None,
"error": str(exc), '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): def _store_connection_result(self, db: Session, router: Router, result: dict):
router.last_connection_status = result["success"] router.last_connection_status = result['success']
router.last_connection_tested_at = result["tested_at"] router.last_connection_tested_at = result['tested_at']
router.last_connection_error = result.get("error") router.last_connection_error = result.get('error')
router.last_connection_hostname = result.get("hostname") router.last_connection_hostname = result.get('hostname')
router.last_connection_model = result.get("model") router.last_connection_model = result.get('model')
router.last_connection_version = result.get("version") router.last_connection_version = result.get('version')
router.last_connection_uptime = result.get("uptime") 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.add(router)
db.commit() db.commit()
db.refresh(router) db.refresh(router)
return result return result
def test_connection(self, db: Session, router: Router, global_ssh_key: str | None = None): def test_connection(self, db: Session, router: Router, global_settings):
result = self.probe_connection(router, global_ssh_key) result = self.probe_connection(router, global_settings.global_ssh_key, global_settings)
return self._store_connection_result(db, router, result) return self._store_connection_result(db, router, result)

View File

@@ -1,7 +1,6 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
@@ -41,7 +40,55 @@ class SwosBetaService:
server=response.headers.get('server'), server=response.headers.get('server'),
save_backup_visible='save backup' in html.lower(), save_backup_visible='save backup' in html.lower(),
backup_endpoint_ok=backup_ok, 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: def download_backup(self, payload: SwosBetaCredentials) -> DownloadedSwosBackup:
@@ -62,6 +109,9 @@ class SwosBetaService:
base_url=base_url, 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): def _request_with_fallback(self, method: str, url: str, payload: SwosBetaCredentials, allow_text_fallback: bool = True):
attempts = [] attempts = []
auth_variants = [ auth_variants = [
@@ -89,8 +139,8 @@ class SwosBetaService:
attempts.append(f'{label}:{exc.__class__.__name__}') attempts.append(f'{label}:{exc.__class__.__name__}')
if last_response is not None: if last_response is not None:
raise ValueError(f'Nie udało się połączyć ze SwOS ({", ".join(attempts)}).') raise ValueError(f'Nie udało się połączyć ze SwitchOS ({", ".join(attempts)}).')
raise ValueError('Nie udało się połączyć ze SwOS.') raise ValueError('Nie udało się połączyć ze SwitchOS.')
def _build_base_url(self, host: str, port: int) -> str: def _build_base_url(self, host: str, port: int) -> str:
raw = host.strip() raw = host.strip()
@@ -118,7 +168,7 @@ class SwosBetaService:
label = payload.label or payload.host label = payload.label or payload.host
safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos' safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos'
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') 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() swos_beta_service = SwosBetaService()

View File

@@ -1,74 +1,151 @@
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app 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'}) 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): def test_switchos_list_marks_global_credentials_usage(monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_probe.db"}') from app.api.routes import settings as settings_route
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',
),
)
with TestClient(app) as client: with TestClient(app) as client:
token = _login(client) _, headers = _login(client)
response = client.post( settings_response = client.put(
'/api/swos-beta/probe', '/api/settings',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, json={
headers={'Authorization': f'Bearer {token}'}, '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 settings_response.status_code == 200
assert response.json()['backup_endpoint_ok'] is True 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): def test_switchos_connection_probe_is_exposed_in_device_route(monkeypatch):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_download.db"}') from app.api.routes import routers as routers_route
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())
with TestClient(app) as client: with TestClient(app) as client:
token = _login(client) _, headers = _login(client)
response = client.post( create_response = client.post(
'/api/swos-beta/download', '/api/routers',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, json={
headers={'Authorization': f'Bearer {token}'}, '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.status_code == 200
assert response.content == b'binary-data' assert response.json()['transport'] == 'http'
assert 'attachment; filename="switch.swb"' == response.headers['content-disposition'] 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')

View File

@@ -56,10 +56,9 @@ export class AppComponent {
{ label: 'nav.routers', link: '/routers', icon: 'pi pi-server', exact: false }, { 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.files', link: '/files', icon: 'pi pi-folder-open', exact: false },
{ label: 'nav.diffConfigs', link: '/diff-configs', icon: 'pi pi-code', 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.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 { get currentPageTitle(): string {
@@ -133,10 +132,6 @@ export class AppComponent {
this.pageLabel = 'logs.title'; this.pageLabel = 'logs.title';
return; return;
} }
if (url.startsWith('/switchos-beta')) {
this.pageLabel = 'switchosBeta.title';
return;
}
if (url.startsWith('/change-password')) { if (url.startsWith('/change-password')) {
this.pageLabel = 'auth.changePassword'; this.pageLabel = 'auth.changePassword';
return; return;

View File

@@ -11,7 +11,6 @@ import { LogsPageComponent } from './features/logs/logs-page.component';
import { RouterDetailPageComponent } from './features/routers/router-detail-page.component'; import { RouterDetailPageComponent } from './features/routers/router-detail-page.component';
import { RoutersPageComponent } from './features/routers/routers-page.component'; import { RoutersPageComponent } from './features/routers/routers-page.component';
import { SettingsPageComponent } from './features/settings/settings-page.component'; import { SettingsPageComponent } from './features/settings/settings-page.component';
import { SwosBetaPageComponent } from './features/swos-beta/swos-beta-page.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', component: LoginPageComponent }, { path: 'login', component: LoginPageComponent },
@@ -24,6 +23,5 @@ export const routes: Routes = [
{ path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent }, { path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent },
{ path: 'settings', canActivate: [authGuard], component: SettingsPageComponent }, { path: 'settings', canActivate: [authGuard], component: SettingsPageComponent },
{ path: 'logs', canActivate: [authGuard], component: LogsPageComponent }, { path: 'logs', canActivate: [authGuard], component: LogsPageComponent },
{ path: 'switchos-beta', canActivate: [authGuard], component: SwosBetaPageComponent },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
]; ];

View File

@@ -104,7 +104,7 @@
</td> </td>
<td> <td>
<div class="table-primary">{{ item.router_name || item.router_id }}</div> <div class="table-primary">{{ item.router_name || item.router_id }}</div>
<small class="table-secondary">ID {{ item.router_id }}</small> <small class="table-secondary">{{ deviceLabel(item) }} · ID {{ item.router_id }}</small>
</td> </td>
<td><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warning'"></p-tag></td> <td><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warning'"></p-tag></td>
<td> <td>
@@ -113,7 +113,7 @@
</td> </td>
<td> <td>
<div class="table-primary">{{ formatBytes(item.file_size) }}</div> <div class="table-primary">{{ formatBytes(item.file_size) }}</div>
<small class="table-secondary">{{ item.backup_type === 'export' ? '.rsc' : '.backup' }}</small> <small class="table-secondary">{{ fileExtension(item) }}</small>
</td> </td>
<td> <td>
<div class="table-actions table-actions--stack" *ngIf="item.backup_type === 'export'; else noCompare"> <div class="table-actions table-actions--stack" *ngIf="item.backup_type === 'export'; else noCompare">
@@ -130,7 +130,7 @@
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
<button *ngIf="item.backup_type==='export'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button> <button *ngIf="item.backup_type==='export'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
<button *ngIf="item.backup_type==='binary'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item)"></button> <button *ngIf="item.backup_type==='binary' && item.device_type==='routeros'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item)"></button>
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="deleteOne(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="deleteOne(item.id)"></button>
</div> </div>
</td> </td>

View File

@@ -16,10 +16,13 @@ import { PageHeaderComponent } from '../../shared/ui/page-header.component';
import { SectionCardComponent } from '../../shared/ui/section-card.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component';
import { StatCardComponent } from '../../shared/ui/stat-card.component'; import { StatCardComponent } from '../../shared/ui/stat-card.component';
type DeviceType = 'routeros' | 'switchos';
interface BackupFile { interface BackupFile {
id: number; id: number;
router_id: number; router_id: number;
router_name?: string; router_name?: string;
device_type: DeviceType;
file_name: string; file_name: string;
backup_type: 'export' | 'binary'; backup_type: 'export' | 'binary';
created_at: string; created_at: string;
@@ -233,6 +236,9 @@ export class FilesPageComponent implements OnInit {
} }
upload(item: BackupFile) { 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.api.http.post(`${this.api.baseUrl}/backups/router/${item.router_id}/upload/${item.id}`, {}).subscribe(() => {
this.ui.success('toast.binaryUploaded'); this.ui.success('toast.binaryUploaded');
}); });
@@ -410,6 +416,15 @@ export class FilesPageComponent implements OnInit {
return `${value.slice(0, 8)}${value.slice(-6)}`; 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) { private setComparePair(firstId: number, secondId: number) {
const [left, right] = this.sortPairByDate(firstId, secondId); const [left, right] = this.sortPairByDate(firstId, secondId);
this.compareLeftId = left; this.compareLeftId = left;

View File

@@ -1,21 +1,21 @@
<app-page-header <app-page-header
[eyebrow]="'routers.profileEyebrow' | translate" [eyebrow]="'routers.profileEyebrow' | translate"
[title]="routerItem?.name || ('routers.detailTitle' | translate)" [title]="routerItem?.name || ('routers.detailTitle' | translate)"
[subtitle]="routerItem ? routerItem.host + ':' + routerItem.port + ' · ' + routerItem.ssh_user : ('routers.detailSubtitle' | translate)" [subtitle]="subtitle"
> >
<div header-actions class="header-actions-row"> <div header-actions class="header-actions-row">
<button pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" (click)="runExport()"></button> <button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" (click)="runExport()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="'routers.binaryOne' | translate" [loading]="runningBinary" (click)="runBinary()"></button> <button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" (click)="runBinary()"></button>
<button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button> <button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button>
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button> <button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
</div> </div>
</app-page-header> </app-page-header>
<div class="stats-grid compact-grid"> <div class="stats-grid compact-grid">
<app-stat-card [label]="'routers.exportsLabel' | translate" [value]="exportBackups.length" [hint]="'routers.exportsLabelHint' | translate" [tag]="'files.exportType' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card> <app-stat-card [label]="'routers.deviceType' | translate" [value]="deviceTypeLabel" [hint]="'routers.listSubtitle' | translate" [tag]="'routers.fleetTag' | translate" severity="info" icon="pi pi-sitemap" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warning" icon="pi pi-database" iconClass="icon-amber"></app-stat-card> <app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warning" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'routers.connectionLabel' | translate" [value]="connectionStateLabel" [hint]="'routers.connectionLabelHint' | translate" [tag]="'routers.probeTag' | translate" severity="info" icon="pi pi-bolt" iconClass="icon-blue"></app-stat-card> <app-stat-card [label]="'routers.connectionLabel' | translate" [value]="connectionStateLabel" [hint]="'routers.connectionLabelHint' | translate" [tag]="'routers.probeTag' | translate" severity="info" icon="pi pi-bolt" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'routers.sshUser' | translate" [value]="routerItem?.ssh_user || '-'" [hint]="'routers.sshUserHint' | translate" [tag]="'routers.accessTag' | translate" severity="secondary" icon="pi pi-user" iconClass="icon-violet"></app-stat-card> <app-stat-card [label]="'routers.sshUser' | translate" [value]="routerItem?.effective_username || '-'" [hint]="'routers.sshUserHint' | translate" [tag]="'routers.accessTag' | translate" severity="secondary" icon="pi pi-user" iconClass="icon-violet"></app-stat-card>
</div> </div>
<div class="dashboard-grid router-detail-grid router-detail-grid--inspection"> <div class="dashboard-grid router-detail-grid router-detail-grid--inspection">
@@ -28,6 +28,10 @@
<div class="metric-tile"><span>{{ 'routers.model' | translate }}</span><strong>{{ connection.model }}</strong></div> <div class="metric-tile"><span>{{ 'routers.model' | translate }}</span><strong>{{ connection.model }}</strong></div>
<div class="metric-tile"><span>{{ 'routers.version' | translate }}</span><strong>{{ connection.version || 'n/a' }}</strong></div> <div class="metric-tile"><span>{{ 'routers.version' | translate }}</span><strong>{{ connection.version || 'n/a' }}</strong></div>
<div class="metric-tile"><span>{{ 'routers.uptime' | translate }}</span><strong>{{ connection.uptime }}</strong></div> <div class="metric-tile"><span>{{ 'routers.uptime' | translate }}</span><strong>{{ connection.uptime }}</strong></div>
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.httpStatus' | translate }}</span><strong>{{ connection.http_status || '—' }}</strong></div>
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.serverHeader' | translate }}</span><strong>{{ connection.server || '—' }}</strong></div>
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.authMode' | translate }}</span><strong>{{ connection.auth_mode || '—' }}</strong></div>
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.backupEndpoint' | translate }}</span><strong>{{ connection.backup_available ? ('routers.backupAvailable' | translate) : ('routers.backupUnavailable' | translate) }}</strong></div>
</div> </div>
<div class="router-status-error" *ngIf="!connection.success && connection.error"> <div class="router-status-error" *ngIf="!connection.success && connection.error">
<strong>{{ 'routers.lastError' | translate }}</strong> <strong>{{ 'routers.lastError' | translate }}</strong>
@@ -42,7 +46,7 @@
</ng-template> </ng-template>
</app-section-card> </app-section-card>
<div class="router-detail-inspection-stack"> <div class="router-detail-inspection-stack" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate"> <app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview"> <div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
<div> <div>
@@ -81,7 +85,7 @@
</div> </div>
</div> </div>
<div class="dashboard-grid router-detail-grid router-detail-grid--stack"> <div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate"> <app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate">
<p-table [value]="exportBackups" responsiveLayout="scroll" styleClass="app-table"> <p-table [value]="exportBackups" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header"> <ng-template pTemplate="header">
@@ -107,7 +111,9 @@
</ng-template> </ng-template>
</p-table> </p-table>
</app-section-card> </app-section-card>
</div>
<div class="dashboard-grid router-detail-grid router-detail-grid--stack">
<app-section-card [title]="'routers.binaryTableTitle' | translate" [subtitle]="'routers.binaryTableSubtitle' | translate"> <app-section-card [title]="'routers.binaryTableTitle' | translate" [subtitle]="'routers.binaryTableSubtitle' | translate">
<p-table [value]="binaryBackups" responsiveLayout="scroll" styleClass="app-table"> <p-table [value]="binaryBackups" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header"> <ng-template pTemplate="header">
@@ -123,7 +129,7 @@
<td> <td>
<div class="table-actions table-actions--labels table-actions--tight"> <div class="table-actions table-actions--labels table-actions--tight">
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item.id)"></button> <button *ngIf="!isSwitchos" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item.id)"></button>
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
</div> </div>

View File

@@ -14,11 +14,37 @@ import { PageHeaderComponent } from '../../shared/ui/page-header.component';
import { SectionCardComponent } from '../../shared/ui/section-card.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component';
import { StatCardComponent } from '../../shared/ui/stat-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 { interface BackupItem {
id: number; id: number;
file_name: string; file_name: string;
backup_type: 'export' | 'binary'; backup_type: 'export' | 'binary';
created_at: string; created_at: string;
device_type: DeviceType;
} }
interface ConnectionSnapshot { interface ConnectionSnapshot {
@@ -29,6 +55,11 @@ interface ConnectionSnapshot {
version?: string | null; version?: string | null;
uptime: string; uptime: string;
error?: string | null; error?: string | null;
transport?: string | null;
server?: string | null;
auth_mode?: string | null;
http_status?: string | null;
backup_available?: boolean | null;
} }
interface BackupDiffStats { interface BackupDiffStats {
@@ -59,7 +90,7 @@ export class RouterDetailPageComponent implements OnInit {
private readonly ui = inject(UiService); private readonly ui = inject(UiService);
routerId!: number; routerId!: number;
routerItem: any; routerItem: DeviceItem | null = null;
backups: BackupItem[] = []; backups: BackupItem[] = [];
connection: ConnectionSnapshot | null = null; connection: ConnectionSnapshot | null = null;
exportContent = ''; exportContent = '';
@@ -73,6 +104,10 @@ export class RouterDetailPageComponent implements OnInit {
testing = false; testing = false;
deletingRouter = false; deletingRouter = false;
get isSwitchos(): boolean {
return this.routerItem?.device_type === 'switchos';
}
get exportBackups(): BackupItem[] { get exportBackups(): BackupItem[] {
return this.backups.filter((item) => item.backup_type === 'export'); return this.backups.filter((item) => item.backup_type === 'export');
} }
@@ -96,13 +131,25 @@ export class RouterDetailPageComponent implements OnInit {
return !!this.diffText; 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() { ngOnInit() {
this.routerId = Number(this.route.snapshot.paramMap.get('id')); this.routerId = Number(this.route.snapshot.paramMap.get('id'));
this.load(); this.load();
} }
load() { load() {
this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem: any) => { this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => {
this.routerItem = routerItem; this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem); this.connection = this.mapStoredConnection(routerItem);
}); });
@@ -110,7 +157,7 @@ export class RouterDetailPageComponent implements OnInit {
} }
runExport() { runExport() {
if (this.exporting) { if (this.exporting || this.isSwitchos) {
return; return;
} }
this.exporting = true; this.exporting = true;
@@ -187,6 +234,9 @@ export class RouterDetailPageComponent implements OnInit {
} }
upload(id: number) { upload(id: number) {
if (this.isSwitchos) {
return;
}
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/upload/${id}`, {}).subscribe(() => { this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/upload/${id}`, {}).subscribe(() => {
this.ui.success('toast.binaryUploaded'); this.ui.success('toast.binaryUploaded');
}); });
@@ -217,7 +267,7 @@ export class RouterDetailPageComponent implements OnInit {
viewExport(id: number) { viewExport(id: number) {
const backup = this.exportBackups.find((item) => item.id === id); const backup = this.exportBackups.find((item) => item.id === id);
this.api.http.get<any>(`${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.exportContent = r.content;
this.previewTitle = backup?.file_name || this.ui.instant('routers.previewTitle'); this.previewTitle = backup?.file_name || this.ui.instant('routers.previewTitle');
this.ui.clear(); this.ui.clear();
@@ -241,7 +291,7 @@ export class RouterDetailPageComponent implements OnInit {
this.diffVisible = true; this.diffVisible = true;
} }
private mapStoredConnection(routerItem: any): ConnectionSnapshot | null { private mapStoredConnection(routerItem: DeviceItem): ConnectionSnapshot | null {
if (!routerItem?.last_connection_tested_at) { if (!routerItem?.last_connection_tested_at) {
return null; return null;
} }
@@ -252,7 +302,12 @@ export class RouterDetailPageComponent implements OnInit {
model: routerItem.last_connection_model || 'Unknown', model: routerItem.last_connection_model || 'Unknown',
version: routerItem.last_connection_version, version: routerItem.last_connection_version,
uptime: routerItem.last_connection_uptime || 'Unknown', 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_version: result.version,
last_connection_uptime: result.uptime, last_connection_uptime: result.uptime,
last_connection_error: result.error, 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
}; };
} }

View File

@@ -11,13 +11,13 @@
</div> </div>
<div class="inline-summary__divider"></div> <div class="inline-summary__divider"></div>
<div class="inline-summary__item"> <div class="inline-summary__item">
<strong>{{ keyCount }}</strong> <strong>{{ routerOsCount }}</strong>
<span>{{ 'routers.summaryKeyAccess' | translate }}</span> <span>{{ 'routers.routeros' | translate }}</span>
</div> </div>
<div class="inline-summary__divider"></div> <div class="inline-summary__divider"></div>
<div class="inline-summary__item"> <div class="inline-summary__item">
<strong>{{ passwordCount }}</strong> <strong>{{ switchOsCount }}</strong>
<span>{{ 'routers.summaryPasswordAccess' | translate }}</span> <span>{{ 'routers.switchos' | translate }}</span>
</div> </div>
</div> </div>
@@ -30,16 +30,16 @@
<tr> <tr>
<td> <td>
<div class="table-primary">{{ routerItem.name }}</div> <div class="table-primary">{{ routerItem.name }}</div>
<small class="table-secondary">{{ 'routers.routerOsTarget' | translate }}</small> <small class="table-secondary">{{ deviceTypeLabel(routerItem) }}</small>
</td> </td>
<td> <td>
<div class="table-primary">{{ routerItem.host }}:{{ routerItem.port }}</div> <div class="table-primary">{{ routerItem.host }}:{{ routerItem.port }}</div>
<small class="table-secondary">{{ routerItem.ssh_user }}</small> <small class="table-secondary">{{ accessUser(routerItem) }}</small>
</td> </td>
<td> <td>
<div class="inline-tags"> <div class="inline-tags">
<p-tag [value]="routerItem.ssh_password ? ('routers.passwordMode' | translate) : ('routers.noPassword' | translate)" [severity]="routerItem.ssh_password ? 'warning' : 'secondary'"></p-tag> <p-tag [value]="primaryAccessTag(routerItem).value" [severity]="primaryAccessTag(routerItem).severity"></p-tag>
<p-tag [value]="hasEffectiveSshKey(routerItem) ? ((usesGlobalSshKey(routerItem) ? 'routers.globalKeyMode' : 'routers.keyMode') | translate) : ('routers.noKey' | translate)" [severity]="hasEffectiveSshKey(routerItem) ? 'success' : 'secondary'"></p-tag> <p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
</div> </div>
</td> </td>
<td> <td>
@@ -54,33 +54,101 @@
</p-table> </p-table>
</app-section-card> </app-section-card>
<p-dialog [(visible)]="visible" [modal]="true" [header]="dialogTitle" [style]="{ width: '640px' }" styleClass="router-dialog"> <p-dialog [(visible)]="visible" [modal]="true" [draggable]="false" [resizable]="false" [style]="{ width: 'min(760px, 96vw)' }" styleClass="router-dialog">
<form [formGroup]="form" (ngSubmit)="save()" class="form-grid-2"> <ng-template pTemplate="header">
<span class="form-field"> <div class="router-dialog-header">
<label>{{ 'routers.name' | translate }}</label> <div class="router-dialog-header__icon">
<input pInputText formControlName="name" placeholder="core-router-waw" /> <i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-sitemap' : 'pi-server'"></i>
</span> </div>
<span class="form-field"> <div class="router-dialog-header__text">
<label>{{ 'routers.host' | translate }}</label> <div class="router-dialog-header__eyebrow">
<input pInputText formControlName="host" placeholder="10.0.0.1" /> {{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }}
</span> </div>
<span class="form-field"> <div class="router-dialog-header__title">{{ dialogTitle }}</div>
<label>{{ 'routers.port' | translate }}</label> <small>
<input pInputText type="number" formControlName="port" placeholder="22" /> {{
</span> selectedDeviceType === 'switchos'
<span class="form-field"> ? ('routers.switchDialogSubtitle' | translate)
<label>{{ 'routers.sshUser' | translate }}</label> : ('routers.routerDialogSubtitle' | translate)
<input pInputText formControlName="ssh_user" placeholder="admin" /> }}
</span> </small>
<span class="form-field form-field--full"> </div>
<label>{{ 'routers.sshPassword' | translate }}</label> </div>
<input pInputText formControlName="ssh_password" [placeholder]="'routers.optionalPassword' | translate" /> </ng-template>
</span>
<span class="form-field form-field--full"> <form [formGroup]="form" (ngSubmit)="save()" class="router-dialog-form">
<label>{{ 'routers.sshPrivateKey' | translate }}</label> <section class="router-dialog-panel">
<textarea pInputTextarea formControlName="ssh_key" rows="7" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea> <div class="router-dialog-panel__header">
</span> <div>
<div class="dialog-actions"> <strong>{{ 'routers.connectionSectionTitle' | translate }}</strong>
<p>{{ 'routers.connectionSectionHint' | translate }}</p>
</div>
<span class="router-dialog-pill">
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-globe' : 'pi-shield'"></i>
{{ selectedDeviceType === 'switchos' ? 'HTTP' : 'SSH' }}
</span>
</div>
<div class="form-grid-2 router-dialog-grid">
<span class="form-field">
<label>{{ 'routers.name' | translate }}</label>
<input pInputText formControlName="name" placeholder="core-router-waw" />
</span>
<span class="form-field">
<label>{{ 'routers.deviceType' | translate }}</label>
<p-dropdown [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-dropdown>
</span>
<span class="form-field">
<label>{{ 'routers.host' | translate }}</label>
<input pInputText formControlName="host" placeholder="10.0.0.1" />
</span>
<span class="form-field">
<label>{{ 'routers.port' | translate }}</label>
<input pInputText type="number" formControlName="port" [placeholder]="selectedDeviceType === 'switchos' ? '80' : '22'" />
</span>
</div>
</section>
<section class="router-dialog-panel">
<div class="router-dialog-panel__header">
<div>
<strong>{{ 'routers.credentialsSectionTitle' | translate }}</strong>
<p>
{{
selectedDeviceType === 'switchos'
? ('routers.switchDialogSubtitle' | translate)
: ('routers.routerDialogSubtitle' | translate)
}}
</p>
</div>
<span class="router-dialog-pill">
<i class="pi pi-key"></i>
{{ selectedDeviceType === 'switchos' ? ('routers.defaultCredentials' | translate) : 'SSH' }}
</span>
</div>
<div class="form-grid-2 router-dialog-grid">
<span class="form-field">
<label>{{ 'routers.sshUser' | translate }}</label>
<input pInputText formControlName="ssh_user" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchUserPlaceholder' | translate) : 'admin'" />
</span>
<span class="form-field">
<label>{{ 'routers.sshPassword' | translate }}</label>
<input pInputText type="password" formControlName="ssh_password" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchPasswordPlaceholder' | translate) : ('routers.optionalPassword' | translate)" />
</span>
<span class="form-field form-field--full" *ngIf="selectedDeviceType === 'routeros'">
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
<textarea pInputTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
</div>
<div class="router-dialog-note" *ngIf="selectedDeviceType === 'switchos'">
<i class="pi pi-info-circle"></i>
<span>{{ 'routers.switchDefaultsHint' | translate }}</span>
</div>
</section>
<div class="dialog-actions router-dialog-actions">
<button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="visible=false"></button> <button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="visible=false"></button>
<button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button> <button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog'; import { DialogModule } from 'primeng/dialog';
import { DropdownModule } from 'primeng/dropdown';
import { InputTextareaModule } from 'primeng/inputtextarea'; import { InputTextareaModule } from 'primeng/inputtextarea';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { TableModule } from 'primeng/table'; 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 { PageHeaderComponent } from '../../shared/ui/page-header.component';
import { SectionCardComponent } from '../../shared/ui/section-card.component'; import { SectionCardComponent } from '../../shared/ui/section-card.component';
type DeviceType = 'routeros' | 'switchos';
interface RouterItem { interface RouterItem {
id: number; id: number;
name: string; name: string;
host: string; host: string;
port: number; port: number;
ssh_user: string; device_type: DeviceType;
ssh_password?: string; ssh_user?: string | null;
ssh_key?: string; ssh_password?: string | null;
ssh_key?: string | null;
effective_username?: string | null;
uses_global_ssh_key?: boolean; uses_global_ssh_key?: boolean;
has_effective_ssh_key?: boolean; has_effective_ssh_key?: boolean;
uses_global_switchos_credentials?: boolean;
has_effective_password?: boolean;
} }
@Component({ @Component({
standalone: true, 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' templateUrl: './routers-page.component.html'
}) })
export class RoutersPageComponent implements OnInit { export class RoutersPageComponent implements OnInit {
@@ -42,11 +62,16 @@ export class RoutersPageComponent implements OnInit {
editingId: number | null = null; editingId: number | null = null;
saving = false; saving = false;
routers: RouterItem[] = []; routers: RouterItem[] = [];
readonly deviceTypeOptions = [
{ label: 'RouterOS', value: 'routeros' },
{ label: 'SwitchOS', value: 'switchos' }
];
readonly form = this.fb.nonNullable.group({ readonly form = this.fb.nonNullable.group({
name: ['', Validators.required], name: ['', Validators.required],
device_type: ['routeros' as DeviceType, Validators.required],
host: ['', Validators.required], host: ['', Validators.required],
port: [22, Validators.required], port: [22, Validators.required],
ssh_user: ['admin', Validators.required], ssh_user: ['admin'],
ssh_password: '', ssh_password: '',
ssh_key: '' ssh_key: ''
}); });
@@ -55,24 +80,22 @@ export class RoutersPageComponent implements OnInit {
return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle'); return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle');
} }
get passwordCount(): number { get selectedDeviceType(): DeviceType {
return this.routers.filter((item) => !!item.ssh_password).length; return this.form.controls.device_type.value;
} }
get keyCount(): number { get routerOsCount(): number {
return this.routers.filter((item) => this.hasEffectiveSshKey(item)).length; return this.routers.filter((item) => item.device_type === 'routeros').length;
} }
hasEffectiveSshKey(item: RouterItem): boolean { get switchOsCount(): number {
return !!item.has_effective_ssh_key; return this.routers.filter((item) => item.device_type === 'switchos').length;
} }
usesGlobalSshKey(item: RouterItem): boolean {
return !!item.uses_global_ssh_key;
}
ngOnInit() { ngOnInit() {
this.form.controls.device_type.valueChanges.subscribe((deviceType) => {
this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType);
});
this.load(); this.load();
} }
@@ -82,7 +105,7 @@ export class RoutersPageComponent implements OnInit {
openCreate() { openCreate() {
this.editingId = null; 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; this.visible = true;
} }
@@ -90,9 +113,10 @@ export class RoutersPageComponent implements OnInit {
this.editingId = item.id; this.editingId = item.id;
this.form.reset({ this.form.reset({
name: item.name, name: item.name,
device_type: item.device_type,
host: item.host, host: item.host,
port: item.port, port: item.port,
ssh_user: item.ssh_user, ssh_user: item.ssh_user ?? '',
ssh_password: item.ssh_password ?? '', ssh_password: item.ssh_password ?? '',
ssh_key: item.ssh_key ?? '' ssh_key: item.ssh_key ?? ''
}); });
@@ -104,9 +128,13 @@ export class RoutersPageComponent implements OnInit {
return; return;
} }
this.saving = true; this.saving = true;
const payload = this.form.getRawValue();
if (payload.device_type === 'switchos') {
payload.ssh_key = '';
}
const request$ = this.editingId const request$ = this.editingId
? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, this.form.getRawValue()) ? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, payload)
: this.api.http.post(`${this.api.baseUrl}/routers`, this.form.getRawValue()); : this.api.http.post(`${this.api.baseUrl}/routers`, payload);
request$.subscribe({ request$.subscribe({
next: () => { next: () => {
@@ -134,8 +162,56 @@ export class RoutersPageComponent implements OnInit {
}); });
} }
open(id: number) { open(id: number) {
this.router.navigate(['/routers', id]); 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 });
}
} }

View File

@@ -253,7 +253,7 @@
</div> </div>
<div class="settings-page-side"> <div class="settings-page-side">
<details class="settings-collapse settings-collapse--sticky"> <details class="settings-collapse settings-collapse--sticky" open>
<summary> <summary>
<span>{{ 'settings.sshDefaultsTitle' | translate }}</span> <span>{{ 'settings.sshDefaultsTitle' | translate }}</span>
<small>{{ 'settings.sshDefaultsSubtitle' | translate }}</small> <small>{{ 'settings.sshDefaultsSubtitle' | translate }}</small>
@@ -297,6 +297,25 @@
<small class="settings-ssh-note" *ngIf="clearStoredSshKey">{{ 'settings.sshKeyClearNotice' | translate }}</small> <small class="settings-ssh-note" *ngIf="clearStoredSshKey">{{ 'settings.sshKeyClearNotice' | translate }}</small>
</div> </div>
<div class="settings-ssh-panel">
<div class="settings-ssh-panel__header">
<div>
<strong>{{ 'settings.switchosDefaultsTitle' | translate }}</strong>
<p>{{ 'settings.switchosDefaultsHint' | translate }}</p>
</div>
</div>
<div class="form-grid-2">
<span class="form-field">
<label>{{ 'settings.defaultSwitchosUsername' | translate }}</label>
<input pInputText formControlName="default_switchos_username" placeholder="admin" />
</span>
<span class="form-field">
<label>{{ 'settings.defaultSwitchosPassword' | translate }}</label>
<input pInputText formControlName="default_switchos_password" placeholder="••••••••" />
</span>
</div>
</div>
</div> </div>
</details> </details>
</div> </div>

View File

@@ -55,6 +55,9 @@ interface SettingsResponse {
connection_test_interval_minutes: number; connection_test_interval_minutes: number;
global_ssh_key: string | null; global_ssh_key: string | null;
has_global_ssh_key: boolean; 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_token: string | null;
pushover_userkey: string | null; pushover_userkey: string | null;
notify_failures_only: boolean; notify_failures_only: boolean;
@@ -104,6 +107,8 @@ export class SettingsPageComponent implements OnInit, OnDestroy {
enable_auto_export: false, enable_auto_export: false,
connection_test_interval_minutes: [0, Validators.min(0)], connection_test_interval_minutes: [0, Validators.min(0)],
global_ssh_key: '', global_ssh_key: '',
default_switchos_username: '',
default_switchos_password: '',
pushover_token: '', pushover_token: '',
pushover_userkey: '', pushover_userkey: '',
notify_failures_only: true, notify_failures_only: true,
@@ -376,6 +381,8 @@ export class SettingsPageComponent implements OnInit, OnDestroy {
enable_auto_export: response.enable_auto_export, enable_auto_export: response.enable_auto_export,
connection_test_interval_minutes: Number(response.connection_test_interval_minutes || 0), connection_test_interval_minutes: Number(response.connection_test_interval_minutes || 0),
global_ssh_key: '', global_ssh_key: '',
default_switchos_username: response.default_switchos_username || '',
default_switchos_password: response.default_switchos_password || '',
pushover_token: response.pushover_token || '', pushover_token: response.pushover_token || '',
pushover_userkey: response.pushover_userkey || '', pushover_userkey: response.pushover_userkey || '',
notify_failures_only: response.notify_failures_only, 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), enable_auto_export: Boolean(raw.enable_auto_export),
connection_test_interval_minutes: Number(raw.connection_test_interval_minutes || 0), connection_test_interval_minutes: Number(raw.connection_test_interval_minutes || 0),
global_ssh_key: normalizedKey || null, 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_token: this.normalizeOptionalText(raw.pushover_token),
pushover_userkey: this.normalizeOptionalText(raw.pushover_userkey), pushover_userkey: this.normalizeOptionalText(raw.pushover_userkey),
notify_failures_only: Boolean(raw.notify_failures_only), notify_failures_only: Boolean(raw.notify_failures_only),

View File

@@ -35,7 +35,7 @@
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"routers": "Routers", "routers": "Devices",
"files": "Repository", "files": "Repository",
"settings": "Settings", "settings": "Settings",
"logs": "Logs", "logs": "Logs",
@@ -81,7 +81,7 @@
"subtitle": "Overview of backups, exports and operational activity in one place.", "subtitle": "Overview of backups, exports and operational activity in one place.",
"exportAll": "Export all", "exportAll": "Export all",
"binaryAll": "Binary backup", "binaryAll": "Binary backup",
"managedRouters": "Routers", "managedRouters": "Devices",
"managedRoutersHint": "All managed devices", "managedRoutersHint": "All managed devices",
"inventoryTag": "Fleet", "inventoryTag": "Fleet",
"exportsCard": "Exports", "exportsCard": "Exports",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Quick snapshot of the most important storage and backup indicators." "storageSnapshotHint": "Quick snapshot of the most important storage and backup indicators."
}, },
"routers": { "routers": {
"title": "Routers", "title": "Devices",
"detailTitle": "Router details", "detailTitle": "Device details",
"add": "Add router", "add": "Add device",
"eyebrow": "device inventory", "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", "registeredDevices": "Registered devices",
"fleetTag": "Fleet", "fleetTag": "Fleet",
"sshPassword": "SSH password", "sshPassword": "Password",
"passwordHint": "Password-based access", "passwordHint": "Password-based access",
"credsTag": "Creds", "credsTag": "Creds",
"sshKey": "SSH key", "sshKey": "SSH key",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22", "defaultPort": "Port 22",
"defaultPortHint": "Standard SSH endpoints", "defaultPortHint": "Standard SSH endpoints",
"portTag": "Port", "portTag": "Port",
"listTitle": "Router list", "listTitle": "Device list",
"listSubtitle": "Compact operational view of every managed device.", "listSubtitle": "Unified view for RouterOS and SwitchOS devices.",
"name": "Name", "name": "Name",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"access": "Access", "access": "Access",
@@ -161,15 +161,15 @@
"keyMode": "Key", "keyMode": "Key",
"globalKeyMode": "Global key", "globalKeyMode": "Global key",
"noKey": "No key", "noKey": "No key",
"createDialogTitle": "Add router", "createDialogTitle": "Add device",
"editDialogTitle": "Edit router", "editDialogTitle": "Edit device",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"sshUser": "SSH user", "sshUser": "Username",
"sshPrivateKey": "SSH private key", "sshPrivateKey": "SSH private key",
"optionalPassword": "Optional password", "optionalPassword": "Optional password",
"optionalPrivateKey": "Optional private key", "optionalPrivateKey": "Optional private key",
"saveRouter": "Save router", "saveRouter": "Save device",
"profileEyebrow": "router profile", "profileEyebrow": "router profile",
"detailSubtitle": "Device operations and backup history", "detailSubtitle": "Device operations and backup history",
"exportOne": "Export", "exportOne": "Export",
@@ -184,7 +184,7 @@
"connectionLabelHint": "Status from the latest automatic or manual connection test", "connectionLabelHint": "Status from the latest automatic or manual connection test",
"probeTag": "Probe", "probeTag": "Probe",
"accessTag": "Access", "accessTag": "Access",
"sshUserHint": "Current SSH user", "sshUserHint": "Effective device login",
"deviceStatusTitle": "Device status", "deviceStatusTitle": "Device status",
"deviceStatusSubtitle": "Stored metadata from the latest automatic or manual connection test.", "deviceStatusSubtitle": "Stored metadata from the latest automatic or manual connection test.",
"hostname": "Hostname", "hostname": "Hostname",
@@ -200,7 +200,7 @@
"exportsTableTitle": "Exports", "exportsTableTitle": "Exports",
"exportsTableSubtitle": "Readable RouterOS snapshots.", "exportsTableSubtitle": "Readable RouterOS snapshots.",
"binaryTableTitle": "Binary backups", "binaryTableTitle": "Binary backups",
"binaryTableSubtitle": "Files ready for device restore.", "binaryTableSubtitle": "Binary files and SwitchOS backups.",
"summaryKeyAccess": "with key-based access", "summaryKeyAccess": "with key-based access",
"summaryPasswordAccess": "with password access", "summaryPasswordAccess": "with password access",
"connectionStateTitle": "Connection state", "connectionStateTitle": "Connection state",
@@ -211,7 +211,28 @@
"openPreviewModal": "Open preview", "openPreviewModal": "Open preview",
"diffModalHint": "The last loaded diff is available in a modal.", "diffModalHint": "The last loaded diff is available in a modal.",
"openDiffModal": "Open diff", "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": { "files": {
"title": "Repository", "title": "Repository",
@@ -233,14 +254,14 @@
"searchLabel": "Search", "searchLabel": "Search",
"searchPlaceholder": "Search by file or router", "searchPlaceholder": "Search by file or router",
"typeLabel": "Type", "typeLabel": "Type",
"routerLabel": "Router", "routerLabel": "Device",
"sortLabel": "Sort by", "sortLabel": "Sort by",
"orderLabel": "Order", "orderLabel": "Order",
"allTypes": "All types", "allTypes": "All types",
"allRouters": "All routers", "allRouters": "All devices",
"sortNewest": "Newest", "sortNewest": "Newest",
"sortName": "Name", "sortName": "Name",
"sortRouter": "Router", "sortRouter": "Device",
"sortType": "Type", "sortType": "Type",
"tableTitle": "Repository table", "tableTitle": "Repository table",
"tableSubtitle": "Artifacts available for download, e-mail and restore.", "tableSubtitle": "Artifacts available for download, e-mail and restore.",
@@ -248,7 +269,7 @@
"compareSelected": "Compare selected exports", "compareSelected": "Compare selected exports",
"fileColumn": "File", "fileColumn": "File",
"typeColumn": "Type", "typeColumn": "Type",
"routerColumn": "Router", "routerColumn": "Device",
"createdColumn": "Created", "createdColumn": "Created",
"actionsColumn": "Actions", "actionsColumn": "Actions",
"checksum": "Checksum", "checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Pushover user key", "pushoverUserKey": "Pushover user key",
"pushoverTokenPlaceholder": "Application token", "pushoverTokenPlaceholder": "Application token",
"pushoverUserKeyPlaceholder": "User key", "pushoverUserKeyPlaceholder": "User key",
"sshDefaultsTitle": "SSH defaults", "sshDefaultsTitle": "Default Credentials",
"sshDefaultsSubtitle": "Optional shared private key used across managed routers.", "sshDefaultsSubtitle": "Shared SSH key and default SwitchOS login used across managed devices.",
"globalSshPrivateKey": "Global SSH private key", "globalSshPrivateKey": "Global SSH private key",
"globalSshPrivateKeyPlaceholder": "Paste PEM or OpenSSH private key", "globalSshPrivateKeyPlaceholder": "Paste PEM or OpenSSH private key",
"save": "Save settings", "save": "Save settings",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Choose the default language and font family for the whole application.", "interfacePreferencesHint": "Choose the default language and font family for the whole application.",
"interfacePreferencesTag": "Per-user", "interfacePreferencesTag": "Per-user",
"fontFamily": "Font family", "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": { "logs": {
"title": "Logs", "title": "Logs",

View File

@@ -35,7 +35,7 @@
}, },
"nav": { "nav": {
"dashboard": "Panel", "dashboard": "Panel",
"routers": "Routers", "routers": "Dispositivos",
"files": "Repositorio", "files": "Repositorio",
"settings": "Ajustes", "settings": "Ajustes",
"logs": "Registros", "logs": "Registros",
@@ -81,7 +81,7 @@
"subtitle": "Resumen de copias, exportaciones y actividad operativa en un solo lugar.", "subtitle": "Resumen de copias, exportaciones y actividad operativa en un solo lugar.",
"exportAll": "Exportar todo", "exportAll": "Exportar todo",
"binaryAll": "Copia binaria", "binaryAll": "Copia binaria",
"managedRouters": "Routers", "managedRouters": "Dispositivos",
"managedRoutersHint": "Todos los dispositivos gestionados", "managedRoutersHint": "Todos los dispositivos gestionados",
"inventoryTag": "Flota", "inventoryTag": "Flota",
"exportsCard": "Exportaciones", "exportsCard": "Exportaciones",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Vista rápida de los indicadores más importantes de almacenamiento y copias." "storageSnapshotHint": "Vista rápida de los indicadores más importantes de almacenamiento y copias."
}, },
"routers": { "routers": {
"title": "Routers", "title": "Dispositivos",
"detailTitle": "Detalles del router", "detailTitle": "Detalles del dispositivo",
"add": "Añadir router", "add": "Agregar dispositivo",
"eyebrow": "inventario de dispositivos", "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", "registeredDevices": "Dispositivos registrados",
"fleetTag": "Flota", "fleetTag": "Flota",
"sshPassword": "Contraseña SSH", "sshPassword": "Contraseña",
"passwordHint": "Acceso con contraseña", "passwordHint": "Acceso con contraseña",
"credsTag": "Credenciales", "credsTag": "Credenciales",
"sshKey": "Clave SSH", "sshKey": "Clave SSH",
@@ -150,8 +150,8 @@
"defaultPort": "Puerto 22", "defaultPort": "Puerto 22",
"defaultPortHint": "Endpoints SSH estándar", "defaultPortHint": "Endpoints SSH estándar",
"portTag": "Puerto", "portTag": "Puerto",
"listTitle": "Lista de routers", "listTitle": "Lista de dispositivos",
"listSubtitle": "Vista operativa compacta de todos los dispositivos gestionados.", "listSubtitle": "Vista unificada para RouterOS y SwitchOS.",
"name": "Nombre", "name": "Nombre",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"access": "Acceso", "access": "Acceso",
@@ -161,15 +161,15 @@
"keyMode": "Clave", "keyMode": "Clave",
"globalKeyMode": "Clave global", "globalKeyMode": "Clave global",
"noKey": "Sin clave", "noKey": "Sin clave",
"createDialogTitle": "Añadir router", "createDialogTitle": "Agregar dispositivo",
"editDialogTitle": "Editar router", "editDialogTitle": "Editar dispositivo",
"host": "Host", "host": "Host",
"port": "Puerto", "port": "Puerto",
"sshUser": "Usuario SSH", "sshUser": "Usuario",
"sshPrivateKey": "Clave privada SSH", "sshPrivateKey": "Clave privada SSH",
"optionalPassword": "Contraseña opcional", "optionalPassword": "Contraseña opcional",
"optionalPrivateKey": "Clave privada opcional", "optionalPrivateKey": "Clave privada opcional",
"saveRouter": "Guardar router", "saveRouter": "Guardar dispositivo",
"profileEyebrow": "perfil del router", "profileEyebrow": "perfil del router",
"detailSubtitle": "Operaciones del dispositivo e historial de copias", "detailSubtitle": "Operaciones del dispositivo e historial de copias",
"exportOne": "Exportar", "exportOne": "Exportar",
@@ -211,7 +211,28 @@
"openPreviewModal": "Abrir vista previa", "openPreviewModal": "Abrir vista previa",
"diffModalHint": "El último diff cargado está disponible en un modal.", "diffModalHint": "El último diff cargado está disponible en un modal.",
"openDiffModal": "Abrir diff", "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": { "files": {
"title": "Repositorio", "title": "Repositorio",
@@ -233,14 +254,14 @@
"searchLabel": "Buscar", "searchLabel": "Buscar",
"searchPlaceholder": "Buscar por archivo o router", "searchPlaceholder": "Buscar por archivo o router",
"typeLabel": "Tipo", "typeLabel": "Tipo",
"routerLabel": "Router", "routerLabel": "Dispositivo",
"sortLabel": "Ordenar por", "sortLabel": "Ordenar por",
"orderLabel": "Orden", "orderLabel": "Orden",
"allTypes": "Todos los tipos", "allTypes": "Todos los tipos",
"allRouters": "Todos los routers", "allRouters": "Todos los dispositivos",
"sortNewest": "Más nuevo", "sortNewest": "Más nuevo",
"sortName": "Nombre", "sortName": "Nombre",
"sortRouter": "Router", "sortRouter": "Dispositivo",
"sortType": "Tipo", "sortType": "Tipo",
"tableTitle": "Tabla del repositorio", "tableTitle": "Tabla del repositorio",
"tableSubtitle": "Artefactos disponibles para descarga, correo y restauración.", "tableSubtitle": "Artefactos disponibles para descarga, correo y restauración.",
@@ -248,7 +269,7 @@
"compareSelected": "Comparar exportaciones seleccionadas", "compareSelected": "Comparar exportaciones seleccionadas",
"fileColumn": "Archivo", "fileColumn": "Archivo",
"typeColumn": "Tipo", "typeColumn": "Tipo",
"routerColumn": "Router", "routerColumn": "Dispositivo",
"createdColumn": "Creado", "createdColumn": "Creado",
"actionsColumn": "Acciones", "actionsColumn": "Acciones",
"checksum": "Checksum", "checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Clave de usuario de Pushover", "pushoverUserKey": "Clave de usuario de Pushover",
"pushoverTokenPlaceholder": "Token de la aplicación", "pushoverTokenPlaceholder": "Token de la aplicación",
"pushoverUserKeyPlaceholder": "Clave de usuario", "pushoverUserKeyPlaceholder": "Clave de usuario",
"sshDefaultsTitle": "Valores SSH por defecto", "sshDefaultsTitle": "Credenciales predeterminadas",
"sshDefaultsSubtitle": "Clave privada compartida opcional usada en todos los routers gestionados.", "sshDefaultsSubtitle": "Clave SSH compartida y acceso por defecto de SwitchOS usados por los dispositivos gestionados.",
"globalSshPrivateKey": "Clave privada SSH global", "globalSshPrivateKey": "Clave privada SSH global",
"globalSshPrivateKeyPlaceholder": "Pega la clave privada PEM u OpenSSH", "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.", "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.", "interfacePreferencesHint": "Elige el idioma predeterminado y la familia tipográfica para toda la aplicación.",
"interfacePreferencesTag": "Por usuario", "interfacePreferencesTag": "Por usuario",
"fontFamily": "Familia tipográfica", "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": { "logs": {
"title": "Registros", "title": "Registros",

View File

@@ -35,7 +35,7 @@
}, },
"nav": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
"routers": "Rutere", "routers": "Enheter",
"files": "Repository", "files": "Repository",
"settings": "Innstillinger", "settings": "Innstillinger",
"logs": "Logger", "logs": "Logger",
@@ -81,7 +81,7 @@
"subtitle": "Oversikt over backuper, eksportfiler og operativ aktivitet på ett sted.", "subtitle": "Oversikt over backuper, eksportfiler og operativ aktivitet på ett sted.",
"exportAll": "Eksporter alle", "exportAll": "Eksporter alle",
"binaryAll": "Binær backup", "binaryAll": "Binær backup",
"managedRouters": "Rutere", "managedRouters": "Enheter",
"managedRoutersHint": "Alle administrerte enheter", "managedRoutersHint": "Alle administrerte enheter",
"inventoryTag": "Flåte", "inventoryTag": "Flåte",
"exportsCard": "Eksporter", "exportsCard": "Eksporter",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Rask oversikt over de viktigste lagrings- og backupindikatorene." "storageSnapshotHint": "Rask oversikt over de viktigste lagrings- og backupindikatorene."
}, },
"routers": { "routers": {
"title": "Rutere", "title": "Enheter",
"detailTitle": "Ruterdetaljer", "detailTitle": "Enhetsdetaljer",
"add": "Legg til ruter", "add": "Legg til enhet",
"eyebrow": "enhetsinventar", "eyebrow": "enhetsinventar",
"subtitle": "Administrer RouterOS-endepunkter, legitimasjon og backupjobber for hele flåten.", "subtitle": "Administrer RouterOS- og SwitchOS-enheter og sikkerhetskopier.",
"registeredDevices": "Registrerte enheter", "registeredDevices": "Registrerte enheter",
"fleetTag": "Flåte", "fleetTag": "Flåte",
"sshPassword": "SSH-passord", "sshPassword": "Passord",
"passwordHint": "Passordbasert tilgang", "passwordHint": "Passordbasert tilgang",
"credsTag": "Tilgang", "credsTag": "Tilgang",
"sshKey": "SSH-nøkkel", "sshKey": "SSH-nøkkel",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22", "defaultPort": "Port 22",
"defaultPortHint": "Standard SSH-endepunkter", "defaultPortHint": "Standard SSH-endepunkter",
"portTag": "Port", "portTag": "Port",
"listTitle": "Ruterliste", "listTitle": "Enhetsliste",
"listSubtitle": "Kompakt driftsvisning av alle administrerte enheter.", "listSubtitle": "Felles visning for RouterOS og SwitchOS.",
"name": "Navn", "name": "Navn",
"endpoint": "Endepunkt", "endpoint": "Endepunkt",
"access": "Tilgang", "access": "Tilgang",
@@ -161,15 +161,15 @@
"keyMode": "Nøkkel", "keyMode": "Nøkkel",
"globalKeyMode": "Global nøkkel", "globalKeyMode": "Global nøkkel",
"noKey": "Ingen nøkkel", "noKey": "Ingen nøkkel",
"createDialogTitle": "Legg til ruter", "createDialogTitle": "Legg til enhet",
"editDialogTitle": "Rediger ruter", "editDialogTitle": "Rediger enhet",
"host": "Vert", "host": "Vert",
"port": "Port", "port": "Port",
"sshUser": "SSH-bruker", "sshUser": "Bruker",
"sshPrivateKey": "SSH privat nøkkel", "sshPrivateKey": "SSH privat nøkkel",
"optionalPassword": "Valgfritt passord", "optionalPassword": "Valgfritt passord",
"optionalPrivateKey": "Valgfri privat nøkkel", "optionalPrivateKey": "Valgfri privat nøkkel",
"saveRouter": "Lagre ruter", "saveRouter": "Lagre enhet",
"profileEyebrow": "ruterprofil", "profileEyebrow": "ruterprofil",
"detailSubtitle": "Enhetsoperasjoner og backuphistorikk", "detailSubtitle": "Enhetsoperasjoner og backuphistorikk",
"exportOne": "Eksport", "exportOne": "Eksport",
@@ -211,7 +211,28 @@
"openPreviewModal": "Åpne forhåndsvisning", "openPreviewModal": "Åpne forhåndsvisning",
"diffModalHint": "Sist lastede diff er tilgjengelig i en modal.", "diffModalHint": "Sist lastede diff er tilgjengelig i en modal.",
"openDiffModal": "Åpne diff", "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": { "files": {
"title": "Repository", "title": "Repository",
@@ -233,14 +254,14 @@
"searchLabel": "Søk", "searchLabel": "Søk",
"searchPlaceholder": "Søk etter fil eller ruter", "searchPlaceholder": "Søk etter fil eller ruter",
"typeLabel": "Type", "typeLabel": "Type",
"routerLabel": "Ruter", "routerLabel": "Enhet",
"sortLabel": "Sorter etter", "sortLabel": "Sorter etter",
"orderLabel": "Rekkefølge", "orderLabel": "Rekkefølge",
"allTypes": "Alle typer", "allTypes": "Alle typer",
"allRouters": "Alle rutere", "allRouters": "Alle enheter",
"sortNewest": "Nyeste", "sortNewest": "Nyeste",
"sortName": "Navn", "sortName": "Navn",
"sortRouter": "Ruter", "sortRouter": "Enhet",
"sortType": "Type", "sortType": "Type",
"tableTitle": "Repositorytabell", "tableTitle": "Repositorytabell",
"tableSubtitle": "Artefakter tilgjengelige for nedlasting, e-post og gjenoppretting.", "tableSubtitle": "Artefakter tilgjengelige for nedlasting, e-post og gjenoppretting.",
@@ -248,7 +269,7 @@
"compareSelected": "Sammenlign valgte eksporter", "compareSelected": "Sammenlign valgte eksporter",
"fileColumn": "Fil", "fileColumn": "Fil",
"typeColumn": "Type", "typeColumn": "Type",
"routerColumn": "Ruter", "routerColumn": "Enhet",
"createdColumn": "Opprettet", "createdColumn": "Opprettet",
"actionsColumn": "Handlinger", "actionsColumn": "Handlinger",
"checksum": "Checksum", "checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Pushover-brukernøkkel", "pushoverUserKey": "Pushover-brukernøkkel",
"pushoverTokenPlaceholder": "Applikasjonstoken", "pushoverTokenPlaceholder": "Applikasjonstoken",
"pushoverUserKeyPlaceholder": "Brukernøkkel", "pushoverUserKeyPlaceholder": "Brukernøkkel",
"sshDefaultsTitle": "SSH-standarder", "sshDefaultsTitle": "Standard legitimasjon",
"sshDefaultsSubtitle": "Valgfri delt privat nøkkel som brukes tvers av administrerte rutere.", "sshDefaultsSubtitle": "Delt SSH-nøkkel og standard innlogging for SwitchOS brukt på administrerte enheter.",
"globalSshPrivateKey": "Global SSH privat nøkkel", "globalSshPrivateKey": "Global SSH privat nøkkel",
"globalSshPrivateKeyPlaceholder": "Lim inn PEM- eller OpenSSH-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.", "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.", "interfacePreferencesHint": "Velg standardspråk og skriftfamilie for hele applikasjonen.",
"interfacePreferencesTag": "Per bruker", "interfacePreferencesTag": "Per bruker",
"fontFamily": "Skriftfamilie", "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": { "logs": {
"title": "Logger", "title": "Logger",

View File

@@ -35,7 +35,7 @@
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"routers": "Routery", "routers": "Urządzenia",
"files": "Repozytorium", "files": "Repozytorium",
"settings": "Ustawienia", "settings": "Ustawienia",
"logs": "Logi", "logs": "Logi",
@@ -81,7 +81,7 @@
"subtitle": "Przegląd backupów, eksportów i aktywności operacyjnej w jednym miejscu.", "subtitle": "Przegląd backupów, eksportów i aktywności operacyjnej w jednym miejscu.",
"exportAll": "Eksportuj wszystko", "exportAll": "Eksportuj wszystko",
"binaryAll": "Backup binarny", "binaryAll": "Backup binarny",
"managedRouters": "Routery", "managedRouters": "Urządzenia",
"managedRoutersHint": "Wszystkie zarządzane urządzenia", "managedRoutersHint": "Wszystkie zarządzane urządzenia",
"inventoryTag": "Flota", "inventoryTag": "Flota",
"exportsCard": "Eksporty", "exportsCard": "Eksporty",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów." "storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."
}, },
"routers": { "routers": {
"title": "Routery", "title": "Urządzenia",
"detailTitle": "Szczegóły routera", "detailTitle": "Szczegóły urządzenia",
"add": "Dodaj router", "add": "Dodaj urządzenie",
"eyebrow": "inwentaryzacja urządzeń", "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", "registeredDevices": "Zarejestrowane urządzenia",
"fleetTag": "Flota", "fleetTag": "Flota",
"sshPassword": "Hasło SSH", "sshPassword": "Hasło",
"passwordHint": "Dostęp hasłem", "passwordHint": "Dostęp hasłem",
"credsTag": "Dostęp", "credsTag": "Dostęp",
"sshKey": "Klucz SSH", "sshKey": "Klucz SSH",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22", "defaultPort": "Port 22",
"defaultPortHint": "Standardowe endpointy SSH", "defaultPortHint": "Standardowe endpointy SSH",
"portTag": "Port", "portTag": "Port",
"listTitle": "Lista routerów", "listTitle": "Lista urządzeń",
"listSubtitle": "Zwięzły widok operacyjny wszystkich zarządzanych urządzeń.", "listSubtitle": "Wspólny widok RouterOS i SwitchOS.",
"name": "Nazwa", "name": "Nazwa",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"access": "Dostęp", "access": "Dostęp",
@@ -161,15 +161,15 @@
"keyMode": "Klucz", "keyMode": "Klucz",
"globalKeyMode": "Klucz globalny", "globalKeyMode": "Klucz globalny",
"noKey": "Bez klucza", "noKey": "Bez klucza",
"createDialogTitle": "Dodaj router", "createDialogTitle": "Dodaj urządzenie",
"editDialogTitle": "Edytuj router", "editDialogTitle": "Edytuj urządzenie",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"sshUser": "Użytkownik SSH", "sshUser": "Użytkownik",
"sshPrivateKey": "Klucz prywatny SSH", "sshPrivateKey": "Klucz prywatny SSH",
"optionalPassword": "Opcjonalne hasło", "optionalPassword": "Opcjonalne hasło",
"optionalPrivateKey": "Opcjonalny klucz prywatny", "optionalPrivateKey": "Opcjonalny klucz prywatny",
"saveRouter": "Zapisz router", "saveRouter": "Zapisz urządzenie",
"profileEyebrow": "profil routera", "profileEyebrow": "profil routera",
"detailSubtitle": "Operacje urządzenia i historia backupów", "detailSubtitle": "Operacje urządzenia i historia backupów",
"exportOne": "Eksport", "exportOne": "Eksport",
@@ -184,7 +184,7 @@
"connectionLabelHint": "Status z ostatniego automatycznego lub ręcznego testu połączenia", "connectionLabelHint": "Status z ostatniego automatycznego lub ręcznego testu połączenia",
"probeTag": "Test", "probeTag": "Test",
"accessTag": "Dostęp", "accessTag": "Dostęp",
"sshUserHint": "Bieżący użytkownik SSH", "sshUserHint": "Efektywny login urządzenia",
"deviceStatusTitle": "Status urządzenia", "deviceStatusTitle": "Status urządzenia",
"deviceStatusSubtitle": "Zapisane metadane z ostatniego automatycznego lub ręcznego testu połączenia.", "deviceStatusSubtitle": "Zapisane metadane z ostatniego automatycznego lub ręcznego testu połączenia.",
"hostname": "Hostname", "hostname": "Hostname",
@@ -200,7 +200,7 @@
"exportsTableTitle": "Eksporty", "exportsTableTitle": "Eksporty",
"exportsTableSubtitle": "Czytelne snapshoty RouterOS.", "exportsTableSubtitle": "Czytelne snapshoty RouterOS.",
"binaryTableTitle": "Backupy binarne", "binaryTableTitle": "Backupy binarne",
"binaryTableSubtitle": "Pliki do odtworzenia urządzenia.", "binaryTableSubtitle": "Pliki binarne i kopie SwitchOS.",
"summaryKeyAccess": "z dostępem kluczem", "summaryKeyAccess": "z dostępem kluczem",
"summaryPasswordAccess": "z dostępem hasłem", "summaryPasswordAccess": "z dostępem hasłem",
"connectionStateTitle": "Stan połączenia", "connectionStateTitle": "Stan połączenia",
@@ -211,7 +211,28 @@
"openPreviewModal": "Otwórz podgląd", "openPreviewModal": "Otwórz podgląd",
"diffModalHint": "Ostatnio załadowany diff jest dostępny w modalu.", "diffModalHint": "Ostatnio załadowany diff jest dostępny w modalu.",
"openDiffModal": "Otwórz diff", "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": { "files": {
"title": "Repozytorium", "title": "Repozytorium",
@@ -233,14 +254,14 @@
"searchLabel": "Szukaj", "searchLabel": "Szukaj",
"searchPlaceholder": "Szukaj po pliku lub routerze", "searchPlaceholder": "Szukaj po pliku lub routerze",
"typeLabel": "Typ", "typeLabel": "Typ",
"routerLabel": "Router", "routerLabel": "Urządzenie",
"sortLabel": "Sortowanie", "sortLabel": "Sortowanie",
"orderLabel": "Kolejność", "orderLabel": "Kolejność",
"allTypes": "Wszystkie typy", "allTypes": "Wszystkie typy",
"allRouters": "Wszystkie routery", "allRouters": "Wszystkie urządzenia",
"sortNewest": "Najnowsze", "sortNewest": "Najnowsze",
"sortName": "Nazwa", "sortName": "Nazwa",
"sortRouter": "Router", "sortRouter": "Urządzenie",
"sortType": "Typ", "sortType": "Typ",
"tableTitle": "Tabela repozytorium", "tableTitle": "Tabela repozytorium",
"tableSubtitle": "Artefakty dostępne do pobrania, wysyłki e-mail i przywracania.", "tableSubtitle": "Artefakty dostępne do pobrania, wysyłki e-mail i przywracania.",
@@ -248,7 +269,7 @@
"compareSelected": "Porównaj zaznaczone eksporty", "compareSelected": "Porównaj zaznaczone eksporty",
"fileColumn": "Plik", "fileColumn": "Plik",
"typeColumn": "Typ", "typeColumn": "Typ",
"routerColumn": "Router", "routerColumn": "Urządzenie",
"createdColumn": "Utworzono", "createdColumn": "Utworzono",
"actionsColumn": "Akcje", "actionsColumn": "Akcje",
"checksum": "Checksum", "checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Klucz użytkownika Pushover", "pushoverUserKey": "Klucz użytkownika Pushover",
"pushoverTokenPlaceholder": "Token aplikacji", "pushoverTokenPlaceholder": "Token aplikacji",
"pushoverUserKeyPlaceholder": "Klucz użytkownika", "pushoverUserKeyPlaceholder": "Klucz użytkownika",
"sshDefaultsTitle": "Domyślne SSH", "sshDefaultsTitle": "Domyślne Poświadczenia",
"sshDefaultsSubtitle": "Opcjonalny współdzielony klucz prywatny używany przez zarządzane routery.", "sshDefaultsSubtitle": "Wspólny klucz SSH oraz domyślne logowanie SwitchOS używane przez urządzenia.",
"globalSshPrivateKey": "Globalny klucz prywatny SSH", "globalSshPrivateKey": "Globalny klucz prywatny SSH",
"globalSshPrivateKeyPlaceholder": "Wklej klucz prywatny PEM lub OpenSSH", "globalSshPrivateKeyPlaceholder": "Wklej klucz prywatny PEM lub OpenSSH",
"save": "Zapisz ustawienia", "save": "Zapisz ustawienia",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Wybierz domyślny język i rodzinę fontów dla całej aplikacji.", "interfacePreferencesHint": "Wybierz domyślny język i rodzinę fontów dla całej aplikacji.",
"interfacePreferencesTag": "Per-user", "interfacePreferencesTag": "Per-user",
"fontFamily": "Rodzina fontów", "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": { "logs": {
"title": "Logi", "title": "Logi",

View File

@@ -3389,3 +3389,198 @@ body.dark-theme .p-confirm-dialog .p-confirm-dialog-icon{
@media (max-width: 991px) { @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;
}
}