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

View File

@@ -13,73 +13,108 @@ from app.services.settings_service import settings_service
router = APIRouter()
def serialize_router(router: Router, global_ssh_key: str | None = None) -> RouterResponse:
def serialize_router(router: Router, global_settings) -> RouterResponse:
has_router_key = bool((router.ssh_key or '').strip())
has_global_key = bool((global_ssh_key or '').strip())
has_global_key = bool((global_settings.global_ssh_key or '').strip())
router_user = (router.ssh_user or '').strip() or None
router_password = (router.ssh_password or '').strip() or None
default_swos_user = (global_settings.default_switchos_username or '').strip() or None
default_swos_password = (global_settings.default_switchos_password or '').strip() or None
effective_username = router_user
uses_global_switchos_credentials = False
has_effective_password = bool(router_password)
if router.device_type == 'switchos':
effective_username = router_user or default_swos_user
uses_global_switchos_credentials = bool(
(not router_user and default_swos_user) or (not router_password and default_swos_password)
)
has_effective_password = bool(router_password or default_swos_password)
payload = RouterResponse.model_validate(router, from_attributes=True).model_dump()
payload['uses_global_ssh_key'] = has_global_key and not has_router_key
payload['has_effective_ssh_key'] = has_router_key or has_global_key
payload['effective_username'] = effective_username
payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key
payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key)
payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials
payload['has_effective_password'] = has_effective_password
payload['supports_export'] = router.device_type == 'routeros'
payload['supports_restore_upload'] = router.device_type == 'routeros'
return RouterResponse.model_validate(payload)
@router.get("", response_model=list[RouterResponse])
@router.get('', response_model=list[RouterResponse])
def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
settings = settings_service.get_or_create(db)
global_settings = settings_service.get_or_create(db)
routers = db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all()
return [serialize_router(router, settings.global_ssh_key) for router in routers]
return [serialize_router(router, global_settings) for router in routers]
@router.post("", response_model=RouterResponse)
@router.post('', response_model=RouterResponse)
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = Router(**payload.model_dump(), owner_id=current_user.id)
router_data = payload.model_dump()
if router_data.get('device_type') == 'switchos' and router_data.get('ssh_user') is None:
router_data['ssh_user'] = ''
router = Router(**router_data, owner_id=current_user.id)
db.add(router)
db.commit()
db.refresh(router)
settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key)
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@router.get("/{router_id}", response_model=RouterResponse)
@router.get('/{router_id}', response_model=RouterResponse)
def get_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key)
raise HTTPException(status_code=404, detail='Device not found')
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@router.put("/{router_id}", response_model=RouterResponse)
@router.put('/{router_id}', response_model=RouterResponse)
def update_router(router_id: int, payload: RouterUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
for key, value in payload.model_dump(exclude_unset=True).items():
raise HTTPException(status_code=404, detail='Device not found')
changes = payload.model_dump(exclude_unset=True)
target_device_type = changes.get('device_type', router.device_type)
if target_device_type == 'switchos':
changes['ssh_key'] = None
if 'port' not in changes:
changes['port'] = 80
if changes.get('ssh_user') is None:
changes['ssh_user'] = ''
elif target_device_type == 'routeros' and 'port' not in changes and router.device_type != 'routeros':
changes['port'] = 22
if not changes.get('ssh_user'):
changes['ssh_user'] = router.ssh_user or 'admin'
for key, value in changes.items():
setattr(router, key, value)
db.add(router)
db.commit()
db.refresh(router)
settings = settings_service.get_or_create(db)
return serialize_router(router, settings.global_ssh_key)
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@router.delete("/{router_id}")
@router.delete('/{router_id}')
def delete_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
raise HTTPException(status_code=404, detail='Device not found')
for backup in list(router.backups):
path = Path(backup.file_path)
if path.exists():
path.unlink()
db.delete(router)
db.commit()
return {"message": "Router deleted"}
return {'message': 'Device deleted'}
@router.get("/{router_id}/test-connection", response_model=RouterTestConnection)
@router.get('/{router_id}/test-connection', response_model=RouterTestConnection)
def test_connection(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, settings.global_ssh_key)
raise HTTPException(status_code=404, detail='Device not found')
global_settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, global_settings)

View File

@@ -23,6 +23,9 @@ def serialize_settings(settings: GlobalSettings) -> SettingsResponse:
payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump()
payload['global_ssh_key'] = None
payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip())
payload['has_default_switchos_credentials'] = bool(
(settings.default_switchos_username or '').strip() or (settings.default_switchos_password or '').strip()
)
return SettingsResponse.model_validate(payload)

View File

@@ -42,10 +42,13 @@ def _run_lightweight_migrations() -> None:
tables = set(inspect(engine).get_table_names())
if 'global_settings' in tables:
_ensure_column('global_settings', 'connection_test_interval_minutes', 'INTEGER DEFAULT 0')
_ensure_column('global_settings', 'default_switchos_username', 'VARCHAR(120)')
_ensure_column('global_settings', 'default_switchos_password', 'VARCHAR(255)')
if 'users' in tables:
_ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL")
_ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL")
if 'routers' in tables:
_ensure_column('routers', 'device_type', "VARCHAR(32) DEFAULT 'routeros' NOT NULL")
_ensure_column('routers', 'last_connection_status', 'BOOLEAN')
_ensure_column('routers', 'last_connection_tested_at', 'DATETIME')
_ensure_column('routers', 'last_connection_error', 'TEXT')
@@ -53,6 +56,11 @@ def _run_lightweight_migrations() -> None:
_ensure_column('routers', 'last_connection_model', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_version', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_uptime', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_transport', 'VARCHAR(32)')
_ensure_column('routers', 'last_connection_server', 'VARCHAR(255)')
_ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)')
_ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)')
_ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN')
def init_db():

View File

@@ -11,6 +11,7 @@ class Router(Base):
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(120), nullable=False)
device_type = Column(String(32), nullable=False, default="routeros")
host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False, default=22)
ssh_user = Column(String(120), nullable=False, default="admin")
@@ -23,6 +24,11 @@ class Router(Base):
last_connection_model = Column(String(255), nullable=True)
last_connection_version = Column(String(255), nullable=True)
last_connection_uptime = Column(String(255), nullable=True)
last_connection_transport = Column(String(32), nullable=True)
last_connection_server = Column(String(255), nullable=True)
last_connection_auth_mode = Column(String(64), nullable=True)
last_connection_http_status = Column(String(32), nullable=True)
last_connection_backup_available = Column(Boolean, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan")

View File

@@ -15,6 +15,8 @@ class GlobalSettings(Base):
enable_auto_export = Column(Boolean, default=False)
connection_test_interval_minutes = Column(Integer, default=0)
global_ssh_key = Column(Text, nullable=True)
default_switchos_username = Column(String(120), nullable=True)
default_switchos_password = Column(String(255), nullable=True)
pushover_token = Column(String(255), nullable=True)
pushover_userkey = Column(String(255), nullable=True)
notify_failures_only = Column(Boolean, default=True)

View File

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

View File

@@ -1,16 +1,19 @@
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
DeviceType = Literal["routeros", "switchos"]
class RouterBase(BaseModel):
name: str = Field(min_length=1, max_length=120)
device_type: DeviceType = "routeros"
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=22, ge=1, le=65535)
ssh_user: str = Field(default="admin", min_length=1, max_length=120)
port: int | None = Field(default=None, ge=1, le=65535)
ssh_user: str | None = Field(default=None, max_length=120)
ssh_key: str | None = None
ssh_password: str | None = None
@@ -21,6 +24,23 @@ class RouterBase(BaseModel):
raise ValueError("Only letters, digits, dashes and underscores are allowed")
return value
@field_validator("host", "ssh_user", "ssh_key", "ssh_password", mode="before")
@classmethod
def normalize_text(cls, value: str | None) -> str | None:
normalized = (value or "").strip()
return normalized or None
@model_validator(mode="after")
def apply_device_defaults(self):
if self.device_type == "routeros":
self.port = self.port or 22
self.ssh_user = self.ssh_user or "admin"
return self
self.port = self.port or 80
self.ssh_key = None
return self
class RouterCreate(RouterBase):
pass
@@ -28,18 +48,30 @@ class RouterCreate(RouterBase):
class RouterUpdate(BaseModel):
name: str | None = None
device_type: DeviceType | None = None
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
ssh_user: str | None = None
ssh_key: str | None = None
ssh_password: str | None = None
@field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before")
@classmethod
def normalize_text(cls, value: str | None) -> str | None:
normalized = (value or "").strip()
return normalized or None
class RouterResponse(RouterBase):
id: int
owner_id: int
effective_username: str | None = None
uses_global_ssh_key: bool = False
has_effective_ssh_key: bool = False
uses_global_switchos_credentials: bool = False
has_effective_password: bool = False
supports_export: bool = False
supports_restore_upload: bool = False
last_connection_status: bool | None = None
last_connection_tested_at: datetime | None = None
last_connection_error: str | None = None
@@ -47,6 +79,11 @@ class RouterResponse(RouterBase):
last_connection_model: str | None = None
last_connection_version: str | None = None
last_connection_uptime: str | None = None
last_connection_transport: str | None = None
last_connection_server: str | None = None
last_connection_auth_mode: str | None = None
last_connection_http_status: str | None = None
last_connection_backup_available: bool | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
@@ -60,3 +97,8 @@ class RouterTestConnection(BaseModel):
hostname: str
version: str | None = None
error: str | None = None
transport: str | None = None
server: str | None = None
auth_mode: str | None = None
http_status: str | None = None
backup_available: bool | None = None

