new features

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 15:43:25 +02:00
parent 1a2ae0d607
commit 92a0f99fb3
17 changed files with 580 additions and 154 deletions

View File

@@ -1,18 +1,23 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.models.router import Router from app.models.router import Router
from app.models.user import User from app.models.user import User
from app.schemas.router import RouterCreate, RouterResponse, RouterTestConnection, RouterUpdate from app.schemas.router import RouterCreate, RouterPingStatus, RouterResponse, RouterTestConnection, RouterUpdate
from app.services.router_service import router_service from app.services.router_service import router_service
from app.services.settings_service import settings_service from app.services.settings_service import settings_service
router = APIRouter() router = APIRouter()
class RouterPingBulkResponse(BaseModel):
items: list[RouterPingStatus]
def serialize_router(router: Router, global_settings) -> RouterResponse: def serialize_router(router: Router, global_settings) -> RouterResponse:
has_router_key = bool((router.ssh_key or '').strip()) has_router_key = bool((router.ssh_key or '').strip())
has_global_key = bool((global_settings.global_ssh_key or '').strip()) has_global_key = bool((global_settings.global_ssh_key or '').strip())
@@ -49,6 +54,12 @@ def list_routers(current_user: User = Depends(get_current_user), db: Session = D
return [serialize_router(router, global_settings) for router in routers] return [serialize_router(router, global_settings) for router in routers]
@router.get('/ping-statuses', response_model=RouterPingBulkResponse)
def list_ping_statuses(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
routers = db.query(Router).filter(Router.owner_id == current_user.id).all()
return RouterPingBulkResponse(items=router_service.ping_many(routers))
@router.post('', response_model=RouterResponse) @router.post('', response_model=RouterResponse)
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router_data = payload.model_dump() router_data = payload.model_dump()

View File

@@ -61,6 +61,10 @@ def _run_lightweight_migrations() -> None:
_ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)') _ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)')
_ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)') _ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)')
_ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN') _ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN')
_ensure_column('routers', 'disable_all_backups', 'BOOLEAN DEFAULT 0 NOT NULL')
_ensure_column('routers', 'disable_export_backups', 'BOOLEAN DEFAULT 0 NOT NULL')
_ensure_column('routers', 'disable_binary_backups', 'BOOLEAN DEFAULT 0 NOT NULL')
_ensure_column('routers', 'disable_ping', 'BOOLEAN DEFAULT 0 NOT NULL')
def init_db(): def init_db():

View File

@@ -29,6 +29,10 @@ class Router(Base):
last_connection_auth_mode = Column(String(64), nullable=True) last_connection_auth_mode = Column(String(64), nullable=True)
last_connection_http_status = Column(String(32), nullable=True) last_connection_http_status = Column(String(32), nullable=True)
last_connection_backup_available = Column(Boolean, nullable=True) last_connection_backup_available = Column(Boolean, nullable=True)
disable_all_backups = Column(Boolean, nullable=False, default=False)
disable_export_backups = Column(Boolean, nullable=False, default=False)
disable_binary_backups = Column(Boolean, nullable=False, default=False)
disable_ping = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False) created_at = Column(DateTime, server_default=func.now(), nullable=False)
backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan") backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan")

View File

@@ -16,6 +16,10 @@ class RouterBase(BaseModel):
ssh_user: str | None = Field(default=None, max_length=120) ssh_user: str | None = Field(default=None, max_length=120)
ssh_key: str | None = None ssh_key: str | None = None
ssh_password: str | None = None ssh_password: str | None = None
disable_all_backups: bool = False
disable_export_backups: bool = False
disable_binary_backups: bool = False
disable_ping: bool = False
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@@ -54,6 +58,10 @@ class RouterUpdate(BaseModel):
ssh_user: str | None = None ssh_user: str | None = None
ssh_key: str | None = None ssh_key: str | None = None
ssh_password: str | None = None ssh_password: str | None = None
disable_all_backups: bool | None = None
disable_export_backups: bool | None = None
disable_binary_backups: bool | None = None
disable_ping: bool | None = None
@field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before") @field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before")
@classmethod @classmethod
@@ -72,6 +80,10 @@ class RouterResponse(RouterBase):
has_effective_password: bool = False has_effective_password: bool = False
supports_export: bool = False supports_export: bool = False
supports_restore_upload: bool = False supports_restore_upload: bool = False
disable_all_backups: bool = False
disable_export_backups: bool = False
disable_binary_backups: bool = False
disable_ping: bool = False
last_connection_status: bool | None = None last_connection_status: bool | None = None
last_connection_tested_at: datetime | None = None last_connection_tested_at: datetime | None = None
last_connection_error: str | None = None last_connection_error: str | None = None
@@ -102,3 +114,10 @@ class RouterTestConnection(BaseModel):
auth_mode: str | None = None auth_mode: str | None = None
http_status: str | None = None http_status: str | None = None
backup_available: bool | None = None backup_available: bool | None = None
class RouterPingStatus(BaseModel):
router_id: int
reachable: bool
latency_ms: float | None = None
disabled: bool = False

View File

