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

{{ 'routers.noPreview' | translate }}

-
-
- +
+ +
+
+
+
- -
-
- {{ diffData.left_file_name }} → {{ diffData.right_file_name }} - {{ 'routers.diffModalHint' | translate }} -
-
- -
+
+ +
+
+ {{ previewTitle }} + {{ 'routers.previewModalHint' | translate }}
- -
- -

{{ 'routers.noDiff' | translate }}

-
-
- -
+
+ +
+
+ +
+ +

{{ 'routers.noPreview' | translate }}

+
+
+ + + +
+
+ {{ diffData.left_file_name }} → {{ diffData.right_file_name }} + {{ 'routers.diffModalHint' | translate }} +
+
+ +
+
+ +
+ +

{{ 'routers.noDiff' | translate }}

+
+
+
@@ -168,3 +219,84 @@
{{ diffText }}
+ + + + +
+
+ +
+
+
+ {{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }} +
+
{{ 'routers.editDialogTitle' | translate }}
+ + {{ + selectedDeviceType === 'switchos' + ? ('routers.switchDialogSubtitle' | translate) + : ('routers.routerDialogSubtitle' | translate) + }} + +
+
+
+ +
+
+
+
+ {{ 'routers.connectionSectionTitle' | translate }} +

{{ 'routers.connectionSectionHint' | translate }}

+
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+
+ {{ 'routers.credentialsSectionTitle' | translate }} +

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