View File

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

View File

@@ -19,7 +19,7 @@ class BackupService:
def _router_for_user(self, db: Session, user: User, router_id: int) -> Router:
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first()
if not router:
raise HTTPException(status_code=404, detail='Router not found')
raise HTTPException(status_code=404, detail='Device not found')
return router
def _serialize_backup(self, backup: Backup):
@@ -28,6 +28,7 @@ class BackupService:
'id': backup.id,
'router_id': backup.router_id,
'router_name': backup.router.name if backup.router else None,
'device_type': backup.router.device_type if backup.router else 'routeros',
'file_path': backup.file_path,
'file_name': backup.file_name,
'backup_type': backup.backup_type,
@@ -179,6 +180,8 @@ class BackupService:
def export_router(self, db: Session, user: User, router_id: int) -> Backup:
router = self._router_for_user(db, user, router_id)
if router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices')
settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
name = f'{router.name}_{router.id}_{stamp}.rsc'
@@ -190,12 +193,14 @@ class BackupService:
db.add(backup)
db.commit()
db.refresh(backup)
log_service.add(db, f'Export OK for router {router.name}')
log_service.add(db, f'Export OK for device {router.name}')
notification_service.notify(settings, f'Export {router.name} OK', True)
return backup
except HTTPException:
raise
except Exception as exc:
notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False)
log_service.add(db, f'Export FAILED for router {router.name}: {exc}')
log_service.add(db, f'Export FAILED for device {router.name}: {exc}')
raise HTTPException(status_code=500, detail=str(exc)) from exc
def binary_backup(self, db: Session, user: User, router_id: int) -> Backup:
@@ -203,34 +208,41 @@ class BackupService:
settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
base_name = f'{router.name}_{router.id}_{stamp}'
name = f'{base_name}.backup'
extension = '.swb' if router.device_type == 'switchos' else '.backup'
name = f'{base_name}{extension}'
file_path = ensure_data_dir() / name
try:
router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key)
router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key, settings)
checksum = compute_checksum(str(file_path))
backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum)
db.add(backup)
db.commit()
db.refresh(backup)
log_service.add(db, f'Binary backup OK for router {router.name}')
log_service.add(db, f'Binary backup OK for device {router.name}')
notification_service.notify(settings, f'Backup {router.name} OK', True)
return backup
except HTTPException:
raise
except Exception as exc:
notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False)
log_service.add(db, f'Binary backup FAILED for router {router.name}: {exc}')
log_service.add(db, f'Binary backup FAILED for device {router.name}: {exc}')
raise HTTPException(status_code=500, detail=str(exc)) from exc
def upload_backup_to_router(self, db: Session, user: User, router_id: int, backup_id: int):
router = self._router_for_user(db, user, router_id)
if router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='Restore upload is available only for RouterOS devices')
backup = self.get_backup_for_user(db, user, backup_id)
if backup.backup_type != 'binary':
raise HTTPException(status_code=400, detail='Only binary backups can be uploaded')
if backup.router and backup.router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='SwitchOS backup files cannot be restored over SSH upload')
checksum = compute_checksum(backup.file_path)
if backup.checksum and checksum != backup.checksum:
raise HTTPException(status_code=400, detail='Checksum mismatch')
settings = settings_service.get_or_create(db)
router_service.upload_backup(router, backup.file_path, settings.global_ssh_key)
log_service.add(db, f'Upload backup OK for router {router.name}')
log_service.add(db, f'Upload backup OK for device {router.name}')
def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True):
backup = self.get_backup_for_user(db, user, backup_id)
@@ -274,9 +286,10 @@ class BackupService:
def email_backup(self, db: Session, user: User, backup_id: int):
backup = self.get_backup_for_user(db, user, backup_id)
settings = settings_service.get_or_create(db)
platform_name = 'SwitchOS' if backup.router and backup.router.device_type == 'switchos' else 'RouterOS'
noun = 'Export' if backup.backup_type == 'export' else 'Backup'
subject = f'RouterOS {noun}: {backup.file_name}'
body = f'Sending {backup.file_name} from router {backup.router.name}.'
subject = f'{platform_name} {noun}: {backup.file_name}'
body = f'Sending {backup.file_name} from device {backup.router.name}.'
notification_service.send_email(settings, subject, body, backup.file_path)
log_service.add(db, f'Email sent for backup {backup.file_name}')
@@ -284,6 +297,9 @@ class BackupService:
routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = []
for router in routers:
if router.device_type != 'routeros':
result.append({'router': router.name, 'status': 'skipped', 'message': 'SwitchOS devices do not support text export'})
continue
try:
backup = self.export_router(db, user, router.id)
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})

View File