@@ -191,6 +191,8 @@ class BackupService:
router = self._router_for_user(db, user, router_id) router = self._router_for_user(db, user, router_id)
if router.device_type != 'routeros': if router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices') raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices')
if router.disable_all_backups or router.disable_export_backups:
raise HTTPException(status_code=400, detail='Exports are disabled for this device')
settings = settings_service.get_or_create(db) settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S') stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
name = f'{router.name}_{router.id}_{stamp}.rsc' name = f'{router.name}_{router.id}_{stamp}.rsc'
@@ -214,6 +216,8 @@ class BackupService:
def binary_backup(self, db: Session, user: User, router_id: int) -> Backup: def binary_backup(self, db: Session, user: User, router_id: int) -> Backup:
router = self._router_for_user(db, user, router_id) router = self._router_for_user(db, user, router_id)
if router.disable_all_backups or router.disable_binary_backups:
raise HTTPException(status_code=400, detail='Binary backups are disabled for this device')
settings = settings_service.get_or_create(db) settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S') stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
base_name = f'{router.name}_{router.id}_{stamp}' base_name = f'{router.name}_{router.id}_{stamp}'
@@ -306,6 +310,13 @@ class BackupService:
routers = db.query(Router).filter(Router.owner_id == user.id).all() routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = [] result = []
for router in routers: for router in routers:
if router.disable_all_backups or router.disable_export_backups:
result.append({
'router': router.name,
'status': 'skipped',
'message': 'Exports are disabled for this device',
})
continue
if (router.device_type or 'routeros').lower() != 'routeros': if (router.device_type or 'routeros').lower() != 'routeros':
result.append({ result.append({
'router': router.name, 'router': router.name,
@@ -324,6 +335,13 @@ class BackupService:
routers = db.query(Router).filter(Router.owner_id == user.id).all() routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = [] result = []
for router in routers: for router in routers:
if router.disable_all_backups or router.disable_binary_backups:
result.append({
'router': router.name,
'status': 'skipped',
'message': 'Binary backups are disabled for this device',
})
continue
try: try:
backup = self.binary_backup(db, user, router.id) backup = self.binary_backup(db, user, router.id)
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})

View File

@@ -1,6 +1,10 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
import io import io
from pathlib import Path from pathlib import Path
import platform
import re
import subprocess
import paramiko import paramiko
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,6 +15,30 @@ from app.services.swos_beta_service import swos_beta_service
class RouterService: class RouterService:
def ping(self, router: Router):
if getattr(router, 'disable_ping', False):
return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': True}
count_flag = '-n' if platform.system().lower().startswith('win') else '-c'
timeout_flag = '-w' if platform.system().lower().startswith('win') else '-W'
command = ['ping', count_flag, '1', timeout_flag, '1', router.host]
try:
completed = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
output = completed.stdout + "\n" + completed.stderr
if completed.returncode != 0:
return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': False}
match = re.search(r'time[=<]\s*([0-9]+(?:[.,][0-9]+)?)\s*ms', output, re.IGNORECASE)
latency = float(match.group(1).replace(',', '.')) if match else None
return {'router_id': router.id, 'reachable': True, 'latency_ms': latency, 'disabled': False}
except Exception:
return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': False}
def ping_many(self, routers: list[Router]):
if not routers:
return []
max_workers = min(8, max(1, len(routers)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(self.ping, routers))
def _load_pkey(self, ssh_key_str: str): def _load_pkey(self, ssh_key_str: str):
key_str = (ssh_key_str or "").strip() key_str = (ssh_key_str or "").strip()
key_buffer = io.StringIO(key_str) key_buffer = io.StringIO(key_str)

View File

@@ -212,6 +212,8 @@ class SchedulerService:
with SessionLocal() as db: with SessionLocal() as db:
routers = db.query(Router).all() routers = db.query(Router).all()
for router in routers: for router in routers:
if router.disable_all_backups or router.disable_export_backups:
continue
try: try:
backup_service.export_router(db, type('U', (), {'id': router.owner_id})(), router.id) backup_service.export_router(db, type('U', (), {'id': router.owner_id})(), router.id)
except Exception as exc: except Exception as exc:
@@ -221,6 +223,8 @@ class SchedulerService:
with SessionLocal() as db: with SessionLocal() as db:
routers = db.query(Router).all() routers = db.query(Router).all()
for router in routers: for router in routers:
if router.disable_all_backups or router.disable_binary_backups:
continue
try: try:
backup_service.binary_backup(db, type('U', (), {'id': router.owner_id})(), router.id) backup_service.binary_backup(db, type('U', (), {'id': router.owner_id})(), router.id)
except Exception as exc: except Exception as exc:

View File

@@ -5193,9 +5193,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5213,9 +5210,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5233,9 +5227,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5253,9 +5244,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5273,9 +5261,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5293,9 +5278,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5313,9 +5295,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5851,9 +5830,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5875,9 +5851,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5899,9 +5872,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5923,9 +5893,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5947,9 +5914,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5971,9 +5935,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6189,9 +6150,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6206,9 +6164,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6223,9 +6178,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6240,9 +6192,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6257,9 +6206,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6274,9 +6220,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6291,9 +6234,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6308,9 +6248,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6325,9 +6262,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6342,9 +6276,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6359,9 +6290,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6376,9 +6304,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6393,9 +6318,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -4,10 +4,11 @@
[subtitle]="subtitle" [subtitle]="subtitle"
> >
<div header-actions class="header-actions-row"> <div header-actions class="header-actions-row">
<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-pencil" [label]="'common.edit' | translate" (click)="openEdit()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" (click)="runBinary()"></button> <button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" [disabled]="routerItem?.disable_all_backups || routerItem?.disable_export_backups" (click)="runExport()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" [disabled]="routerItem?.disable_all_backups || routerItem?.disable_binary_backups" (click)="runBinary()"></button>
<button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button> <button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button>
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button> <button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteDevice' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
</div> </div>
</app-page-header> </app-page-header>
@@ -46,7 +47,58 @@
</ng-template> </ng-template>
</app-section-card> </app-section-card>
<div class="router-detail-inspection-stack" *ngIf="!isSwitchos"> <app-section-card [title]="'routers.backupSettingsTitle' | translate" [subtitle]="'routers.backupSettingsHint' | translate">
<form [formGroup]="settingsForm" class="device-settings-form" (ngSubmit)="saveSettings()">
<div class="device-settings-stack">
<label class="device-toggle device-toggle--primary" [class.is-active]="settingsForm.controls.disable_all_backups.value">
<input type="checkbox" formControlName="disable_all_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-ban"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableAllBackups' | translate }}</strong>
<small>{{ 'routers.disableAllBackupsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_all_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" *ngIf="!isSwitchos" [class.is-active]="settingsForm.controls.disable_export_backups.value">
<input type="checkbox" formControlName="disable_export_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-file-export"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableExports' | translate }}</strong>
<small>{{ 'routers.disableExportsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_export_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" [class.is-active]="settingsForm.controls.disable_binary_backups.value">
<input type="checkbox" formControlName="disable_binary_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-database"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableBinaryBackups' | translate }}</strong>
<small>{{ 'routers.disableBinaryBackupsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_binary_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" [class.is-active]="settingsForm.controls.disable_ping.value">
<input type="checkbox" formControlName="disable_ping" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-wifi"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disablePing' | translate }}</strong>
<small>{{ 'routers.disablePingHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_ping.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
</div>
<div class="dialog-actions device-settings-actions">
<button pButton type="submit" [loading]="savingSettings" [disabled]="savingSettings" [label]="'common.save' | translate"></button>
</div>
</form>
</app-section-card>
</div>
<div class="router-detail-split-grid" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate"> <app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview"> <div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
<div> <div>
@@ -83,7 +135,6 @@
</ng-template> </ng-template>
</app-section-card> </app-section-card>
</div> </div>
</div>
<div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos"> <div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate"> <app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate">
@@ -168,3 +219,84 @@
<pre class="code-preview preview-dialog__content">{{ diffText }}</pre> <pre class="code-preview preview-dialog__content">{{ diffText }}</pre>
</ng-template> </ng-template>
</p-dialog> </p-dialog>
<p-dialog [(visible)]="editVisible" [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">{{ 'routers.editDialogTitle' | translate }}</div>
<small>
{{
selectedDeviceType === 'switchos'
? ('routers.switchDialogSubtitle' | translate)
: ('routers.routerDialogSubtitle' | translate)
}}
</small>
</div>
</div>
</ng-template>
<form [formGroup]="form" (ngSubmit)="saveEdit()" 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>
</div>
<div class="form-grid-2 router-dialog-grid">
<span class="form-field">
<label>{{ 'routers.name' | translate }}</label>
<input pInputText formControlName="name" />
</span>
<span class="form-field">
<label>{{ 'routers.deviceType' | translate }}</label>
<p-select [appendTo]="'body'" [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
<label>{{ 'routers.host' | translate }}</label>
<input pInputText formControlName="host" />
</span>
<span class="form-field">
<label>{{ 'routers.port' | translate }}</label>
<input pInputText type="number" formControlName="port" />
</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>
</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 pTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
</div>
</section>
<div class="dialog-actions router-dialog-actions">
<button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="editVisible=false"></button>
<button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
</div>
</form>
</p-dialog>

View File

@@ -1,12 +1,16 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpResponse } from '@angular/common/http'; import { HttpResponse } from '@angular/common/http';
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog'; import { DialogModule } from 'primeng/dialog';
import { InputTextModule } from 'primeng/inputtext';
import { SelectModule } from 'primeng/select';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { TagModule } from 'primeng/tag'; import { TagModule } from 'primeng/tag';
import { TextareaModule } from 'primeng/textarea';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { UiService } from '../../core/services/ui.service'; import { UiService } from '../../core/services/ui.service';
@@ -22,9 +26,16 @@ interface DeviceItem {
host: string; host: string;
port: number; port: number;
device_type: DeviceType; device_type: DeviceType;
ssh_user?: string | null;
ssh_password?: string | null;
ssh_key?: string | null;
effective_username?: string | null; effective_username?: string | null;
supports_export: boolean; supports_export: boolean;
supports_restore_upload: boolean; supports_restore_upload: boolean;
disable_all_backups?: boolean;
disable_export_backups?: boolean;
disable_binary_backups?: boolean;
disable_ping?: boolean;
last_connection_status?: boolean | null; last_connection_status?: boolean | null;
last_connection_tested_at?: string | null; last_connection_tested_at?: string | null;
last_connection_error?: string | null; last_connection_error?: string | null;
@@ -80,7 +91,21 @@ interface BackupDiffResponse {
@Component({ @Component({
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, ButtonModule, DialogModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent], imports: [
CommonModule,
TranslateModule,
ReactiveFormsModule,
ButtonModule,
DialogModule,
TableModule,
TagModule,
InputTextModule,
SelectModule,
TextareaModule,
PageHeaderComponent,
SectionCardComponent,
StatCardComponent
],
templateUrl: './router-detail-page.component.html' templateUrl: './router-detail-page.component.html'
}) })
export class RouterDetailPageComponent implements OnInit { export class RouterDetailPageComponent implements OnInit {
@@ -88,6 +113,7 @@ export class RouterDetailPageComponent implements OnInit {
private readonly api = inject(ApiService); private readonly api = inject(ApiService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly ui = inject(UiService); private readonly ui = inject(UiService);
private readonly fb = inject(FormBuilder);
routerId!: number; routerId!: number;
routerItem: DeviceItem | null = null; routerItem: DeviceItem | null = null;
@@ -98,16 +124,42 @@ export class RouterDetailPageComponent implements OnInit {
previewTitle = ''; previewTitle = '';
previewVisible = false; previewVisible = false;
diffVisible = false; diffVisible = false;
editVisible = false;
diffData: BackupDiffResponse | null = null; diffData: BackupDiffResponse | null = null;
exporting = false; exporting = false;
runningBinary = false; runningBinary = false;
testing = false; testing = false;
deletingRouter = false; deletingRouter = false;
saving = false;
savingSettings = false;
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'],
ssh_password: '',
ssh_key: ''
});
readonly settingsForm = this.fb.nonNullable.group({
disable_all_backups: false,
disable_export_backups: false,
disable_binary_backups: false,
disable_ping: false
});
get isSwitchos(): boolean { get isSwitchos(): boolean {
return this.routerItem?.device_type === 'switchos'; return this.routerItem?.device_type === 'switchos';
} }
get selectedDeviceType(): DeviceType {
return this.form.controls.device_type.value;
}
get exportBackups(): BackupItem[] { get exportBackups(): BackupItem[] {
return this.backups.filter((item) => item.backup_type === 'export'); return this.backups.filter((item) => item.backup_type === 'export');
} }
@@ -145,6 +197,14 @@ export class RouterDetailPageComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.routerId = Number(this.route.snapshot.paramMap.get('id')); this.routerId = Number(this.route.snapshot.paramMap.get('id'));
this.form.controls.device_type.valueChanges.subscribe((deviceType) => {
this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType);
});
this.settingsForm.controls.disable_all_backups.valueChanges.subscribe((disabled) => {
if (disabled) {
this.settingsForm.patchValue({ disable_export_backups: true, disable_binary_backups: true }, { emitEvent: false });
}
});
this.load(); this.load();
} }
@@ -152,12 +212,86 @@ export class RouterDetailPageComponent implements OnInit {
this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => { this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => {
this.routerItem = routerItem; this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem); this.connection = this.mapStoredConnection(routerItem);
this.patchSettingsForm(routerItem);
}); });
this.api.http.get<BackupItem[]>(`${this.api.baseUrl}/backups/router/${this.routerId}`).subscribe((r) => (this.backups = r)); this.api.http.get<BackupItem[]>(`${this.api.baseUrl}/backups/router/${this.routerId}`).subscribe((r) => (this.backups = r));
} }
openEdit() {
if (!this.routerItem) {
return;
}
this.form.reset({
name: this.routerItem.name,
device_type: this.routerItem.device_type,
host: this.routerItem.host,
port: this.routerItem.port,
ssh_user: this.routerItem.ssh_user ?? '',
ssh_password: this.routerItem.ssh_password ?? '',
ssh_key: this.routerItem.ssh_key ?? ''
});
this.editVisible = true;
}
saveEdit() {
if (this.form.invalid || this.saving) {
return;
}
this.saving = true;
const payload = this.form.getRawValue();
if (payload.device_type === 'switchos') {
payload.ssh_key = '';
}
this.api.http.put<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({
next: (routerItem) => {
this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem);
this.editVisible = false;
this.ui.success('toast.routerUpdated');
},
complete: () => {
this.saving = false;
}
});
}
saveSettings() {
if (!this.routerItem || this.savingSettings) {
return;
}
this.savingSettings = true;
const payload = this.settingsForm.getRawValue();
if (this.routerItem.device_type === 'switchos') {
payload.disable_export_backups = true;
}
if (payload.disable_all_backups) {
payload.disable_export_backups = true;
payload.disable_binary_backups = true;
}
this.api.http.put<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({
next: (routerItem) => {
this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem);
this.patchSettingsForm(routerItem);
this.ui.success('toast.routerUpdated');
},
complete: () => {
this.savingSettings = false;
}
});
}
private patchSettingsForm(item: DeviceItem) {
this.settingsForm.reset({
disable_all_backups: !!item.disable_all_backups,
disable_export_backups: !!item.disable_export_backups,
disable_binary_backups: !!item.disable_binary_backups,
disable_ping: !!item.disable_ping
}, { emitEvent: false });
}
runExport() { runExport() {
if (this.exporting || this.isSwitchos) { if (this.exporting || this.isSwitchos || this.routerItem?.disable_all_backups || this.routerItem?.disable_export_backups) {
return; return;
} }
this.exporting = true; this.exporting = true;
@@ -173,7 +307,7 @@ export class RouterDetailPageComponent implements OnInit {
} }
runBinary() { runBinary() {
if (this.runningBinary) { if (this.runningBinary || this.routerItem?.disable_all_backups || this.routerItem?.disable_binary_backups) {
return; return;
} }
this.runningBinary = true; this.runningBinary = true;
@@ -332,6 +466,14 @@ export class RouterDetailPageComponent implements OnInit {
}; };
} }
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 });
}
private openBlob(response: HttpResponse<Blob>, fallbackName: string) { private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
const disposition = response.headers.get('content-disposition') || ''; const disposition = response.headers.get('content-disposition') || '';
const match = disposition.match(/filename="?([^";]+)"?/i); const match = disposition.match(/filename="?([^";]+)"?/i);

View File

@@ -24,7 +24,7 @@
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate"> <app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
<p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table"> <p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr> <tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'routers.backupPolicy' | translate }}</th><th>{{ 'routers.ping' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-routerItem> <ng-template pTemplate="body" let-routerItem>
<tr> <tr>
@@ -42,6 +42,13 @@
<p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag> <p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
</div> </div>
</td> </td>
<td>
<div class="table-primary">{{ backupPolicyLabel(routerItem) }}</div>
<small class="table-secondary" *ngIf="routerItem.disable_all_backups">{{ 'common.disabled' | translate }}</small>
</td>
<td>
<small class="table-secondary">{{ pingLabel(routerItem) }}</small>
</td>
<td> <td>
<div class="table-actions table-actions--labels"> <div class="table-actions table-actions--labels">
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button> <button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>

View File

@@ -32,6 +32,17 @@ interface RouterItem {
has_effective_ssh_key?: boolean; has_effective_ssh_key?: boolean;
uses_global_switchos_credentials?: boolean; uses_global_switchos_credentials?: boolean;
has_effective_password?: boolean; has_effective_password?: boolean;
disable_all_backups?: boolean;
disable_export_backups?: boolean;
disable_binary_backups?: boolean;
disable_ping?: boolean;
}
interface RouterPingStatus {
router_id: number;
reachable: boolean;
latency_ms?: number | null;
disabled?: boolean;
} }
@Component({ @Component({
@@ -62,6 +73,7 @@ export class RoutersPageComponent implements OnInit {
editingId: number | null = null; editingId: number | null = null;
saving = false; saving = false;
routers: RouterItem[] = []; routers: RouterItem[] = [];
pingStatuses: Record<number, RouterPingStatus> = {};
readonly deviceTypeOptions = [ readonly deviceTypeOptions = [
{ label: 'RouterOS', value: 'routeros' }, { label: 'RouterOS', value: 'routeros' },
{ label: 'SwitchOS', value: 'switchos' } { label: 'SwitchOS', value: 'switchos' }
@@ -100,12 +112,31 @@ export class RoutersPageComponent implements OnInit {
} }
load() { load() {
this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => (this.routers = r)); this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => {
this.routers = r;
this.loadPingStatuses();
});
}
loadPingStatuses() {
this.api.http.get<{ items: RouterPingStatus[] }>(`${this.api.baseUrl}/routers/ping-statuses`).subscribe({
next: (response) => {
this.pingStatuses = response.items.reduce<Record<number, RouterPingStatus>>((acc, item) => {
acc[item.router_id] = item;
return acc;
}, {});
},
error: () => {
this.pingStatuses = {};
}
});
} }
openCreate() { openCreate() {
this.editingId = null; this.editingId = null;
this.form.reset({ name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' }); this.form.reset({
name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: ''
});
this.visible = true; this.visible = true;
} }
@@ -174,6 +205,35 @@ export class RoutersPageComponent implements OnInit {
return item.effective_username || item.ssh_user || '—'; return item.effective_username || item.ssh_user || '—';
} }
pingLabel(item: RouterItem): string {
if (item.disable_ping) {
return this.ui.instant('routers.pingDisabled');
}
const ping = this.pingStatuses[item.id];
if (!ping) {
return this.ui.instant('routers.pingChecking');
}
if (!ping.reachable) {
return this.ui.instant('routers.noPing');
}
const value = typeof ping.latency_ms === 'number' ? Math.round(ping.latency_ms) : null;
return value === null ? this.ui.instant('routers.pingAvailable') : `${this.ui.instant('routers.ping')}: ${value} ms`;
}
backupPolicyLabel(item: RouterItem): string {
if (item.disable_all_backups) {
return this.ui.instant('routers.backupsDisabledAll');
}
const parts: string[] = [];
if (!item.disable_export_backups && item.device_type === 'routeros') {
parts.push(this.ui.instant('routers.exportOne'));
}
if (!item.disable_binary_backups) {
parts.push(this.ui.instant('routers.binaryOne'));
}
return parts.length ? parts.join(' / ') : this.ui.instant('routers.backupsDisabledAll');
}
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } { primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
if (item.device_type === 'switchos') { if (item.device_type === 'switchos') {
if (item.uses_global_switchos_credentials) { if (item.uses_global_switchos_credentials) {

View File

@@ -31,7 +31,8 @@
"desc": "Descending", "desc": "Descending",
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",
"failed": "Failed" "failed": "Failed",
"save": "Save"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -175,7 +176,7 @@
"exportOne": "Export", "exportOne": "Export",
"binaryOne": "Binary", "binaryOne": "Binary",
"testConnection": "Test connection", "testConnection": "Test connection",
"deleteRouter": "Delete router", "deleteRouter": "Delete device",
"exportsLabel": "Exports", "exportsLabel": "Exports",
"exportsLabelHint": "Text snapshots", "exportsLabelHint": "Text snapshots",
"binaryLabel": "Binary backups", "binaryLabel": "Binary backups",
@@ -232,7 +233,25 @@
"connectionSectionHint": "Basic device identity and endpoint used to reach it.", "connectionSectionHint": "Basic device identity and endpoint used to reach it.",
"credentialsSectionTitle": "Access and credentials", "credentialsSectionTitle": "Access and credentials",
"routerDialogSubtitle": "Set the device endpoint, SSH access data and your preferred login method.", "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." "switchDialogSubtitle": "Set the SwitchOS endpoint and optional local or shared credentials from settings.",
"backupPolicy": "Backups",
"backupSettingsTitle": "Backup settings",
"backupSettingsHint": "You can disable all backups or only selected backup types for this device.",
"deleteDevice": "Delete device",
"disableAllBackupsHint": "Automatically checks exports and binary backups.",
"disableExportsHint": "Blocks only text exports for this device.",
"disableBinaryBackupsHint": "Blocks only binary backups for this device.",
"disablePing": "Disable ping for this device",
"disablePingHint": "This device will not be pinged on the devices list.",
"pingDisabled": "Ping disabled",
"disableAllBackups": "Disable all backups for this device",
"disableExports": "Disable exports",
"disableBinaryBackups": "Disable binary backups",
"backupsDisabledAll": "All backups disabled",
"ping": "Ping",
"pingChecking": "Checking...",
"noPing": "No ping",
"pingAvailable": "Ping available"
}, },
"files": { "files": {
"title": "Repository", "title": "Repository",
@@ -463,7 +482,7 @@
"header": "Confirmation", "header": "Confirmation",
"deleteBackup": "Delete this backup file?", "deleteBackup": "Delete this backup file?",
"deleteSelectedFiles": "Delete {{count}} selected files?", "deleteSelectedFiles": "Delete {{count}} selected files?",
"deleteRouterWithFiles": "Delete the router and all related files?", "deleteRouterWithFiles": "Delete the device and all related files?",
"deleteLogsOlderThan": "Delete logs older than {{days}} days?" "deleteLogsOlderThan": "Delete logs older than {{days}} days?"
}, },
"footer": { "footer": {

View File

@@ -31,7 +31,8 @@
"desc": "Descendente", "desc": "Descendente",
"enabled": "Activado", "enabled": "Activado",
"disabled": "Desactivado", "disabled": "Desactivado",
"failed": "Error" "failed": "Error",
"save": "Guardar"
}, },
"nav": { "nav": {
"dashboard": "Panel", "dashboard": "Panel",
@@ -175,7 +176,7 @@
"exportOne": "Exportar", "exportOne": "Exportar",
"binaryOne": "Binario", "binaryOne": "Binario",
"testConnection": "Probar conexión", "testConnection": "Probar conexión",
"deleteRouter": "Eliminar router", "deleteRouter": "Eliminar dispositivo",
"exportsLabel": "Exportaciones", "exportsLabel": "Exportaciones",
"exportsLabelHint": "Instantáneas de texto", "exportsLabelHint": "Instantáneas de texto",
"binaryLabel": "Copias binarias", "binaryLabel": "Copias binarias",
@@ -463,7 +464,7 @@
"header": "Confirmación", "header": "Confirmación",
"deleteBackup": "¿Eliminar este archivo de copia?", "deleteBackup": "¿Eliminar este archivo de copia?",
"deleteSelectedFiles": "¿Eliminar {{count}} archivos seleccionados?", "deleteSelectedFiles": "¿Eliminar {{count}} archivos seleccionados?",
"deleteRouterWithFiles": "¿Eliminar el router y todos los archivos relacionados?", "deleteRouterWithFiles": "¿Eliminar el dispositivo y todos los archivos relacionados?",
"deleteLogsOlderThan": "¿Eliminar registros anteriores a {{days}} días?" "deleteLogsOlderThan": "¿Eliminar registros anteriores a {{days}} días?"
}, },
"footer": { "footer": {

View File

@@ -31,7 +31,8 @@
"desc": "Synkende", "desc": "Synkende",
"enabled": "På", "enabled": "På",
"disabled": "Av", "disabled": "Av",
"failed": "Feilet" "failed": "Feilet",
"save": "Lagre"
}, },
"nav": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
@@ -175,7 +176,7 @@
"exportOne": "Eksport", "exportOne": "Eksport",
"binaryOne": "Binær", "binaryOne": "Binær",
"testConnection": "Test tilkobling", "testConnection": "Test tilkobling",
"deleteRouter": "Slett ruter", "deleteRouter": "Slett enhet",
"exportsLabel": "Eksporter", "exportsLabel": "Eksporter",
"exportsLabelHint": "Tekstbaserte øyeblikksbilder", "exportsLabelHint": "Tekstbaserte øyeblikksbilder",
"binaryLabel": "Binære backuper", "binaryLabel": "Binære backuper",
@@ -463,7 +464,7 @@
"header": "Bekreftelse", "header": "Bekreftelse",
"deleteBackup": "Slette denne backupfilen?", "deleteBackup": "Slette denne backupfilen?",
"deleteSelectedFiles": "Slette {{count}} valgte filer?", "deleteSelectedFiles": "Slette {{count}} valgte filer?",
"deleteRouterWithFiles": "Slette ruteren og alle relaterte filer?", "deleteRouterWithFiles": "Slette enheten og alle relaterte filer?",
"deleteLogsOlderThan": "Slette logger eldre enn {{days}} dager?" "deleteLogsOlderThan": "Slette logger eldre enn {{days}} dager?"
}, },
"footer": { "footer": {

View File

@@ -31,7 +31,8 @@
"desc": "Malejąco", "desc": "Malejąco",
"enabled": "Włączone", "enabled": "Włączone",
"disabled": "Wyłączone", "disabled": "Wyłączone",
"failed": "Błąd" "failed": "Błąd",
"save": "Zapisz"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -102,7 +103,7 @@
"activityTitle": "Ostatnia aktywność", "activityTitle": "Ostatnia aktywność",
"activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.", "activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.",
"noActivity": "Brak ostatnich zdarzeń do wyświetlenia.", "noActivity": "Brak ostatnich zdarzeń do wyświetlenia.",
"avgBackupsPerRouter": "Śr. backupów / router", "avgBackupsPerRouter": "Śr. backupów / urządzenie",
"activitySuccess": "Zadanie zakończone", "activitySuccess": "Zadanie zakończone",
"activityFailure": "Wymaga uwagi", "activityFailure": "Wymaga uwagi",
"activityMaintenance": "Utrzymanie", "activityMaintenance": "Utrzymanie",
@@ -111,10 +112,10 @@
"operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.", "operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.",
"latestSnapshot": "Najnowszy snapshot", "latestSnapshot": "Najnowszy snapshot",
"coverageLabel": "Pokrycie floty", "coverageLabel": "Pokrycie floty",
"coverageHint": "Routery z co najmniej jednym backupem", "coverageHint": "Urządzenia z co najmniej jednym backupem",
"weeklyActivityLabel": "Aktywność 7 dni", "weeklyActivityLabel": "Aktywność 7 dni",
"weeklyActivityHint": "Nowe backupy z ostatniego tygodnia", "weeklyActivityHint": "Nowe backupy z ostatniego tygodnia",
"busiestRouterLabel": "Najaktywniejszy router", "busiestRouterLabel": "Najaktywniejsze urządzenie",
"routerSnapshotsHint": "{{count}} snapshotów w repozytorium", "routerSnapshotsHint": "{{count}} snapshotów w repozytorium",
"exportShareLabel": "Udział eksportów", "exportShareLabel": "Udział eksportów",
"activityTodayLabel": "Zdarzenia dzisiaj", "activityTodayLabel": "Zdarzenia dzisiaj",
@@ -127,7 +128,7 @@
"storageViewMixHint": "Podział wszystkich kopii na eksporty tekstowe i backupy binarne.", "storageViewMixHint": "Podział wszystkich kopii na eksporty tekstowe i backupy binarne.",
"storageViewActivity": "Aktywność 7 dni", "storageViewActivity": "Aktywność 7 dni",
"storageViewActivityHint": "Liczba nowych backupów z ostatnich siedmiu dni.", "storageViewActivityHint": "Liczba nowych backupów z ostatnich siedmiu dni.",
"storageViewRouters": "Top routery", "storageViewRouters": "Top urządzenia",
"storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.", "storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.",
"storageChartEmpty": "Brak danych do narysowania wykresu.", "storageChartEmpty": "Brak danych do narysowania wykresu.",
"storageSnapshotTitle": "Metryki repozytorium", "storageSnapshotTitle": "Metryki repozytorium",
@@ -170,12 +171,12 @@
"optionalPassword": "Opcjonalne hasło", "optionalPassword": "Opcjonalne hasło",
"optionalPrivateKey": "Opcjonalny klucz prywatny", "optionalPrivateKey": "Opcjonalny klucz prywatny",
"saveRouter": "Zapisz urządzenie", "saveRouter": "Zapisz urządzenie",
"profileEyebrow": "profil routera", "profileEyebrow": "profil urządzenia",
"detailSubtitle": "Operacje urządzenia i historia backupów", "detailSubtitle": "Operacje urządzenia i historia backupów",
"exportOne": "Eksport", "exportOne": "Eksport",
"binaryOne": "Backup", "binaryOne": "Backup",
"testConnection": "Test połączenia", "testConnection": "Test połączenia",
"deleteRouter": "Usuń router", "deleteRouter": "Usuń urządzenie",
"exportsLabel": "Eksporty", "exportsLabel": "Eksporty",
"exportsLabelHint": "Tekstowe snapshoty", "exportsLabelHint": "Tekstowe snapshoty",
"binaryLabel": "Backupy binarne", "binaryLabel": "Backupy binarne",
@@ -232,7 +233,25 @@
"connectionSectionHint": "Podstawowa tożsamość urządzenia i endpoint używany do połączenia.", "connectionSectionHint": "Podstawowa tożsamość urządzenia i endpoint używany do połączenia.",
"credentialsSectionTitle": "Dostęp i poświadczenia", "credentialsSectionTitle": "Dostęp i poświadczenia",
"routerDialogSubtitle": "Ustaw adres urządzenia, dane dostępu SSH i preferowaną metodę logowania.", "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ń." "switchDialogSubtitle": "Ustaw endpoint SwitchOS i opcjonalne poświadczenia lokalne lub domyślne z ustawień.",
"backupPolicy": "Kopie",
"backupSettingsTitle": "Ustawienia kopii",
"backupSettingsHint": "Steruj osobno eksportem, backupem binarnym i pingiem dla tego urządzenia.",
"deleteDevice": "Usuń urządzenie",
"disableAllBackupsHint": "Jednym przełącznikiem blokuje wszystkie typy kopii i automatycznie zaznacza opcje poniżej.",
"disableExportsHint": "Wyłącza tylko eksporty tekstowe i zostawia backup binarny bez zmian.",
"disableBinaryBackupsHint": "Wyłącza tylko backupy binarne i nie rusza eksportów tekstowych.",
"disablePing": "Wyłącz ping do urządzenia",
"disablePingHint": "Wyłącza sprawdzanie dostępności pingiem na liście urządzeń.",
"pingDisabled": "Ping wyłączony",
"disableAllBackups": "Wyłącz wszystkie kopie dla tego urządzenia",
"disableExports": "Wyłącz eksporty",
"disableBinaryBackups": "Wyłącz kopie binarne",
"backupsDisabledAll": "Wszystkie kopie wyłączone",
"ping": "Ping",
"pingChecking": "Sprawdzanie...",
"noPing": "Brak pingu",
"pingAvailable": "Ping dostępny"
}, },
"files": { "files": {
"title": "Repozytorium", "title": "Repozytorium",
@@ -250,9 +269,9 @@
"binaryCard": "Backupy binarne", "binaryCard": "Backupy binarne",
"binaryHint": "Obrazy odzyskiwania", "binaryHint": "Obrazy odzyskiwania",
"filtersTitle": "Filtry", "filtersTitle": "Filtry",
"filtersSubtitle": "Zawęź listę plików po routerze, typie lub słowie kluczowym.", "filtersSubtitle": "Zawęź listę plików po urządzeniu, typie lub słowie kluczowym.",
"searchLabel": "Szukaj", "searchLabel": "Szukaj",
"searchPlaceholder": "Szukaj po pliku lub routerze", "searchPlaceholder": "Szukaj po pliku lub urządzeniu",
"typeLabel": "Typ", "typeLabel": "Typ",
"routerLabel": "Urządzenie", "routerLabel": "Urządzenie",
"dateLabel": "Data", "dateLabel": "Data",
@@ -289,7 +308,7 @@
"compareLatestPair": "Najnowsza para", "compareLatestPair": "Najnowsza para",
"setOlder": "Ustaw jako starszy", "setOlder": "Ustaw jako starszy",
"setNewer": "Ustaw jako nowszy", "setNewer": "Ustaw jako nowszy",
"latestForRouter": "Diff dla routera", "latestForRouter": "Diff dla urządzenia",
"binaryNoCompare": "Diff tylko dla .rsc", "binaryNoCompare": "Diff tylko dla .rsc",
"openPlainDiff": "Pokaż diff tekstowy", "openPlainDiff": "Pokaż diff tekstowy",
"minutesAgo": "{{value}} min temu", "minutesAgo": "{{value}} min temu",
@@ -299,8 +318,8 @@
"compareSubtitle": "Wybierz dwa pliki .rsc i uruchom diff bez przewijania całej tabeli.", "compareSubtitle": "Wybierz dwa pliki .rsc i uruchom diff bez przewijania całej tabeli.",
"exportPoolLabel": "eksportów gotowych do porównania", "exportPoolLabel": "eksportów gotowych do porównania",
"compareSelectionHint": "Wybierz starszy i nowszy plik", "compareSelectionHint": "Wybierz starszy i nowszy plik",
"compareReadySameRouter": "Para gotowa · router {{router}}", "compareReadySameRouter": "Para gotowa · urządzenie {{router}}",
"compareReadyMixedRouters": "Para gotowa · różne routery" "compareReadyMixedRouters": "Para gotowa · różne urządzenia"
}, },
"settings": { "settings": {
"title": "Ustawienia", "title": "Ustawienia",
@@ -431,16 +450,16 @@
"error": "Błąd", "error": "Błąd",
"exportPreviewLoaded": "Załadowano podgląd eksportu.", "exportPreviewLoaded": "Załadowano podgląd eksportu.",
"backupSentEmail": "Backup został wysłany e-mailem.", "backupSentEmail": "Backup został wysłany e-mailem.",
"binaryUploaded": "Backup binarny został wysłany na router.", "binaryUploaded": "Backup binarny został wysłany na urządzenie.",
"backupDeleted": "Backup został usunięty.", "backupDeleted": "Backup został usunięty.",
"selectedBackupsDeleted": "Wybrane backupy zostały usunięte.", "selectedBackupsDeleted": "Wybrane backupy zostały usunięte.",
"diffLoaded": "Załadowano diff.", "diffLoaded": "Załadowano diff.",
"archivePrepared": "Archiwum zostało przygotowane.", "archivePrepared": "Archiwum zostało przygotowane.",
"exportedRouters": "Wykonano eksport dla {{count}} urządzeń.", "exportedRouters": "Wykonano eksport dla {{count}} urządzeń.",
"binaryCompletedRouters": "Wykonano backup binarny dla {{count}} urządzeń.", "binaryCompletedRouters": "Wykonano backup binarny dla {{count}} urządzeń.",
"routerCreated": "Router został dodany.", "routerCreated": "Urządzenie zostało dodane.",
"routerUpdated": "Router został zaktualizowany.", "routerUpdated": "Urządzenie zostało zaktualizowane.",
"routerDeleted": "Router został usunięty.", "routerDeleted": "Urządzenie zostało usunięte.",
"exportCreated": "Eksport został utworzony.", "exportCreated": "Eksport został utworzony.",
"binaryCreated": "Backup binarny został utworzony.", "binaryCreated": "Backup binarny został utworzony.",
"connectionSuccessful": "Połączenie zakończone powodzeniem.", "connectionSuccessful": "Połączenie zakończone powodzeniem.",
@@ -463,7 +482,7 @@
"header": "Potwierdzenie", "header": "Potwierdzenie",
"deleteBackup": "Usunąć ten plik backupu?", "deleteBackup": "Usunąć ten plik backupu?",
"deleteSelectedFiles": "Usunąć {{count}} zaznaczonych plików?", "deleteSelectedFiles": "Usunąć {{count}} zaznaczonych plików?",
"deleteRouterWithFiles": "Usunąć router i wszystkie powiązane pliki?", "deleteRouterWithFiles": "Usunąć urządzenie i wszystkie powiązane pliki?",
"deleteLogsOlderThan": "Usunąć logi starsze niż {{days}} dni?" "deleteLogsOlderThan": "Usunąć logi starsze niż {{days}} dni?"
}, },
"footer": { "footer": {
@@ -485,7 +504,7 @@
"exportsCard": "Eksporty do diffu", "exportsCard": "Eksporty do diffu",
"exportsCardHint": "Pliki .rsc w bieżącym zakresie", "exportsCardHint": "Pliki .rsc w bieżącym zakresie",
"scopeCard": "Zakres", "scopeCard": "Zakres",
"scopeCardHint": "Wybrany router lub cała flota", "scopeCardHint": "Wybrane urządzenie lub cała flota",
"scopeTag": "Zakres", "scopeTag": "Zakres",
"readyCard": "Para", "readyCard": "Para",
"readyCardHint": "Stan wyboru do porównania", "readyCardHint": "Stan wyboru do porównania",
@@ -494,7 +513,7 @@
"lastDiffCardHint": "Ostatnio otwarta para plików", "lastDiffCardHint": "Ostatnio otwarta para plików",
"lastDiffTag": "Historia", "lastDiffTag": "Historia",
"workspaceTitle": "Stanowisko porównawcze", "workspaceTitle": "Stanowisko porównawcze",
"workspaceSubtitle": "Wybierz router, ustaw starszy i nowszy eksport, a potem otwórz diff w modalu.", "workspaceSubtitle": "Wybierz urządzenie, ustaw starszy i nowszy eksport, a potem otwórz diff w modalu.",
"tableTitle": "Eksporty do wyboru", "tableTitle": "Eksporty do wyboru",
"tableSubtitle": "Szybkie przypisanie starszego i nowszego pliku oraz podgląd bez opuszczania strony.", "tableSubtitle": "Szybkie przypisanie starszego i nowszego pliku oraz podgląd bez opuszczania strony.",
"waitingTag": "Czeka", "waitingTag": "Czeka",

View File

@@ -2382,12 +2382,15 @@ app-page-header{
} }
.router-detail-grid--inspection{ .router-detail-grid--inspection{
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr);
align-items: start; align-items: start;
} }
.router-detail-inspection-stack{ .router-detail-split-grid{
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem; gap: 1rem;
margin-top: 1rem;
} }
.router-detail-grid--stack{ .router-detail-grid--stack{
@@ -2510,7 +2513,7 @@ app-page-header{
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.diff-workspace__pair, .router-detail-grid--inspection{ .diff-workspace__pair, .router-detail-grid--inspection, .router-detail-split-grid{
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -3432,3 +3435,35 @@ body.dark-theme .p-toast .p-toast-summary, body.dark-theme .p-toast .p-toast-det
height: 2rem; height: 2rem;
} }
.device-settings-form{display:block;}
.device-settings-stack{display:grid;gap:12px;}
.device-settings-actions{margin-top:16px;}
.device-toggle{position:relative;display:grid;grid-template-columns:auto auto auto minmax(0,1fr) auto;align-items:center;gap:14px;padding:16px 18px;border:1px solid color-mix(in srgb,var(--border-color) 88%, transparent);border-radius:18px;background:linear-gradient(135deg,color-mix(in srgb,var(--surface-1) 92%, transparent),color-mix(in srgb,var(--surface-2) 90%, transparent));cursor:pointer;transition:border-color .15s ease,transform .15s ease,background .15s ease,box-shadow .15s ease;box-shadow:var(--shadow-md);overflow:hidden;}
.device-toggle::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,transparent,rgba(255,255,255,.04),transparent);opacity:0;transition:opacity .15s ease;}
.device-toggle:hover{border-color:color-mix(in srgb,var(--accent) 55%, var(--border-color));transform:translateY(-1px);}
.device-toggle:hover::after,.device-toggle.is-active::after{opacity:1;}
.device-toggle.is-active{border-color:color-mix(in srgb,var(--accent) 60%, var(--border-color));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 12%, var(--surface-1)),color-mix(in srgb,var(--accent) 6%, var(--surface-2)));box-shadow:0 16px 40px -26px color-mix(in srgb,var(--accent) 45%, transparent),var(--shadow-md);}
.device-toggle input{position:absolute;opacity:0;pointer-events:none;inline-size:1px;block-size:1px;}
.device-toggle__switch{position:relative;display:inline-flex;align-items:center;inline-size:46px;block-size:26px;border-radius:999px;background:color-mix(in srgb,var(--surface-3) 88%, transparent);border:1px solid color-mix(in srgb,var(--border-color) 82%, transparent);box-shadow:inset 0 1px 2px rgba(0,0,0,.16);transition:background .15s ease,border-color .15s ease,box-shadow .15s ease;}
.device-toggle__switch::after{content:"";position:absolute;inset:3px auto 3px 3px;inline-size:18px;block-size:18px;border-radius:50%;background:var(--text-main);box-shadow:0 4px 10px rgba(0,0,0,.18);transition:transform .15s ease,background .15s ease;}
.device-toggle__icon{display:grid;place-items:center;inline-size:40px;block-size:40px;border-radius:12px;background:color-mix(in srgb,var(--surface-3) 84%, transparent);border:1px solid color-mix(in srgb,var(--border-color) 82%, transparent);color:var(--text-muted);font-size:1rem;}
.device-toggle.is-active .device-toggle__icon{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 35%, var(--border-color));background:color-mix(in srgb,var(--accent) 12%, var(--surface-3));}
.device-toggle.is-active .device-toggle__switch{background:color-mix(in srgb,var(--accent) 20%, var(--surface-3));border-color:color-mix(in srgb,var(--accent) 35%, var(--border-color));box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 20%, transparent);}
.device-toggle.is-active .device-toggle__switch::after{transform:translateX(20px);background:var(--accent);}
.device-toggle__content{display:grid;gap:4px;min-width:0;}
.device-toggle__content strong{font-size:.94rem;color:var(--text-main);}
.device-toggle__content small{line-height:1.45;color:var(--text-muted);}
.device-toggle__state{font-size:.75rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-muted);padding:8px 10px;border-radius:999px;background:color-mix(in srgb,var(--surface-3) 88%, transparent);border:1px solid color-mix(in srgb,var(--border-color) 82%, transparent);}
.device-toggle.is-active .device-toggle__state{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 28%, var(--border-color));background:color-mix(in srgb,var(--accent) 12%, var(--surface-3));}
body.dark-theme .device-toggle{background:linear-gradient(135deg,color-mix(in srgb,var(--surface-2) 94%, transparent),color-mix(in srgb,var(--surface-1) 88%, transparent));}
body.dark-theme .device-toggle.is-active{background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 14%, var(--surface-2)),color-mix(in srgb,var(--accent) 7%, var(--surface-1)));}
@media (max-width: 1100px){
.router-detail-split-grid{grid-template-columns:minmax(0,1fr);}
}
@media (max-width: 720px){
.device-toggle{grid-template-columns:auto auto auto minmax(0,1fr);align-items:start;}
.device-toggle__state{grid-column:2 / -1;justify-self:start;}
}