+
+
+
+ + + + + + + + + + + + +
+
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/routers/router-detail-page.component.ts b/frontend/src/app/features/routers/router-detail-page.component.ts index c37b5e4..e7ba275 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.ts +++ b/frontend/src/app/features/routers/router-detail-page.component.ts @@ -1,12 +1,16 @@ import { CommonModule } from '@angular/common'; import { HttpResponse } from '@angular/common/http'; import { Component, OnInit, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; +import { InputTextModule } from 'primeng/inputtext'; +import { SelectModule } from 'primeng/select'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; +import { TextareaModule } from 'primeng/textarea'; import { ApiService } from '../../core/services/api.service'; import { UiService } from '../../core/services/ui.service'; @@ -22,9 +26,16 @@ interface DeviceItem { host: string; port: number; device_type: DeviceType; + ssh_user?: string | null; + ssh_password?: string | null; + ssh_key?: string | null; effective_username?: string | null; supports_export: 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_tested_at?: string | null; last_connection_error?: string | null; @@ -80,7 +91,21 @@ interface BackupDiffResponse { @Component({ 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' }) export class RouterDetailPageComponent implements OnInit { @@ -88,6 +113,7 @@ export class RouterDetailPageComponent implements OnInit { private readonly api = inject(ApiService); private readonly router = inject(Router); private readonly ui = inject(UiService); + private readonly fb = inject(FormBuilder); routerId!: number; routerItem: DeviceItem | null = null; @@ -98,16 +124,42 @@ export class RouterDetailPageComponent implements OnInit { previewTitle = ''; previewVisible = false; diffVisible = false; + editVisible = false; diffData: BackupDiffResponse | null = null; exporting = false; runningBinary = false; testing = 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 { return this.routerItem?.device_type === 'switchos'; } + get selectedDeviceType(): DeviceType { + return this.form.controls.device_type.value; + } + get exportBackups(): BackupItem[] { return this.backups.filter((item) => item.backup_type === 'export'); } @@ -145,6 +197,14 @@ export class RouterDetailPageComponent implements OnInit { ngOnInit() { 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(); } @@ -152,12 +212,86 @@ export class RouterDetailPageComponent implements OnInit { this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => { this.routerItem = routerItem; this.connection = this.mapStoredConnection(routerItem); + this.patchSettingsForm(routerItem); }); this.api.http.get(`${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(`${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(`${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() { - if (this.exporting || this.isSwitchos) { + if (this.exporting || this.isSwitchos || this.routerItem?.disable_all_backups || this.routerItem?.disable_export_backups) { return; } this.exporting = true; @@ -173,7 +307,7 @@ export class RouterDetailPageComponent implements OnInit { } runBinary() { - if (this.runningBinary) { + if (this.runningBinary || this.routerItem?.disable_all_backups || this.routerItem?.disable_binary_backups) { return; } 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, fallbackName: string) { const disposition = response.headers.get('content-disposition') || ''; const match = disposition.match(/filename="?([^";]+)"?/i); diff --git a/frontend/src/app/features/routers/routers-page.component.html b/frontend/src/app/features/routers/routers-page.component.html index 68b1927..8947194 100644 --- a/frontend/src/app/features/routers/routers-page.component.html +++ b/frontend/src/app/features/routers/routers-page.component.html @@ -24,7 +24,7 @@ - {{ 'routers.name' | translate }}{{ 'routers.endpoint' | translate }}{{ 'routers.access' | translate }}{{ 'common.actions' | translate }} + {{ 'routers.name' | translate }}{{ 'routers.endpoint' | translate }}{{ 'routers.access' | translate }}{{ 'routers.backupPolicy' | translate }}{{ 'routers.ping' | translate }}{{ 'common.actions' | translate }} @@ -42,6 +42,13 @@
+ +
{{ backupPolicyLabel(routerItem) }}
+ {{ 'common.disabled' | translate }} + + + {{ pingLabel(routerItem) }} +
diff --git a/frontend/src/app/features/routers/routers-page.component.ts b/frontend/src/app/features/routers/routers-page.component.ts index 8e9ed81..d929c13 100644 --- a/frontend/src/app/features/routers/routers-page.component.ts +++ b/frontend/src/app/features/routers/routers-page.component.ts @@ -32,6 +32,17 @@ interface RouterItem { has_effective_ssh_key?: boolean; uses_global_switchos_credentials?: 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({ @@ -62,6 +73,7 @@ export class RoutersPageComponent implements OnInit { editingId: number | null = null; saving = false; routers: RouterItem[] = []; + pingStatuses: Record = {}; readonly deviceTypeOptions = [ { label: 'RouterOS', value: 'routeros' }, { label: 'SwitchOS', value: 'switchos' } @@ -100,12 +112,31 @@ export class RoutersPageComponent implements OnInit { } load() { - this.api.http.get(`${this.api.baseUrl}/routers`).subscribe((r) => (this.routers = r)); + this.api.http.get(`${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>((acc, item) => { + acc[item.router_id] = item; + return acc; + }, {}); + }, + error: () => { + this.pingStatuses = {}; + } + }); } openCreate() { 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; } @@ -174,6 +205,35 @@ export class RoutersPageComponent implements OnInit { 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' } { if (item.device_type === 'switchos') { if (item.uses_global_switchos_credentials) { diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 576fa8e..66ed8f8 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -31,7 +31,8 @@ "desc": "Descending", "enabled": "Enabled", "disabled": "Disabled", - "failed": "Failed" + "failed": "Failed", + "save": "Save" }, "nav": { "dashboard": "Dashboard", @@ -175,7 +176,7 @@ "exportOne": "Export", "binaryOne": "Binary", "testConnection": "Test connection", - "deleteRouter": "Delete router", + "deleteRouter": "Delete device", "exportsLabel": "Exports", "exportsLabelHint": "Text snapshots", "binaryLabel": "Binary backups", @@ -232,7 +233,25 @@ "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." + "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": { "title": "Repository", @@ -463,7 +482,7 @@ "header": "Confirmation", "deleteBackup": "Delete this backup file?", "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?" }, "footer": { diff --git a/frontend/src/assets/i18n/es.json b/frontend/src/assets/i18n/es.json index c519057..5f08516 100644 --- a/frontend/src/assets/i18n/es.json +++ b/frontend/src/assets/i18n/es.json @@ -31,7 +31,8 @@ "desc": "Descendente", "enabled": "Activado", "disabled": "Desactivado", - "failed": "Error" + "failed": "Error", + "save": "Guardar" }, "nav": { "dashboard": "Panel", @@ -175,7 +176,7 @@ "exportOne": "Exportar", "binaryOne": "Binario", "testConnection": "Probar conexión", - "deleteRouter": "Eliminar router", + "deleteRouter": "Eliminar dispositivo", "exportsLabel": "Exportaciones", "exportsLabelHint": "Instantáneas de texto", "binaryLabel": "Copias binarias", @@ -463,7 +464,7 @@ "header": "Confirmación", "deleteBackup": "¿Eliminar este archivo de copia?", "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?" }, "footer": { diff --git a/frontend/src/assets/i18n/no.json b/frontend/src/assets/i18n/no.json index 6595de9..09b3ad1 100644 --- a/frontend/src/assets/i18n/no.json +++ b/frontend/src/assets/i18n/no.json @@ -31,7 +31,8 @@ "desc": "Synkende", "enabled": "På", "disabled": "Av", - "failed": "Feilet" + "failed": "Feilet", + "save": "Lagre" }, "nav": { "dashboard": "Dashbord", @@ -175,7 +176,7 @@ "exportOne": "Eksport", "binaryOne": "Binær", "testConnection": "Test tilkobling", - "deleteRouter": "Slett ruter", + "deleteRouter": "Slett enhet", "exportsLabel": "Eksporter", "exportsLabelHint": "Tekstbaserte øyeblikksbilder", "binaryLabel": "Binære backuper", @@ -463,7 +464,7 @@ "header": "Bekreftelse", "deleteBackup": "Slette denne backupfilen?", "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?" }, "footer": { diff --git a/frontend/src/assets/i18n/pl.json b/frontend/src/assets/i18n/pl.json index bb56566..d3981dc 100644 --- a/frontend/src/assets/i18n/pl.json +++ b/frontend/src/assets/i18n/pl.json @@ -31,7 +31,8 @@ "desc": "Malejąco", "enabled": "Włączone", "disabled": "Wyłączone", - "failed": "Błąd" + "failed": "Błąd", + "save": "Zapisz" }, "nav": { "dashboard": "Dashboard", @@ -102,7 +103,7 @@ "activityTitle": "Ostatnia aktywność", "activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.", "noActivity": "Brak ostatnich zdarzeń do wyświetlenia.", - "avgBackupsPerRouter": "Śr. backupów / router", + "avgBackupsPerRouter": "Śr. backupów / urządzenie", "activitySuccess": "Zadanie zakończone", "activityFailure": "Wymaga uwagi", "activityMaintenance": "Utrzymanie", @@ -111,10 +112,10 @@ "operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.", "latestSnapshot": "Najnowszy snapshot", "coverageLabel": "Pokrycie floty", - "coverageHint": "Routery z co najmniej jednym backupem", + "coverageHint": "Urządzenia z co najmniej jednym backupem", "weeklyActivityLabel": "Aktywność 7 dni", "weeklyActivityHint": "Nowe backupy z ostatniego tygodnia", - "busiestRouterLabel": "Najaktywniejszy router", + "busiestRouterLabel": "Najaktywniejsze urządzenie", "routerSnapshotsHint": "{{count}} snapshotów w repozytorium", "exportShareLabel": "Udział eksportów", "activityTodayLabel": "Zdarzenia dzisiaj", @@ -127,7 +128,7 @@ "storageViewMixHint": "Podział wszystkich kopii na eksporty tekstowe i backupy binarne.", "storageViewActivity": "Aktywność 7 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.", "storageChartEmpty": "Brak danych do narysowania wykresu.", "storageSnapshotTitle": "Metryki repozytorium", @@ -170,12 +171,12 @@ "optionalPassword": "Opcjonalne hasło", "optionalPrivateKey": "Opcjonalny klucz prywatny", "saveRouter": "Zapisz urządzenie", - "profileEyebrow": "profil routera", + "profileEyebrow": "profil urządzenia", "detailSubtitle": "Operacje urządzenia i historia backupów", "exportOne": "Eksport", "binaryOne": "Backup", "testConnection": "Test połączenia", - "deleteRouter": "Usuń router", + "deleteRouter": "Usuń urządzenie", "exportsLabel": "Eksporty", "exportsLabelHint": "Tekstowe snapshoty", "binaryLabel": "Backupy binarne", @@ -232,7 +233,25 @@ "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ń." + "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": { "title": "Repozytorium", @@ -250,9 +269,9 @@ "binaryCard": "Backupy binarne", "binaryHint": "Obrazy odzyskiwania", "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", - "searchPlaceholder": "Szukaj po pliku lub routerze", + "searchPlaceholder": "Szukaj po pliku lub urządzeniu", "typeLabel": "Typ", "routerLabel": "Urządzenie", "dateLabel": "Data", @@ -289,7 +308,7 @@ "compareLatestPair": "Najnowsza para", "setOlder": "Ustaw jako starszy", "setNewer": "Ustaw jako nowszy", - "latestForRouter": "Diff dla routera", + "latestForRouter": "Diff dla urządzenia", "binaryNoCompare": "Diff tylko dla .rsc", "openPlainDiff": "Pokaż diff tekstowy", "minutesAgo": "{{value}} min temu", @@ -299,8 +318,8 @@ "compareSubtitle": "Wybierz dwa pliki .rsc i uruchom diff bez przewijania całej tabeli.", "exportPoolLabel": "eksportów gotowych do porównania", "compareSelectionHint": "Wybierz starszy i nowszy plik", - "compareReadySameRouter": "Para gotowa · router {{router}}", - "compareReadyMixedRouters": "Para gotowa · różne routery" + "compareReadySameRouter": "Para gotowa · urządzenie {{router}}", + "compareReadyMixedRouters": "Para gotowa · różne urządzenia" }, "settings": { "title": "Ustawienia", @@ -431,16 +450,16 @@ "error": "Błąd", "exportPreviewLoaded": "Załadowano podgląd eksportu.", "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.", "selectedBackupsDeleted": "Wybrane backupy zostały usunięte.", "diffLoaded": "Załadowano diff.", "archivePrepared": "Archiwum zostało przygotowane.", "exportedRouters": "Wykonano eksport dla {{count}} urządzeń.", "binaryCompletedRouters": "Wykonano backup binarny dla {{count}} urządzeń.", - "routerCreated": "Router został dodany.", - "routerUpdated": "Router został zaktualizowany.", - "routerDeleted": "Router został usunięty.", + "routerCreated": "Urządzenie zostało dodane.", + "routerUpdated": "Urządzenie zostało zaktualizowane.", + "routerDeleted": "Urządzenie zostało usunięte.", "exportCreated": "Eksport został utworzony.", "binaryCreated": "Backup binarny został utworzony.", "connectionSuccessful": "Połączenie zakończone powodzeniem.", @@ -463,7 +482,7 @@ "header": "Potwierdzenie", "deleteBackup": "Usunąć ten plik backupu?", "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?" }, "footer": { @@ -485,7 +504,7 @@ "exportsCard": "Eksporty do diffu", "exportsCardHint": "Pliki .rsc w bieżącym zakresie", "scopeCard": "Zakres", - "scopeCardHint": "Wybrany router lub cała flota", + "scopeCardHint": "Wybrane urządzenie lub cała flota", "scopeTag": "Zakres", "readyCard": "Para", "readyCardHint": "Stan wyboru do porównania", @@ -494,7 +513,7 @@ "lastDiffCardHint": "Ostatnio otwarta para plików", "lastDiffTag": "Historia", "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", "tableSubtitle": "Szybkie przypisanie starszego i nowszego pliku oraz podgląd bez opuszczania strony.", "waitingTag": "Czeka", diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index f52755d..13f5727 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -2382,12 +2382,15 @@ app-page-header{ } .router-detail-grid--inspection{ + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr); align-items: start; } -.router-detail-inspection-stack{ +.router-detail-split-grid{ display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; + margin-top: 1rem; } .router-detail-grid--stack{ @@ -2510,7 +2513,7 @@ app-page-header{ } @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; } @@ -3432,3 +3435,35 @@ body.dark-theme .p-toast .p-toast-summary, body.dark-theme .p-toast .p-toast-det 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;} +}