@@ -6,6 +6,7 @@ import paramiko
from sqlalchemy.orm import Session
from app.models.router import Router
from app.services.swos_beta_service import swos_beta_service
class RouterService:
@@ -47,18 +48,25 @@ class RouterService:
return client
def export(self, router: Router, global_ssh_key: str | None = None) -> str:
if router.device_type != 'routeros':
raise ValueError('Export tekstowy jest dostępny tylko dla RouterOS.')
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command("/export")
output = stdout.read().decode("utf-8", errors="ignore")
_, stdout, _ = client.exec_command('/export')
output = stdout.read().decode('utf-8', errors='ignore')
client.close()
return output
def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None) -> str:
def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None, global_settings=None) -> str:
if router.device_type == 'switchos':
downloaded = swos_beta_service.download_backup_for_router(router, global_settings)
Path(local_path).write_bytes(downloaded.content)
return local_path
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command(f"/system backup save name={backup_name}")
_, stdout, _ = client.exec_command(f'/system backup save name={backup_name}')
stdout.channel.recv_exit_status()
sftp = client.open_sftp()
remote_file = f"{backup_name}.backup"
remote_file = f'{backup_name}.backup'
sftp.get(remote_file, local_path)
try:
sftp.remove(remote_file)
@@ -69,6 +77,8 @@ class RouterService:
return local_path
def upload_backup(self, router: Router, local_backup_path: str, global_ssh_key: str | None = None):
if router.device_type != 'routeros':
raise ValueError('Przywracanie plików jest dostępne tylko dla RouterOS.')
client = self._connect(router, global_ssh_key)
sftp = client.open_sftp()
target_name = Path(local_backup_path).name
@@ -76,64 +86,84 @@ class RouterService:
sftp.close()
client.close()
def probe_connection(self, router: Router, global_ssh_key: str | None = None):
def _probe_routeros_connection(self, router: Router, global_ssh_key: str | None = None):
tested_at = datetime.utcnow()
try:
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command("/system resource print without-paging")
resource_output = stdout.read().decode("utf-8", errors="ignore")
_, stdout, _ = client.exec_command("/system identity print")
identity_output = stdout.read().decode("utf-8", errors="ignore")
_, stdout, _ = client.exec_command('/system resource print without-paging')
resource_output = stdout.read().decode('utf-8', errors='ignore')
_, stdout, _ = client.exec_command('/system identity print')
identity_output = stdout.read().decode('utf-8', errors='ignore')
client.close()
model = "Unknown"
uptime = "Unknown"
hostname = "Unknown"
version = "Unknown"
model = 'Unknown'
uptime = 'Unknown'
hostname = 'Unknown'
version = 'Unknown'
for line in resource_output.splitlines():
if "board-name" in line:
model = line.split(":", 1)[1].strip()
if "uptime" in line:
uptime = line.split(":", 1)[1].strip()
if "version" in line:
version = line.split(":", 1)[1].strip()
if 'board-name' in line:
model = line.split(':', 1)[1].strip()
if 'uptime' in line:
uptime = line.split(':', 1)[1].strip()
if 'version' in line:
version = line.split(':', 1)[1].strip()
for line in identity_output.splitlines():
if "name" in line:
hostname = line.split(":", 1)[1].strip()
if 'name' in line:
hostname = line.split(':', 1)[1].strip()
return {
"success": True,
"tested_at": tested_at,
"model": model,
"uptime": uptime,
"hostname": hostname,
"version": version,
"error": None,
'success': True,
'tested_at': tested_at,
'model': model,
'uptime': uptime,
'hostname': hostname,
'version': version,
'error': None,
'transport': 'ssh',
'server': None,
'auth_mode': 'ssh',
'http_status': None,
'backup_available': None,
}
except Exception as exc:
return {
"success": False,
"tested_at": tested_at,
"model": "Unknown",
"uptime": "Unknown",
"hostname": router.name,
"version": None,
"error": str(exc),
'success': False,
'tested_at': tested_at,
'model': 'Unknown',
'uptime': 'Unknown',
'hostname': router.name,
'version': None,
'error': str(exc),
'transport': 'ssh',
'server': None,
'auth_mode': 'ssh',
'http_status': None,
'backup_available': None,
}
def probe_connection(self, router: Router, global_ssh_key: str | None = None, global_settings=None):
if router.device_type == 'switchos':
return swos_beta_service.probe_router(router, global_settings)
return self._probe_routeros_connection(router, global_ssh_key)
def _store_connection_result(self, db: Session, router: Router, result: dict):
router.last_connection_status = result["success"]
router.last_connection_tested_at = result["tested_at"]
router.last_connection_error = result.get("error")
router.last_connection_hostname = result.get("hostname")
router.last_connection_model = result.get("model")
router.last_connection_version = result.get("version")
router.last_connection_uptime = result.get("uptime")
router.last_connection_status = result['success']
router.last_connection_tested_at = result['tested_at']
router.last_connection_error = result.get('error')
router.last_connection_hostname = result.get('hostname')
router.last_connection_model = result.get('model')
router.last_connection_version = result.get('version')
router.last_connection_uptime = result.get('uptime')
router.last_connection_transport = result.get('transport')
router.last_connection_server = result.get('server')
router.last_connection_auth_mode = result.get('auth_mode')
router.last_connection_http_status = result.get('http_status')
router.last_connection_backup_available = result.get('backup_available')
db.add(router)
db.commit()
db.refresh(router)
return result
def test_connection(self, db: Session, router: Router, global_ssh_key: str | None = None):
result = self.probe_connection(router, global_ssh_key)
def test_connection(self, db: Session, router: Router, global_settings):
result = self.probe_connection(router, global_settings.global_ssh_key, global_settings)
return self._store_connection_result(db, router, result)

View File

@@ -1,7 +1,6 @@
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
import requests
@@ -41,7 +40,55 @@ class SwosBetaService:
server=response.headers.get('server'),
save_backup_visible='save backup' in html.lower(),
backup_endpoint_ok=backup_ok,
note='Moduł działa osobno i nie zapisuje kopii do głównego repozytorium.'
note='SwitchOS jest obsługiwany bezpośrednio w liście urządzeń.'
)
def probe_router(self, router, global_settings) -> dict:
payload = self.credentials_from_router(router, global_settings)
tested_at = datetime.utcnow()
try:
result = self.probe(payload)
return {
'success': result.success,
'tested_at': tested_at,
'model': 'SwitchOS',
'uptime': f'HTTP {result.status_code}',
'hostname': result.page_title or router.name,
'version': None,
'error': None,
'transport': 'http',
'server': result.server,
'auth_mode': result.auth_mode,
'http_status': str(result.status_code),
'backup_available': result.backup_endpoint_ok,
}
except Exception as exc:
return {
'success': False,
'tested_at': tested_at,
'model': 'SwitchOS',
'uptime': 'HTTP',
'hostname': router.name,
'version': None,
'error': str(exc),
'transport': 'http',
'server': None,
'auth_mode': None,
'http_status': None,
'backup_available': None,
}
def credentials_from_router(self, router, global_settings) -> SwosBetaCredentials:
username = (getattr(router, 'ssh_user', None) or '').strip() or (getattr(global_settings, 'default_switchos_username', None) or '').strip()
password = (getattr(router, 'ssh_password', None) or '').strip() or (getattr(global_settings, 'default_switchos_password', None) or '').strip()
if not username:
raise ValueError('Brak użytkownika SwitchOS. Ustaw dane w urządzeniu albo w ustawieniach globalnych.')
return SwosBetaCredentials(
host=router.host,
port=router.port or 80,
username=username,
password=password,
label=router.name,
)
def download_backup(self, payload: SwosBetaCredentials) -> DownloadedSwosBackup:
@@ -62,6 +109,9 @@ class SwosBetaService:
base_url=base_url,
)
def download_backup_for_router(self, router, global_settings) -> DownloadedSwosBackup:
return self.download_backup(self.credentials_from_router(router, global_settings))
def _request_with_fallback(self, method: str, url: str, payload: SwosBetaCredentials, allow_text_fallback: bool = True):
attempts = []
auth_variants = [
@@ -89,8 +139,8 @@ class SwosBetaService:
attempts.append(f'{label}:{exc.__class__.__name__}')
if last_response is not None:
raise ValueError(f'Nie udało się połączyć ze SwOS ({", ".join(attempts)}).')
raise ValueError('Nie udało się połączyć ze SwOS.')
raise ValueError(f'Nie udało się połączyć ze SwitchOS ({", ".join(attempts)}).')
raise ValueError('Nie udało się połączyć ze SwitchOS.')
def _build_base_url(self, host: str, port: int) -> str:
raw = host.strip()
@@ -118,7 +168,7 @@ class SwosBetaService:
label = payload.label or payload.host
safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos'
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
return f'{safe}-swos-{timestamp}.swb'
return f'{safe}-switchos-{timestamp}.swb'
swos_beta_service = SwosBetaService()

View File

@@ -1,74 +1,151 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
from app.schemas.swos_beta import SwosBetaProbeResponse
def _login(client: TestClient) -> str:
def _login(client: TestClient) -> tuple[str, dict[str, str]]:
response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'})
return response.json()['access_token']
token = response.json()['access_token']
return token, {'Authorization': f'Bearer {token}'}
def test_swos_probe_endpoint(monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_probe.db"}')
monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data'))
monkeypatch.setenv('SECRET_KEY', 'test-secret')
monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin')
monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin')
from app.api.routes import swos_beta
monkeypatch.setattr(
swos_beta.swos_beta_service,
'probe',
lambda payload: SwosBetaProbeResponse(
success=True,
base_url='http://192.168.88.1',
status_code=200,
auth_mode='digest',
page_title='SwOS',
content_type='text/html',
server='MikroTik',
save_backup_visible=True,
backup_endpoint_ok=True,
note='beta',
),
)
def test_switchos_list_marks_global_credentials_usage(monkeypatch, tmp_path):
from app.api.routes import settings as settings_route
with TestClient(app) as client:
token = _login(client)
response = client.post(
'/api/swos-beta/probe',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''},
headers={'Authorization': f'Bearer {token}'},
_, headers = _login(client)
settings_response = client.put(
'/api/settings',
json={
'backup_retention_days': 7,
'log_retention_days': 7,
'export_cron': '',
'binary_cron': '',
'retention_cron': '',
'enable_auto_export': False,
'connection_test_interval_minutes': 0,
'global_ssh_key': None,
'default_switchos_username': 'sw-admin',
'default_switchos_password': 'sw-pass',
'pushover_token': None,
'pushover_userkey': None,
'notify_failures_only': True,
'smtp_host': None,
'smtp_port': 587,
'smtp_login': None,
'smtp_password': None,
'smtp_notifications_enabled': False,
'recipient_email': None,
'clear_global_ssh_key': False,
},
headers=headers,
)
assert response.status_code == 200
assert response.json()['backup_endpoint_ok'] is True
assert settings_response.status_code == 200
assert settings_response.json()['has_default_switchos_credentials'] is True
create_response = client.post(
'/api/routers',
json={
'name': 'switch01',
'device_type': 'switchos',
'host': '192.168.88.2',
'port': 80,
'ssh_user': '',
'ssh_password': '',
'ssh_key': None,
},
headers=headers,
)
assert create_response.status_code == 200
list_response = client.get('/api/routers', headers=headers)
assert list_response.status_code == 200
payload = next(item for item in list_response.json() if item['name'] == 'switch01')
assert payload['device_type'] == 'switchos'
assert payload['uses_global_switchos_credentials'] is True
assert payload['effective_username'] == 'sw-admin'
def test_swos_download_endpoint(monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_download.db"}')
monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data'))
monkeypatch.setenv('SECRET_KEY', 'test-secret')
monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin')
monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin')
from app.api.routes import swos_beta
class FakeBackup:
filename = 'switch.swb'
content = b'binary-data'
content_type = 'application/octet-stream'
monkeypatch.setattr(swos_beta.swos_beta_service, 'download_backup', lambda payload: FakeBackup())
def test_switchos_connection_probe_is_exposed_in_device_route(monkeypatch):
from app.api.routes import routers as routers_route
with TestClient(app) as client:
token = _login(client)
response = client.post(
'/api/swos-beta/download',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''},
headers={'Authorization': f'Bearer {token}'},
_, headers = _login(client)
create_response = client.post(
'/api/routers',
json={
'name': 'switch02',
'device_type': 'switchos',
'host': '192.168.88.3',
'port': 80,
'ssh_user': 'admin',
'ssh_password': 'secret',
'ssh_key': None,
},
headers=headers,
)
device_id = create_response.json()['id']
monkeypatch.setattr(
routers_route.router_service,
'test_connection',
lambda db, router, global_settings: {
'success': True,
'tested_at': '2026-04-13T10:00:00',
'model': 'SwitchOS',
'uptime': 'HTTP 200',
'hostname': 'MikroTik SwitchOS',
'version': None,
'error': None,
'transport': 'http',
'server': 'MikroTik',
'auth_mode': 'digest',
'http_status': '200',
'backup_available': True,
},
)
response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers)
assert response.status_code == 200
assert response.content == b'binary-data'
assert 'attachment; filename="switch.swb"' == response.headers['content-disposition']
assert response.json()['transport'] == 'http'
assert response.json()['backup_available'] is True
def test_switchos_binary_backup_is_saved_as_swb(monkeypatch, tmp_path):
from app.services import backup_service as backup_service_module
from app.services import router_service as router_service_module
data_dir = tmp_path / 'data'
data_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(backup_service_module, 'ensure_data_dir', lambda: data_dir)
def fake_binary_backup(router, backup_name, local_path, global_ssh_key=None, global_settings=None):
Path(local_path).write_bytes(b'switchos-binary')
return local_path
monkeypatch.setattr(router_service_module.router_service, 'binary_backup', fake_binary_backup)
with TestClient(app) as client:
_, headers = _login(client)
create_response = client.post(
'/api/routers',
json={
'name': 'switch03',
'device_type': 'switchos',
'host': '192.168.88.4',
'port': 80,
'ssh_user': 'admin',
'ssh_password': 'secret',
'ssh_key': None,
},
headers=headers,
)
device_id = create_response.json()['id']
backup_response = client.post(f'/api/backups/router/{device_id}/binary', headers=headers)
assert backup_response.status_code == 200
assert backup_response.json()['backup_type'] == 'binary'
assert backup_response.json()['file_name'].endswith('.swb')

View File

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

View File

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

View File

@@ -104,7 +104,7 @@
</td>
<td>
<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><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warning'"></p-tag></td>
<td>
@@ -113,7 +113,7 @@
</td>
<td>
<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>
<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" 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==='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>
</div>
</td>

View File

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

View File

@@ -1,21 +1,21 @@
<app-page-header
[eyebrow]="'routers.profileEyebrow' | 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">
<button 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 *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]="(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="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
</div>
</app-page-header>
<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.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 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.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" *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 class="router-status-error" *ngIf="!connection.success && connection.error">
<strong>{{ 'routers.lastError' | translate }}</strong>
@@ -42,7 +46,7 @@
</ng-template>
</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">
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
<div>
@@ -81,7 +85,7 @@
</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">
<p-table [value]="exportBackups" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header">
@@ -107,7 +111,9 @@
</ng-template>
</p-table>
</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">
<p-table [value]="binaryBackups" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header">
@@ -123,7 +129,7 @@
<td>
<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" 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="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
</div>

View File

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

View File

@@ -11,13 +11,13 @@
</div>
<div class="inline-summary__divider"></div>
<div class="inline-summary__item">
<strong>{{ keyCount }}</strong>
<span>{{ 'routers.summaryKeyAccess' | translate }}</span>
<strong>{{ routerOsCount }}</strong>
<span>{{ 'routers.routeros' | translate }}</span>
</div>
<div class="inline-summary__divider"></div>
<div class="inline-summary__item">
<strong>{{ passwordCount }}</strong>
<span>{{ 'routers.summaryPasswordAccess' | translate }}</span>
<strong>{{ switchOsCount }}</strong>
<span>{{ 'routers.switchos' | translate }}</span>
</div>
</div>
@@ -30,16 +30,16 @@
<tr>
<td>
<div class="table-primary">{{ routerItem.name }}</div>
<small class="table-secondary">{{ 'routers.routerOsTarget' | translate }}</small>
<small class="table-secondary">{{ deviceTypeLabel(routerItem) }}</small>
</td>
<td>
<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>
<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]="hasEffectiveSshKey(routerItem) ? ((usesGlobalSshKey(routerItem) ? 'routers.globalKeyMode' : 'routers.keyMode') | translate) : ('routers.noKey' | translate)" [severity]="hasEffectiveSshKey(routerItem) ? 'success' : 'secondary'"></p-tag>
<p-tag [value]="primaryAccessTag(routerItem).value" [severity]="primaryAccessTag(routerItem).severity"></p-tag>
<p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
</div>
</td>
<td>
@@ -54,33 +54,101 @@
</p-table>
</app-section-card>
<p-dialog [(visible)]="visible" [modal]="true" [header]="dialogTitle" [style]="{ width: '640px' }" styleClass="router-dialog">
<form [formGroup]="form" (ngSubmit)="save()" class="form-grid-2">
<span class="form-field">
<label>{{ 'routers.name' | translate }}</label>
<input pInputText formControlName="name" placeholder="core-router-waw" />
</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="22" />
</span>
<span class="form-field">
<label>{{ 'routers.sshUser' | translate }}</label>
<input pInputText formControlName="ssh_user" placeholder="admin" />
</span>
<span class="form-field form-field--full">
<label>{{ 'routers.sshPassword' | translate }}</label>
<input pInputText formControlName="ssh_password" [placeholder]="'routers.optionalPassword' | translate" />
</span>
<span class="form-field form-field--full">
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
<textarea pInputTextarea formControlName="ssh_key" rows="7" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
<div class="dialog-actions">
<p-dialog [(visible)]="visible" [modal]="true" [draggable]="false" [resizable]="false" [style]="{ width: 'min(760px, 96vw)' }" styleClass="router-dialog">
<ng-template pTemplate="header">
<div class="router-dialog-header">
<div class="router-dialog-header__icon">
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-sitemap' : 'pi-server'"></i>
</div>
<div class="router-dialog-header__text">
<div class="router-dialog-header__eyebrow">
{{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }}
</div>
<div class="router-dialog-header__title">{{ dialogTitle }}</div>
<small>
{{
selectedDeviceType === 'switchos'
? ('routers.switchDialogSubtitle' | translate)
: ('routers.routerDialogSubtitle' | translate)
}}
</small>
</div>
</div>
</ng-template>
<form [formGroup]="form" (ngSubmit)="save()" class="router-dialog-form">
<section class="router-dialog-panel">
<div class="router-dialog-panel__header">
<div>
<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="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
</div>

View File

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

View File

@@ -253,7 +253,7 @@
</div>
<div class="settings-page-side">
<details class="settings-collapse settings-collapse--sticky">
<details class="settings-collapse settings-collapse--sticky" open>
<summary>
<span>{{ 'settings.sshDefaultsTitle' | translate }}</span>
<small>{{ 'settings.sshDefaultsSubtitle' | translate }}</small>
@@ -297,6 +297,25 @@
<small class="settings-ssh-note" *ngIf="clearStoredSshKey">{{ 'settings.sshKeyClearNotice' | translate }}</small>
</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>
</details>
</div>

View File

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

View File

@@ -35,7 +35,7 @@
},
"nav": {
"dashboard": "Dashboard",
"routers": "Routers",
"routers": "Devices",
"files": "Repository",
"settings": "Settings",
"logs": "Logs",
@@ -81,7 +81,7 @@
"subtitle": "Overview of backups, exports and operational activity in one place.",
"exportAll": "Export all",
"binaryAll": "Binary backup",
"managedRouters": "Routers",
"managedRouters": "Devices",
"managedRoutersHint": "All managed devices",
"inventoryTag": "Fleet",
"exportsCard": "Exports",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Quick snapshot of the most important storage and backup indicators."
},
"routers": {
"title": "Routers",
"detailTitle": "Router details",
"add": "Add router",
"title": "Devices",
"detailTitle": "Device details",
"add": "Add device",
"eyebrow": "device inventory",
"subtitle": "Manage RouterOS endpoints, credentials and fleet-wide backup jobs.",
"subtitle": "Manage RouterOS and SwitchOS devices plus their backups.",
"registeredDevices": "Registered devices",
"fleetTag": "Fleet",
"sshPassword": "SSH password",
"sshPassword": "Password",
"passwordHint": "Password-based access",
"credsTag": "Creds",
"sshKey": "SSH key",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22",
"defaultPortHint": "Standard SSH endpoints",
"portTag": "Port",
"listTitle": "Router list",
"listSubtitle": "Compact operational view of every managed device.",
"listTitle": "Device list",
"listSubtitle": "Unified view for RouterOS and SwitchOS devices.",
"name": "Name",
"endpoint": "Endpoint",
"access": "Access",
@@ -161,15 +161,15 @@
"keyMode": "Key",
"globalKeyMode": "Global key",
"noKey": "No key",
"createDialogTitle": "Add router",
"editDialogTitle": "Edit router",
"createDialogTitle": "Add device",
"editDialogTitle": "Edit device",
"host": "Host",
"port": "Port",
"sshUser": "SSH user",
"sshUser": "Username",
"sshPrivateKey": "SSH private key",
"optionalPassword": "Optional password",
"optionalPrivateKey": "Optional private key",
"saveRouter": "Save router",
"saveRouter": "Save device",
"profileEyebrow": "router profile",
"detailSubtitle": "Device operations and backup history",
"exportOne": "Export",
@@ -184,7 +184,7 @@
"connectionLabelHint": "Status from the latest automatic or manual connection test",
"probeTag": "Probe",
"accessTag": "Access",
"sshUserHint": "Current SSH user",
"sshUserHint": "Effective device login",
"deviceStatusTitle": "Device status",
"deviceStatusSubtitle": "Stored metadata from the latest automatic or manual connection test.",
"hostname": "Hostname",
@@ -200,7 +200,7 @@
"exportsTableTitle": "Exports",
"exportsTableSubtitle": "Readable RouterOS snapshots.",
"binaryTableTitle": "Binary backups",
"binaryTableSubtitle": "Files ready for device restore.",
"binaryTableSubtitle": "Binary files and SwitchOS backups.",
"summaryKeyAccess": "with key-based access",
"summaryPasswordAccess": "with password access",
"connectionStateTitle": "Connection state",
@@ -211,7 +211,28 @@
"openPreviewModal": "Open preview",
"diffModalHint": "The last loaded diff is available in a modal.",
"openDiffModal": "Open diff",
"noDiff": "Choose an export and run a diff to see the latest comparison."
"noDiff": "Choose an export and run a diff to see the latest comparison.",
"routeros": "RouterOS",
"switchos": "SwitchOS",
"deviceType": "Device type",
"defaultCredentials": "Default credentials",
"localCredentials": "Local credentials",
"noCredentials": "No credentials",
"switchUserPlaceholder": "Empty = use settings default",
"switchPasswordPlaceholder": "Empty = use settings default",
"switchDefaultsHint": "For SwitchOS you can leave username and password empty to use the defaults from settings.",
"downloadSwitchBackup": "Download backup",
"httpStatus": "HTTP status",
"serverHeader": "Server header",
"authMode": "Auth mode",
"backupEndpoint": "Backup endpoint",
"backupAvailable": "Available",
"backupUnavailable": "Unavailable",
"connectionSectionTitle": "Connection profile",
"connectionSectionHint": "Basic device identity and endpoint used to reach it.",
"credentialsSectionTitle": "Access and credentials",
"routerDialogSubtitle": "Set the device endpoint, SSH access data and your preferred login method.",
"switchDialogSubtitle": "Set the SwitchOS endpoint and optional local or shared credentials from settings."
},
"files": {
"title": "Repository",
@@ -233,14 +254,14 @@
"searchLabel": "Search",
"searchPlaceholder": "Search by file or router",
"typeLabel": "Type",
"routerLabel": "Router",
"routerLabel": "Device",
"sortLabel": "Sort by",
"orderLabel": "Order",
"allTypes": "All types",
"allRouters": "All routers",
"allRouters": "All devices",
"sortNewest": "Newest",
"sortName": "Name",
"sortRouter": "Router",
"sortRouter": "Device",
"sortType": "Type",
"tableTitle": "Repository table",
"tableSubtitle": "Artifacts available for download, e-mail and restore.",
@@ -248,7 +269,7 @@
"compareSelected": "Compare selected exports",
"fileColumn": "File",
"typeColumn": "Type",
"routerColumn": "Router",
"routerColumn": "Device",
"createdColumn": "Created",
"actionsColumn": "Actions",
"checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Pushover user key",
"pushoverTokenPlaceholder": "Application token",
"pushoverUserKeyPlaceholder": "User key",
"sshDefaultsTitle": "SSH defaults",
"sshDefaultsSubtitle": "Optional shared private key used across managed routers.",
"sshDefaultsTitle": "Default Credentials",
"sshDefaultsSubtitle": "Shared SSH key and default SwitchOS login used across managed devices.",
"globalSshPrivateKey": "Global SSH private key",
"globalSshPrivateKeyPlaceholder": "Paste PEM or OpenSSH private key",
"save": "Save settings",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Choose the default language and font family for the whole application.",
"interfacePreferencesTag": "Per-user",
"fontFamily": "Font family",
"fontDefault": "Default"
"fontDefault": "Default",
"switchosDefaultsTitle": "Default SwitchOS credentials",
"switchosDefaultsHint": "Used when a SwitchOS device has no local username or password.",
"defaultSwitchosUsername": "Default SwitchOS username",
"defaultSwitchosPassword": "Default SwitchOS password"
},
"logs": {
"title": "Logs",

View File

@@ -35,7 +35,7 @@
},
"nav": {
"dashboard": "Panel",
"routers": "Routers",
"routers": "Dispositivos",
"files": "Repositorio",
"settings": "Ajustes",
"logs": "Registros",
@@ -81,7 +81,7 @@
"subtitle": "Resumen de copias, exportaciones y actividad operativa en un solo lugar.",
"exportAll": "Exportar todo",
"binaryAll": "Copia binaria",
"managedRouters": "Routers",
"managedRouters": "Dispositivos",
"managedRoutersHint": "Todos los dispositivos gestionados",
"inventoryTag": "Flota",
"exportsCard": "Exportaciones",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Vista rápida de los indicadores más importantes de almacenamiento y copias."
},
"routers": {
"title": "Routers",
"detailTitle": "Detalles del router",
"add": "Añadir router",
"title": "Dispositivos",
"detailTitle": "Detalles del dispositivo",
"add": "Agregar dispositivo",
"eyebrow": "inventario de dispositivos",
"subtitle": "Gestiona endpoints de RouterOS, credenciales y tareas de copia para toda la flota.",
"subtitle": "Administra dispositivos RouterOS y SwitchOS y sus copias.",
"registeredDevices": "Dispositivos registrados",
"fleetTag": "Flota",
"sshPassword": "Contraseña SSH",
"sshPassword": "Contraseña",
"passwordHint": "Acceso con contraseña",
"credsTag": "Credenciales",
"sshKey": "Clave SSH",
@@ -150,8 +150,8 @@
"defaultPort": "Puerto 22",
"defaultPortHint": "Endpoints SSH estándar",
"portTag": "Puerto",
"listTitle": "Lista de routers",
"listSubtitle": "Vista operativa compacta de todos los dispositivos gestionados.",
"listTitle": "Lista de dispositivos",
"listSubtitle": "Vista unificada para RouterOS y SwitchOS.",
"name": "Nombre",
"endpoint": "Endpoint",
"access": "Acceso",
@@ -161,15 +161,15 @@
"keyMode": "Clave",
"globalKeyMode": "Clave global",
"noKey": "Sin clave",
"createDialogTitle": "Añadir router",
"editDialogTitle": "Editar router",
"createDialogTitle": "Agregar dispositivo",
"editDialogTitle": "Editar dispositivo",
"host": "Host",
"port": "Puerto",
"sshUser": "Usuario SSH",
"sshUser": "Usuario",
"sshPrivateKey": "Clave privada SSH",
"optionalPassword": "Contraseña opcional",
"optionalPrivateKey": "Clave privada opcional",
"saveRouter": "Guardar router",
"saveRouter": "Guardar dispositivo",
"profileEyebrow": "perfil del router",
"detailSubtitle": "Operaciones del dispositivo e historial de copias",
"exportOne": "Exportar",
@@ -211,7 +211,28 @@
"openPreviewModal": "Abrir vista previa",
"diffModalHint": "El último diff cargado está disponible en un modal.",
"openDiffModal": "Abrir diff",
"noDiff": "Elige una exportación y ejecuta un diff para ver la última comparación."
"noDiff": "Elige una exportación y ejecuta un diff para ver la última comparación.",
"routeros": "RouterOS",
"switchos": "SwitchOS",
"deviceType": "Tipo de dispositivo",
"defaultCredentials": "Credenciales por defecto",
"localCredentials": "Credenciales locales",
"noCredentials": "Sin credenciales",
"switchUserPlaceholder": "Vacío = usar ajustes",
"switchPasswordPlaceholder": "Vacío = usar ajustes",
"switchDefaultsHint": "Para SwitchOS puedes dejar usuario y contraseña vacíos para usar los valores por defecto.",
"downloadSwitchBackup": "Descargar copia",
"httpStatus": "Estado HTTP",
"serverHeader": "Cabecera Server",
"authMode": "Modo de autenticación",
"backupEndpoint": "Endpoint de copia",
"backupAvailable": "Disponible",
"backupUnavailable": "No disponible",
"connectionSectionTitle": "Perfil de conexión",
"connectionSectionHint": "Identidad básica del dispositivo y endpoint usado para alcanzarlo.",
"credentialsSectionTitle": "Acceso y credenciales",
"routerDialogSubtitle": "Configura el endpoint del dispositivo, los datos SSH y el método de acceso preferido.",
"switchDialogSubtitle": "Configura el endpoint de SwitchOS y las credenciales locales u opcionales compartidas desde ajustes."
},
"files": {
"title": "Repositorio",
@@ -233,14 +254,14 @@
"searchLabel": "Buscar",
"searchPlaceholder": "Buscar por archivo o router",
"typeLabel": "Tipo",
"routerLabel": "Router",
"routerLabel": "Dispositivo",
"sortLabel": "Ordenar por",
"orderLabel": "Orden",
"allTypes": "Todos los tipos",
"allRouters": "Todos los routers",
"allRouters": "Todos los dispositivos",
"sortNewest": "Más nuevo",
"sortName": "Nombre",
"sortRouter": "Router",
"sortRouter": "Dispositivo",
"sortType": "Tipo",
"tableTitle": "Tabla del repositorio",
"tableSubtitle": "Artefactos disponibles para descarga, correo y restauración.",
@@ -248,7 +269,7 @@
"compareSelected": "Comparar exportaciones seleccionadas",
"fileColumn": "Archivo",
"typeColumn": "Tipo",
"routerColumn": "Router",
"routerColumn": "Dispositivo",
"createdColumn": "Creado",
"actionsColumn": "Acciones",
"checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Clave de usuario de Pushover",
"pushoverTokenPlaceholder": "Token de la aplicación",
"pushoverUserKeyPlaceholder": "Clave de usuario",
"sshDefaultsTitle": "Valores SSH por defecto",
"sshDefaultsSubtitle": "Clave privada compartida opcional usada en todos los routers gestionados.",
"sshDefaultsTitle": "Credenciales predeterminadas",
"sshDefaultsSubtitle": "Clave SSH compartida y acceso por defecto de SwitchOS usados por los dispositivos gestionados.",
"globalSshPrivateKey": "Clave privada SSH global",
"globalSshPrivateKeyPlaceholder": "Pega la clave privada PEM u OpenSSH",
"globalSshPrivateKeyHiddenPlaceholder": "La clave guardada está oculta. Introduce la contraseña arriba para verla o pega aquí una nueva clave para reemplazarla.",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Elige el idioma predeterminado y la familia tipográfica para toda la aplicación.",
"interfacePreferencesTag": "Por usuario",
"fontFamily": "Familia tipográfica",
"fontDefault": "Predeterminada"
"fontDefault": "Predeterminada",
"switchosDefaultsTitle": "Credenciales por defecto de SwitchOS",
"switchosDefaultsHint": "Se usan cuando un dispositivo SwitchOS no tiene usuario o contraseña local.",
"defaultSwitchosUsername": "Usuario SwitchOS por defecto",
"defaultSwitchosPassword": "Contraseña SwitchOS por defecto"
},
"logs": {
"title": "Registros",

View File

@@ -35,7 +35,7 @@
},
"nav": {
"dashboard": "Dashbord",
"routers": "Rutere",
"routers": "Enheter",
"files": "Repository",
"settings": "Innstillinger",
"logs": "Logger",
@@ -81,7 +81,7 @@
"subtitle": "Oversikt over backuper, eksportfiler og operativ aktivitet på ett sted.",
"exportAll": "Eksporter alle",
"binaryAll": "Binær backup",
"managedRouters": "Rutere",
"managedRouters": "Enheter",
"managedRoutersHint": "Alle administrerte enheter",
"inventoryTag": "Flåte",
"exportsCard": "Eksporter",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Rask oversikt over de viktigste lagrings- og backupindikatorene."
},
"routers": {
"title": "Rutere",
"detailTitle": "Ruterdetaljer",
"add": "Legg til ruter",
"title": "Enheter",
"detailTitle": "Enhetsdetaljer",
"add": "Legg til enhet",
"eyebrow": "enhetsinventar",
"subtitle": "Administrer RouterOS-endepunkter, legitimasjon og backupjobber for hele flåten.",
"subtitle": "Administrer RouterOS- og SwitchOS-enheter og sikkerhetskopier.",
"registeredDevices": "Registrerte enheter",
"fleetTag": "Flåte",
"sshPassword": "SSH-passord",
"sshPassword": "Passord",
"passwordHint": "Passordbasert tilgang",
"credsTag": "Tilgang",
"sshKey": "SSH-nøkkel",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22",
"defaultPortHint": "Standard SSH-endepunkter",
"portTag": "Port",
"listTitle": "Ruterliste",
"listSubtitle": "Kompakt driftsvisning av alle administrerte enheter.",
"listTitle": "Enhetsliste",
"listSubtitle": "Felles visning for RouterOS og SwitchOS.",
"name": "Navn",
"endpoint": "Endepunkt",
"access": "Tilgang",
@@ -161,15 +161,15 @@
"keyMode": "Nøkkel",
"globalKeyMode": "Global nøkkel",
"noKey": "Ingen nøkkel",
"createDialogTitle": "Legg til ruter",
"editDialogTitle": "Rediger ruter",
"createDialogTitle": "Legg til enhet",
"editDialogTitle": "Rediger enhet",
"host": "Vert",
"port": "Port",
"sshUser": "SSH-bruker",
"sshUser": "Bruker",
"sshPrivateKey": "SSH privat nøkkel",
"optionalPassword": "Valgfritt passord",
"optionalPrivateKey": "Valgfri privat nøkkel",
"saveRouter": "Lagre ruter",
"saveRouter": "Lagre enhet",
"profileEyebrow": "ruterprofil",
"detailSubtitle": "Enhetsoperasjoner og backuphistorikk",
"exportOne": "Eksport",
@@ -211,7 +211,28 @@
"openPreviewModal": "Åpne forhåndsvisning",
"diffModalHint": "Sist lastede diff er tilgjengelig i en modal.",
"openDiffModal": "Åpne diff",
"noDiff": "Velg en eksport og kjør diff for å se siste sammenligning."
"noDiff": "Velg en eksport og kjør diff for å se siste sammenligning.",
"routeros": "RouterOS",
"switchos": "SwitchOS",
"deviceType": "Enhetstype",
"defaultCredentials": "Standard legitimasjon",
"localCredentials": "Lokal legitimasjon",
"noCredentials": "Ingen legitimasjon",
"switchUserPlaceholder": "Tom = bruk innstillinger",
"switchPasswordPlaceholder": "Tom = bruk innstillinger",
"switchDefaultsHint": "For SwitchOS kan du la bruker og passord være tomme for å bruke standardverdier fra innstillinger.",
"downloadSwitchBackup": "Last ned backup",
"httpStatus": "HTTP-status",
"serverHeader": "Server-header",
"authMode": "Autentiseringsmodus",
"backupEndpoint": "Backup-endepunkt",
"backupAvailable": "Tilgjengelig",
"backupUnavailable": "Utilgjengelig",
"connectionSectionTitle": "Tilkoblingsprofil",
"connectionSectionHint": "Grunnleggende enhetsidentitet og endpoint som brukes for å nå den.",
"credentialsSectionTitle": "Tilgang og legitimasjon",
"routerDialogSubtitle": "Sett enhetens endpoint, SSH-data og foretrukket innloggingsmetode.",
"switchDialogSubtitle": "Sett SwitchOS-endpoint og valgfrie lokale eller delte standarddata fra innstillinger."
},
"files": {
"title": "Repository",
@@ -233,14 +254,14 @@
"searchLabel": "Søk",
"searchPlaceholder": "Søk etter fil eller ruter",
"typeLabel": "Type",
"routerLabel": "Ruter",
"routerLabel": "Enhet",
"sortLabel": "Sorter etter",
"orderLabel": "Rekkefølge",
"allTypes": "Alle typer",
"allRouters": "Alle rutere",
"allRouters": "Alle enheter",
"sortNewest": "Nyeste",
"sortName": "Navn",
"sortRouter": "Ruter",
"sortRouter": "Enhet",
"sortType": "Type",
"tableTitle": "Repositorytabell",
"tableSubtitle": "Artefakter tilgjengelige for nedlasting, e-post og gjenoppretting.",
@@ -248,7 +269,7 @@
"compareSelected": "Sammenlign valgte eksporter",
"fileColumn": "Fil",
"typeColumn": "Type",
"routerColumn": "Ruter",
"routerColumn": "Enhet",
"createdColumn": "Opprettet",
"actionsColumn": "Handlinger",
"checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Pushover-brukernøkkel",
"pushoverTokenPlaceholder": "Applikasjonstoken",
"pushoverUserKeyPlaceholder": "Brukernøkkel",
"sshDefaultsTitle": "SSH-standarder",
"sshDefaultsSubtitle": "Valgfri delt privat nøkkel som brukes tvers av administrerte rutere.",
"sshDefaultsTitle": "Standard legitimasjon",
"sshDefaultsSubtitle": "Delt SSH-nøkkel og standard innlogging for SwitchOS brukt på administrerte enheter.",
"globalSshPrivateKey": "Global SSH privat nøkkel",
"globalSshPrivateKeyPlaceholder": "Lim inn PEM- eller OpenSSH-privat nøkkel",
"globalSshPrivateKeyHiddenPlaceholder": "Den lagrede nøkkelen er skjult. Skriv inn passordet over for å se den, eller lim inn en ny nøkkel her for å erstatte den.",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Velg standardspråk og skriftfamilie for hele applikasjonen.",
"interfacePreferencesTag": "Per bruker",
"fontFamily": "Skriftfamilie",
"fontDefault": "Standard"
"fontDefault": "Standard",
"switchosDefaultsTitle": "Standard SwitchOS-legitimasjon",
"switchosDefaultsHint": "Brukes når en SwitchOS-enhet ikke har lokalt brukernavn eller passord.",
"defaultSwitchosUsername": "Standard SwitchOS-bruker",
"defaultSwitchosPassword": "Standard SwitchOS-passord"
},
"logs": {
"title": "Logger",

View File

@@ -35,7 +35,7 @@
},
"nav": {
"dashboard": "Dashboard",
"routers": "Routery",
"routers": "Urządzenia",
"files": "Repozytorium",
"settings": "Ustawienia",
"logs": "Logi",
@@ -81,7 +81,7 @@
"subtitle": "Przegląd backupów, eksportów i aktywności operacyjnej w jednym miejscu.",
"exportAll": "Eksportuj wszystko",
"binaryAll": "Backup binarny",
"managedRouters": "Routery",
"managedRouters": "Urządzenia",
"managedRoutersHint": "Wszystkie zarządzane urządzenia",
"inventoryTag": "Flota",
"exportsCard": "Eksporty",
@@ -134,14 +134,14 @@
"storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."
},
"routers": {
"title": "Routery",
"detailTitle": "Szczegóły routera",
"add": "Dodaj router",
"title": "Urządzenia",
"detailTitle": "Szczegóły urządzenia",
"add": "Dodaj urządzenie",
"eyebrow": "inwentaryzacja urządzeń",
"subtitle": "Zarządzaj endpointami RouterOS, poświadczeniami i zadaniami backupu dla całej floty.",
"subtitle": "Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.",
"registeredDevices": "Zarejestrowane urządzenia",
"fleetTag": "Flota",
"sshPassword": "Hasło SSH",
"sshPassword": "Hasło",
"passwordHint": "Dostęp hasłem",
"credsTag": "Dostęp",
"sshKey": "Klucz SSH",
@@ -150,8 +150,8 @@
"defaultPort": "Port 22",
"defaultPortHint": "Standardowe endpointy SSH",
"portTag": "Port",
"listTitle": "Lista routerów",
"listSubtitle": "Zwięzły widok operacyjny wszystkich zarządzanych urządzeń.",
"listTitle": "Lista urządzeń",
"listSubtitle": "Wspólny widok RouterOS i SwitchOS.",
"name": "Nazwa",
"endpoint": "Endpoint",
"access": "Dostęp",
@@ -161,15 +161,15 @@
"keyMode": "Klucz",
"globalKeyMode": "Klucz globalny",
"noKey": "Bez klucza",
"createDialogTitle": "Dodaj router",
"editDialogTitle": "Edytuj router",
"createDialogTitle": "Dodaj urządzenie",
"editDialogTitle": "Edytuj urządzenie",
"host": "Host",
"port": "Port",
"sshUser": "Użytkownik SSH",
"sshUser": "Użytkownik",
"sshPrivateKey": "Klucz prywatny SSH",
"optionalPassword": "Opcjonalne hasło",
"optionalPrivateKey": "Opcjonalny klucz prywatny",
"saveRouter": "Zapisz router",
"saveRouter": "Zapisz urządzenie",
"profileEyebrow": "profil routera",
"detailSubtitle": "Operacje urządzenia i historia backupów",
"exportOne": "Eksport",
@@ -184,7 +184,7 @@
"connectionLabelHint": "Status z ostatniego automatycznego lub ręcznego testu połączenia",
"probeTag": "Test",
"accessTag": "Dostęp",
"sshUserHint": "Bieżący użytkownik SSH",
"sshUserHint": "Efektywny login urządzenia",
"deviceStatusTitle": "Status urządzenia",
"deviceStatusSubtitle": "Zapisane metadane z ostatniego automatycznego lub ręcznego testu połączenia.",
"hostname": "Hostname",
@@ -200,7 +200,7 @@
"exportsTableTitle": "Eksporty",
"exportsTableSubtitle": "Czytelne snapshoty RouterOS.",
"binaryTableTitle": "Backupy binarne",
"binaryTableSubtitle": "Pliki do odtworzenia urządzenia.",
"binaryTableSubtitle": "Pliki binarne i kopie SwitchOS.",
"summaryKeyAccess": "z dostępem kluczem",
"summaryPasswordAccess": "z dostępem hasłem",
"connectionStateTitle": "Stan połączenia",
@@ -211,7 +211,28 @@
"openPreviewModal": "Otwórz podgląd",
"diffModalHint": "Ostatnio załadowany diff jest dostępny w modalu.",
"openDiffModal": "Otwórz diff",
"noDiff": "Wybierz eksport i uruchom diff, aby zobaczyć ostatnie porównanie."
"noDiff": "Wybierz eksport i uruchom diff, aby zobaczyć ostatnie porównanie.",
"routeros": "RouterOS",
"switchos": "SwitchOS",
"deviceType": "Typ urządzenia",
"defaultCredentials": "Domyślne dane",
"localCredentials": "Lokalne dane",
"noCredentials": "Brak danych",
"switchUserPlaceholder": "Puste = z ustawień",
"switchPasswordPlaceholder": "Puste = z ustawień",
"switchDefaultsHint": "Dla SwitchOS możesz zostawić użytkownika i hasło puste, aby użyć wartości domyślnych z ustawień.",
"downloadSwitchBackup": "Pobierz backup",
"httpStatus": "Status HTTP",
"serverHeader": "Nagłówek Server",
"authMode": "Tryb autoryzacji",
"backupEndpoint": "Endpoint backupu",
"backupAvailable": "Dostępny",
"backupUnavailable": "Niedostępny",
"connectionSectionTitle": "Profil połączenia",
"connectionSectionHint": "Podstawowa tożsamość urządzenia i endpoint używany do połączenia.",
"credentialsSectionTitle": "Dostęp i poświadczenia",
"routerDialogSubtitle": "Ustaw adres urządzenia, dane dostępu SSH i preferowaną metodę logowania.",
"switchDialogSubtitle": "Ustaw endpoint SwitchOS i opcjonalne poświadczenia lokalne lub domyślne z ustawień."
},
"files": {
"title": "Repozytorium",
@@ -233,14 +254,14 @@
"searchLabel": "Szukaj",
"searchPlaceholder": "Szukaj po pliku lub routerze",
"typeLabel": "Typ",
"routerLabel": "Router",
"routerLabel": "Urządzenie",
"sortLabel": "Sortowanie",
"orderLabel": "Kolejność",
"allTypes": "Wszystkie typy",
"allRouters": "Wszystkie routery",
"allRouters": "Wszystkie urządzenia",
"sortNewest": "Najnowsze",
"sortName": "Nazwa",
"sortRouter": "Router",
"sortRouter": "Urządzenie",
"sortType": "Typ",
"tableTitle": "Tabela repozytorium",
"tableSubtitle": "Artefakty dostępne do pobrania, wysyłki e-mail i przywracania.",
@@ -248,7 +269,7 @@
"compareSelected": "Porównaj zaznaczone eksporty",
"fileColumn": "Plik",
"typeColumn": "Typ",
"routerColumn": "Router",
"routerColumn": "Urządzenie",
"createdColumn": "Utworzono",
"actionsColumn": "Akcje",
"checksum": "Checksum",
@@ -311,8 +332,8 @@
"pushoverUserKey": "Klucz użytkownika Pushover",
"pushoverTokenPlaceholder": "Token aplikacji",
"pushoverUserKeyPlaceholder": "Klucz użytkownika",
"sshDefaultsTitle": "Domyślne SSH",
"sshDefaultsSubtitle": "Opcjonalny współdzielony klucz prywatny używany przez zarządzane routery.",
"sshDefaultsTitle": "Domyślne Poświadczenia",
"sshDefaultsSubtitle": "Wspólny klucz SSH oraz domyślne logowanie SwitchOS używane przez urządzenia.",
"globalSshPrivateKey": "Globalny klucz prywatny SSH",
"globalSshPrivateKeyPlaceholder": "Wklej klucz prywatny PEM lub OpenSSH",
"save": "Zapisz ustawienia",
@@ -377,7 +398,11 @@
"interfacePreferencesHint": "Wybierz domyślny język i rodzinę fontów dla całej aplikacji.",
"interfacePreferencesTag": "Per-user",
"fontFamily": "Rodzina fontów",
"fontDefault": "Domyślna"
"fontDefault": "Domyślna",
"switchosDefaultsTitle": "Domyślne dane SwitchOS",
"switchosDefaultsHint": "Używane, gdy urządzenie SwitchOS nie ma własnego loginu lub hasła.",
"defaultSwitchosUsername": "Domyślny użytkownik SwitchOS",
"defaultSwitchosPassword": "Domyślne hasło SwitchOS"
},
"logs": {
"title": "Logi",

View File

@@ -3389,3 +3389,198 @@ body.dark-theme .p-confirm-dialog .p-confirm-dialog-icon{
@media (max-width: 991px) {
}
.router-dialog .p-dialog-header{
padding: 1.15rem 1.2rem 1rem;
align-items: flex-start;
background:
linear-gradient(135deg, rgba(75, 144, 217, 0.16), rgba(79, 181, 147, 0.1)),
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
var(--surface-1);
border-bottom: 1px solid rgba(75, 144, 217, 0.18);
}
.router-dialog .p-dialog-header-icons{
align-self: flex-start;
margin-top: 0.2rem;
}
.router-dialog .p-dialog-content{
padding: 0 1.2rem 1.2rem;
background: linear-gradient(180deg, rgba(75, 144, 217, 0.06) 0%, rgba(75, 144, 217, 0) 180px), var(--surface-1);
}
.router-dialog-header{
display: flex;
align-items: center;
gap: 0.9rem;
width: calc(100% - 0.5rem);
}
.router-dialog-header__icon{
width: 3rem;
height: 3rem;
border-radius: 18px;
display: grid;
place-items: center;
flex-shrink: 0;
background: linear-gradient(135deg, rgba(75, 144, 217, 0.24), rgba(79, 181, 147, 0.14));
border: 1px solid rgba(75, 144, 217, 0.2);
color: var(--primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.router-dialog-header__icon .pi{
font-size: 1.05rem;
}
.router-dialog-header__text{
min-width: 0;
}
.router-dialog-header__eyebrow{
font-family: var(--font-title);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-soft);
}
.router-dialog-header__title{
margin-top: 0.2rem;
font-family: var(--font-title);
font-size: 1.18rem;
line-height: 1.25;
}
.router-dialog-header__text small{
display: block;
margin-top: 0.3rem;
max-width: 42rem;
line-height: 1.55;
}
.router-dialog-form{
display: grid;
gap: 1rem;
}
.router-dialog-panel{
padding: 1rem;
border-radius: 22px;
border: 1px solid var(--border-color);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)), var(--surface-0);
box-shadow: var(--shadow-md);
}
.router-dialog-panel:first-child{
margin-top: 1rem;
}
.router-dialog-panel__header{
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.95rem;
}
.router-dialog-panel__header strong{
display: block;
font-family: var(--font-title);
font-size: 0.88rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.router-dialog-panel__header p{
margin: 0.35rem 0 0;
color: var(--text-soft);
line-height: 1.55;
}
.router-dialog-pill{
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.85rem;
border-radius: 999px;
border: 1px solid rgba(75, 144, 217, 0.18);
background: rgba(75, 144, 217, 0.08);
color: var(--text-main);
font-family: var(--font-title);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.router-dialog-grid{
gap: 0.95rem 1rem;
}
.router-dialog-note{
margin-top: 0.9rem;
padding: 0.85rem 0.95rem;
border-radius: 16px;
border: 1px solid rgba(75, 144, 217, 0.18);
background: rgba(75, 144, 217, 0.08);
color: var(--text-soft);
display: flex;
align-items: flex-start;
gap: 0.65rem;
line-height: 1.55;
}
.router-dialog-note .pi{
margin-top: 0.1rem;
color: var(--accent);
}
.router-dialog .p-inputtext,
.router-dialog .p-dropdown,
.router-dialog .p-inputtextarea{
background: rgba(255, 255, 255, 0.02);
}
.router-dialog .p-inputtextarea{
min-height: 11rem;
}
.router-dialog-actions{
justify-content: space-between;
gap: 0.75rem;
padding-top: 0.1rem;
}
.router-dialog-actions .p-button{
min-width: 11rem;
}
@media (max-width: 720px) {
.router-dialog .p-dialog-header{
padding: 1rem 0.85rem 0.85rem;
}
.router-dialog .p-dialog-content{
padding: 0 0.85rem 0.95rem;
}
.router-dialog-header{
align-items: flex-start;
}
.router-dialog-panel__header{
flex-direction: column;
}
.router-dialog-actions{
flex-direction: column-reverse;
align-items: stretch;
}
.router-dialog-actions .p-button{
width: 100%;
min-width: 0;
}
}