first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 11:39:03 +02:00
parent 5a62e825a7
commit 1e752f110f
37 changed files with 6687 additions and 5453 deletions

3
.gitignore vendored
View File

@@ -32,4 +32,5 @@ frontend/.cache/
*.zip
docker-data/db/*
docker-data/*.rsc
docker-data/*.backup
docker-data/*.backup
storage/*

View File

@@ -1,4 +1,4 @@
# RouterOS Backup Manager Next
# Mikrotik Backup System
## Deploy in docker
```bash

View File

@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = 'RouterOS Backup Manager Next'
app_name: str = 'Mikrotik Backup System'
app_env: str = 'development'
secret_key: str = 'change-me'
jwt_algorithm: str = 'HS256'

View File

@@ -306,8 +306,12 @@ class BackupService:
routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = []
for router in routers:
if router.device_type != 'routeros':
result.append({'router': router.name, 'status': 'skipped', 'message': 'SwitchOS devices do not support text export'})
if (router.device_type or 'routeros').lower() != 'routeros':
result.append({
'router': router.name,
'status': 'skipped',
'message': 'Text export is available only for RouterOS devices',
})
continue
try:
backup = self.export_router(db, user, router.id)

View File

@@ -53,7 +53,7 @@ class NotificationService:
return
if settings.smtp_notifications_enabled:
try:
self.send_email(settings, "RouterOS Backup notification", message)
self.send_email(settings, "Mikrotik Backup System notification", message)
except Exception:
pass
if settings.pushover_token and settings.pushover_userkey:
@@ -63,7 +63,7 @@ class NotificationService:
pass
def send_test_email(self, settings: GlobalSettings):
self.send_email(settings, "RouterOS Backup test", "This is a test email from RouterOS Backup Manager Next")
self.send_email(settings, "Mikrotik Backup System test", "This is a test email from Mikrotik Backup System")
def send_test_pushover(self, settings: GlobalSettings):
if not (settings.pushover_token and settings.pushover_userkey):
@@ -71,7 +71,7 @@ class NotificationService:
self.send_pushover(
settings.pushover_token,
settings.pushover_userkey,
"Test pushover from RouterOS Backup Manager Next",
"Test pushover from Mikrotik Backup System",
)

View File

@@ -1,209 +0,0 @@
from datetime import datetime
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
def _login(client: TestClient) -> tuple[str, dict[str, str]]:
response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'})
token = response.json()['access_token']
return token, {'Authorization': f'Bearer {token}'}
def test_switchos_list_marks_global_credentials_usage(monkeypatch, tmp_path):
from app.api.routes import settings as settings_route
with TestClient(app) as client:
_, headers = _login(client)
settings_response = client.put(
'/api/settings',
json={
'backup_retention_days': 7,
'log_retention_days': 7,
'export_cron': '',
'binary_cron': '',
'retention_cron': '',
'enable_auto_export': False,
'connection_test_interval_minutes': 0,
'global_ssh_key': None,
'default_switchos_username': 'sw-admin',
'default_switchos_password': 'sw-pass',
'pushover_token': None,
'pushover_userkey': None,
'notify_failures_only': True,
'smtp_host': None,
'smtp_port': 587,
'smtp_login': None,
'smtp_password': None,
'smtp_notifications_enabled': False,
'recipient_email': None,
'clear_global_ssh_key': False,
},
headers=headers,
)
assert settings_response.status_code == 200
assert settings_response.json()['has_default_switchos_credentials'] is True
create_response = client.post(
'/api/routers',
json={
'name': 'switch01',
'device_type': 'switchos',
'host': '192.168.88.2',
'port': 80,
'ssh_user': '',
'ssh_password': '',
'ssh_key': None,
},
headers=headers,
)
assert create_response.status_code == 200
list_response = client.get('/api/routers', headers=headers)
assert list_response.status_code == 200
payload = next(item for item in list_response.json() if item['name'] == 'switch01')
assert payload['device_type'] == 'switchos'
assert payload['uses_global_switchos_credentials'] is True
assert payload['effective_username'] == 'sw-admin'
def test_switchos_connection_probe_is_exposed_in_device_route(monkeypatch):
from app.api.routes import routers as routers_route
with TestClient(app) as client:
_, headers = _login(client)
create_response = client.post(
'/api/routers',
json={
'name': 'switch02',
'device_type': 'switchos',
'host': '192.168.88.3',
'port': 80,
'ssh_user': 'admin',
'ssh_password': 'secret',
'ssh_key': None,
},
headers=headers,
)
device_id = create_response.json()['id']
monkeypatch.setattr(
routers_route.router_service,
'test_connection',
lambda db, router, global_settings: {
'success': True,
'tested_at': datetime(2026, 4, 13, 10, 0, 0),
'model': 'SwitchOS',
'uptime': 'HTTP 200',
'hostname': 'MikroTik SwitchOS',
'version': None,
'error': None,
'transport': 'http',
'server': 'MikroTik',
'auth_mode': 'digest',
'http_status': '200',
'backup_available': True,
},
)
response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers)
assert response.status_code == 200
assert response.json()['transport'] == 'http'
assert response.json()['backup_available'] is True
def test_switchos_binary_backup_is_saved_as_swb(monkeypatch, tmp_path):
from app.services import backup_service as backup_service_module
from app.services import router_service as router_service_module
data_dir = tmp_path / 'data'
data_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(backup_service_module, 'ensure_data_dir', lambda: data_dir)
def fake_binary_backup(router, backup_name, local_path, global_ssh_key=None, global_settings=None):
Path(local_path).write_bytes(b'switchos-binary')
return local_path
monkeypatch.setattr(router_service_module.router_service, 'binary_backup', fake_binary_backup)
with TestClient(app) as client:
_, headers = _login(client)
create_response = client.post(
'/api/routers',
json={
'name': 'switch03',
'device_type': 'switchos',
'host': '192.168.88.4',
'port': 80,
'ssh_user': 'admin',
'ssh_password': 'secret',
'ssh_key': None,
},
headers=headers,
)
device_id = create_response.json()['id']
backup_response = client.post(f'/api/backups/router/{device_id}/binary', headers=headers)
assert backup_response.status_code == 200
assert backup_response.json()['backup_type'] == 'binary'
assert backup_response.json()['file_name'].endswith('.swb')
logs_response = client.get('/api/logs', headers=headers)
assert logs_response.status_code == 200
assert any('Binary backup OK for SwitchOS device switch03' in item['message'] for item in logs_response.json())
def test_switchos_connection_test_creates_operation_log(monkeypatch):
from app.services import router_service as router_service_module
monkeypatch.setattr(
router_service_module.router_service,
'probe_connection',
lambda router, global_ssh_key=None, global_settings=None: {
'success': True,
'tested_at': datetime(2026, 4, 13, 10, 0, 0),
'model': 'SwitchOS',
'uptime': 'HTTP 200',
'hostname': 'switch04',
'version': None,
'error': None,
'transport': 'http',
'server': 'MikroTik',
'auth_mode': 'digest',
'http_status': '200',
'backup_available': True,
},
)
with TestClient(app) as client:
_, headers = _login(client)
create_response = client.post(
'/api/routers',
json={
'name': 'switch04',
'device_type': 'switchos',
'host': '192.168.88.5',
'port': 80,
'ssh_user': 'admin',
'ssh_password': 'secret',
'ssh_key': None,
},
headers=headers,
)
assert create_response.status_code == 200
device_id = create_response.json()['id']
response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers)
assert response.status_code == 200
logs_response = client.get('/api/logs', headers=headers)
assert logs_response.status_code == 200
assert any(
'Connection test OK for SwitchOS device switch04' in item['message']
and 'auth=digest' in item['message']
and 'http=200' in item['message']
for item in logs_response.json()
)

View File

@@ -2,7 +2,7 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"routeros-backup-manager-next-ui": {
"mikrotik-backup-system-ui": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
@@ -11,7 +11,7 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/routeros-backup-manager-next-ui",
"outputPath": "dist/mikrotik-backup-system-ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
@@ -24,8 +24,6 @@
],
"styles": [
"node_modules/primeicons/primeicons.css",
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
"node_modules/primeng/resources/primeng.min.css",
"src/styles.css"
]
},
@@ -38,8 +36,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
"maximumWarning": "2mb",
"maximumError": "3mb"
},
{
"type": "anyComponentStyle",
@@ -59,14 +57,14 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "routeros-backup-manager-next-ui:build"
"buildTarget": "mikrotik-backup-system-ui:build"
},
"configurations": {
"production": {
"buildTarget": "routeros-backup-manager-next-ui:build:production"
"buildTarget": "mikrotik-backup-system-ui:build:production"
},
"development": {
"buildTarget": "routeros-backup-manager-next-ui:build:development"
"buildTarget": "mikrotik-backup-system-ui:build:development"
}
},
"defaultConfiguration": "development"

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "routeros-backup-manager-next-ui",
"name": "mikrotik-backup-system-ui",
"version": "1.0.0",
"private": true,
"scripts": {
@@ -11,27 +11,28 @@
"build:dev": "ng build --configuration development"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"@angular/animations": "^20.3.0",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/platform-browser-dynamic": "^20.3.0",
"@angular/router": "^20.3.0",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"primeicons": "^7.0.0",
"primeng": "^17.18.0",
"primeng": "^20.1.2",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"zone.js": "^0.14.4"
"tslib": "^2.8.0",
"zone.js": "~0.15.0",
"@primeuix/themes": "^1.2.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "^5.4.0",
"@angular-devkit/build-angular": "^20.3.0",
"@angular/cli": "^20.3.0",
"@angular/compiler-cli": "^20.3.0",
"typescript": "~5.8.0",
"ansi-colors": "^4.1.3",
"esbuild": "^0.25.0",
"semver": "^7.7.1",

View File

@@ -53,7 +53,7 @@ export class AppComponent {
readonly menuItems = [
{ label: 'nav.dashboard', link: '/', icon: 'pi pi-home', exact: true },
{ label: 'nav.routers', link: '/routers', icon: 'pi pi-server', exact: false },
{ label: 'nav.routers', link: '/devices', icon: 'pi pi-server', exact: false },
{ label: 'nav.files', link: '/files', icon: 'pi pi-folder-open', exact: false },
{ label: 'nav.diffConfigs', link: '/diff-configs', icon: 'pi pi-code', exact: false },
{ label: 'nav.logs', link: '/logs', icon: 'pi pi-history', exact: false },
@@ -108,11 +108,11 @@ export class AppComponent {
}
private updatePageLabel(url: string) {
if (url.startsWith('/routers/')) {
if (url.startsWith('/devices/') || url.startsWith('/routers/')) {
this.pageLabel = 'routers.detailTitle';
return;
}
if (url.startsWith('/routers')) {
if (url.startsWith('/devices') || url.startsWith('/routers')) {
this.pageLabel = 'routers.title';
return;
}

View File

@@ -17,8 +17,10 @@ export const routes: Routes = [
{ path: 'register', component: RegisterPageComponent },
{ path: 'change-password', canActivate: [authGuard], component: ChangePasswordPageComponent },
{ path: '', canActivate: [authGuard], component: DashboardPageComponent },
{ path: 'routers', canActivate: [authGuard], component: RoutersPageComponent },
{ path: 'routers/:id', canActivate: [authGuard], component: RouterDetailPageComponent },
{ path: 'devices', canActivate: [authGuard], component: RoutersPageComponent },
{ path: 'devices/:id', canActivate: [authGuard], component: RouterDetailPageComponent },
{ path: 'routers', redirectTo: 'devices', pathMatch: 'full' },
{ path: 'routers/:id', redirectTo: 'devices/:id', pathMatch: 'full' },
{ path: 'files', canActivate: [authGuard], component: FilesPageComponent },
{ path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent },
{ path: 'settings', canActivate: [authGuard], component: SettingsPageComponent },

View File

@@ -19,7 +19,23 @@ export class ThemeService {
set(mode: 'light' | 'dark') {
this.modeState.set(mode);
document.body.classList.toggle('dark-theme', mode === 'dark');
const isDark = mode === 'dark';
const html = document.documentElement;
const body = document.body;
html.classList.toggle('dark-theme', isDark);
body.classList.toggle('dark-theme', isDark);
html.setAttribute('data-theme', mode);
body.setAttribute('data-theme', mode);
html.style.colorScheme = isDark ? 'dark' : 'light';
body.style.colorScheme = isDark ? 'dark' : 'light';
localStorage.setItem(this.key, mode);
requestAnimationFrame(() => {
window.dispatchEvent(new Event('resize'));
});
}
}
}

View File

@@ -0,0 +1,134 @@
import { definePreset } from '@primeuix/themes';
import Lara from '@primeuix/themes/lara';
const AppPreset = definePreset(Lara, {
primitive: {
borderRadius: {
none: '0',
xs: '8px',
sm: '10px',
md: '12px',
lg: '16px',
xl: '20px'
}
},
semantic: {
primary: {
50: '#f6eee8',
100: '#ecd7c8',
200: '#dfb79e',
300: '#cf9571',
400: '#b9754d',
500: '#8d593a',
600: '#794a30',
700: '#653d28',
800: '#533220',
900: '#43291a',
950: '#2a1910'
},
colorScheme: {
light: {
surface: {
0: '#ffffff',
50: '#f8f8f5',
100: '#f1f1ed',
200: '#e6e6e0',
300: '#dfdfd8',
400: '#d0d0c8',
500: '#b7b7ae',
600: '#8f8f86',
700: '#6e6e67',
800: '#4f4f49',
900: '#31312d',
950: '#1e1e1b'
},
content: {
background: '#f8f8f5',
hoverBackground: '#f1f1ed',
borderColor: 'rgba(17, 20, 23, 0.12)',
color: '#111417',
hoverColor: '#111417'
},
formField: {
background: 'rgba(255, 255, 255, 0.5)',
disabledBackground: '#f1f1ed',
borderColor: 'rgba(17, 20, 23, 0.2)',
hoverBorderColor: '#8d593a',
focusBorderColor: '#8d593a',
color: '#111417',
placeholderColor: '#5e666e',
floatLabelColor: '#5e666e'
},
overlay: {
select: {
background: '#f8f8f5',
borderColor: 'rgba(17, 20, 23, 0.12)',
color: '#111417'
},
popover: {
background: '#f8f8f5',
borderColor: 'rgba(17, 20, 23, 0.12)',
color: '#111417'
},
modal: {
background: '#f8f8f5',
borderColor: 'rgba(17, 20, 23, 0.12)',
color: '#111417'
}
}
},
dark: {
surface: {
0: '#17212b',
50: '#1d2733',
100: '#222d3a',
200: '#2d3947',
300: '#3a4858',
400: '#516173',
500: '#6c7c8d',
600: '#93a5b6',
700: '#b7c7d6',
800: '#dae4ec',
900: '#edf2f7',
950: '#f7fbff'
},
content: {
background: '#1d2733',
hoverBackground: '#222d3a',
borderColor: 'rgba(146, 170, 194, 0.16)',
color: '#dae4ec',
hoverColor: '#dae4ec'
},
formField: {
background: 'rgba(255, 255, 255, 0.03)',
disabledBackground: '#222d3a',
borderColor: 'rgba(146, 170, 194, 0.25)',
hoverBorderColor: '#4b90d9',
focusBorderColor: '#4b90d9',
color: '#dae4ec',
placeholderColor: '#93a5b6',
floatLabelColor: '#93a5b6'
},
overlay: {
select: {
background: '#1d2733',
borderColor: 'rgba(146, 170, 194, 0.16)',
color: '#dae4ec'
},
popover: {
background: '#1d2733',
borderColor: 'rgba(146, 170, 194, 0.16)',
color: '#dae4ec'
},
modal: {
background: '#1d2733',
borderColor: 'rgba(146, 170, 194, 0.16)',
color: '#dae4ec'
}
}
}
}
}
});
export default AppPreset;

View File

@@ -7,7 +7,7 @@
<div class="stats-grid" *ngIf="data">
<app-stat-card [label]="'dashboard.managedRouters' | translate" [value]="data.routers_count" [hint]="'dashboard.managedRoutersHint' | translate" [tag]="'dashboard.inventoryTag' | translate" icon="pi pi-server" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'dashboard.exportsCard' | translate" [value]="data.export_count" [hint]="'dashboard.exportsHint' | translate" [tag]="'dashboard.textTag' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
<app-stat-card [label]="'dashboard.binaryCard' | translate" [value]="data.binary_count" [hint]="'dashboard.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warning" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'dashboard.binaryCard' | translate" [value]="data.binary_count" [hint]="'dashboard.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'dashboard.allFilesCard' | translate" [value]="data.total_backups" [hint]="'dashboard.allFilesHint' | translate" [tag]="'dashboard.archiveTag' | translate" severity="info" icon="pi pi-folder" iconClass="icon-violet"></app-stat-card>
</div>

View File

@@ -7,7 +7,7 @@
<div class="stats-grid compact-grid">
<app-stat-card [label]="'diffConfigs.exportsCard' | translate" [value]="availableExportsCount" [hint]="'diffConfigs.exportsCardHint' | translate" [tag]="'files.exportType' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
<app-stat-card [label]="'diffConfigs.scopeCard' | translate" [value]="selectedRouterLabel" [hint]="'diffConfigs.scopeCardHint' | translate" [tag]="'diffConfigs.scopeTag' | translate" severity="info" icon="pi pi-server" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'diffConfigs.readyCard' | translate" [value]="compareReady ? ('common.ok' | translate) : ('common.idle' | translate)" [hint]="'diffConfigs.readyCardHint' | translate" [tag]="'diffConfigs.readyTag' | translate" severity="warning" icon="pi pi-code" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'diffConfigs.readyCard' | translate" [value]="compareReady ? ('common.ok' | translate) : ('common.idle' | translate)" [hint]="'diffConfigs.readyCardHint' | translate" [tag]="'diffConfigs.readyTag' | translate" severity="warn" icon="pi pi-code" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'diffConfigs.lastDiffCard' | translate" [value]="lastDiffLabel" [hint]="'diffConfigs.lastDiffCardHint' | translate" [tag]="'diffConfigs.lastDiffTag' | translate" severity="secondary" icon="pi pi-history" iconClass="icon-violet"></app-stat-card>
</div>
@@ -16,7 +16,7 @@
<div class="diff-workspace__toolbar">
<span class="form-field diff-workspace__router">
<label>{{ 'files.routerLabel' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value" (onChange)="load()"></p-dropdown>
<p-select [appendTo]="'body'" [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value" (onChange)="load()"></p-select>
</span>
<div class="diff-workspace__actions">
<button pButton type="button" severity="secondary" icon="pi pi-refresh" [label]="'common.reset' | translate" (click)="routerId = null; compareLeftId = null; compareRightId = null; load()"></button>
@@ -31,7 +31,7 @@
<strong>{{ 'files.compareOlder' | translate }}</strong>
<p-tag [value]="compareLeft ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareLeft ? 'success' : 'secondary'"></p-tag>
</div>
<p-dropdown [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-dropdown>
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-select>
<div class="diff-pick-card__meta" *ngIf="compareLeft as item">
<strong>{{ item.file_name }}</strong>
<small>{{ item.router_name || item.router_id }} · {{ relativeAge(item.created_at) }}</small>
@@ -48,7 +48,7 @@
<strong>{{ 'files.compareNewer' | translate }}</strong>
<p-tag [value]="compareRight ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareRight ? 'success' : 'secondary'"></p-tag>
</div>
<p-dropdown [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-dropdown>
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-select>
<div class="diff-pick-card__meta" *ngIf="compareRight as item">
<strong>{{ item.file_name }}</strong>
<small>{{ item.router_name || item.router_id }} · {{ relativeAge(item.created_at) }}</small>

View File

@@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { DropdownModule } from 'primeng/dropdown';
import { SelectModule } from 'primeng/select';
import { TableModule } from 'primeng/table';
import { TagModule } from 'primeng/tag';
@@ -53,7 +53,7 @@ interface BackupDiffResponse {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, DropdownModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
templateUrl: './diff-configs-page.component.html'
})
export class DiffConfigsPageComponent implements OnInit {

View File

@@ -9,7 +9,7 @@
<app-stat-card [label]="'files.visibleFiles' | translate" [value]="files.length" [hint]="'files.visibleFilesHint' | translate" [tag]="'files.liveTag' | translate" icon="pi pi-folder-open" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'files.selected' | translate" [value]="selectedIds.length" [hint]="'files.selectedHint' | translate" [tag]="'files.batchTag' | translate" severity="secondary" icon="pi pi-check-square" iconClass="icon-violet"></app-stat-card>
<app-stat-card [label]="'files.exportsCard' | translate" [value]="exportCount" [hint]="'files.exportsHint' | translate" [tag]="'dashboard.textTag' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
<app-stat-card [label]="'files.binaryCard' | translate" [value]="binaryCount" [hint]="'files.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warning" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'files.binaryCard' | translate" [value]="binaryCount" [hint]="'files.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
</div>
<app-section-card [title]="'files.filtersTitle' | translate" [subtitle]="'files.filtersSubtitle' | translate">
@@ -24,12 +24,12 @@
<span class="form-field">
<label>{{ 'files.typeLabel' | translate }}</label>
<p-dropdown [options]="typeOptions" [(ngModel)]="backupType" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [options]="typeOptions" [(ngModel)]="backupType" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
<label>{{ 'files.routerLabel' | translate }}</label>
<p-dropdown [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
@@ -39,12 +39,12 @@
<span class="form-field">
<label>{{ 'files.sortLabel' | translate }}</label>
<p-dropdown [options]="sortOptions" [(ngModel)]="sortBy" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [options]="sortOptions" [(ngModel)]="sortBy" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
<label>{{ 'files.orderLabel' | translate }}</label>
<p-dropdown [options]="orderOptions" [(ngModel)]="order" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [options]="orderOptions" [(ngModel)]="order" optionLabel="label" optionValue="value"></p-select>
</span>
<div class="filters-actions repository-toolbar__actions">
@@ -68,14 +68,14 @@
<div class="repository-compare__grid">
<div class="compare-strip__slot repository-compare__slot">
<label>{{ 'files.compareOlder' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-dropdown>
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-select>
</div>
<button pButton type="button" severity="secondary" icon="pi pi-sort-alt" styleClass="compare-strip__swap" (click)="swapCompare()" [disabled]="!compareLeftId && !compareRightId"></button>
<div class="compare-strip__slot repository-compare__slot">
<label>{{ 'files.compareNewer' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-dropdown>
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-select>
</div>
<div class="compare-strip__actions repository-compare__actions">
@@ -111,7 +111,7 @@
<div class="table-primary">{{ item.router_name || item.router_id }}</div>
<small class="table-secondary">{{ deviceLabel(item) }} · ID {{ item.router_id }}</small>
</td>
<td><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warning'"></p-tag></td>
<td><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warn'"></p-tag></td>
<td>
<div class="table-primary">{{ item.created_at | date: 'dd.MM.yyyy HH:mm' }}</div>
<small class="table-secondary">{{ relativeAge(item.created_at) }}</small>

View File

@@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { DropdownModule } from 'primeng/dropdown';
import { SelectModule } from 'primeng/select';
import { InputTextModule } from 'primeng/inputtext';
import { TableModule } from 'primeng/table';
import { TagModule } from 'primeng/tag';
@@ -57,7 +57,7 @@ interface BackupDiffResponse {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, DropdownModule, InputTextModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, InputTextModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
templateUrl: './files-page.component.html'
})
export class FilesPageComponent implements OnInit {

View File

@@ -13,7 +13,7 @@
<div class="stats-grid compact-grid">
<app-stat-card [label]="'routers.deviceType' | translate" [value]="deviceTypeLabel" [hint]="'routers.listSubtitle' | translate" [tag]="'routers.fleetTag' | translate" severity="info" icon="pi pi-sitemap" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warning" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
<app-stat-card [label]="'routers.connectionLabel' | translate" [value]="connectionStateLabel" [hint]="'routers.connectionLabelHint' | translate" [tag]="'routers.probeTag' | translate" severity="info" icon="pi pi-bolt" iconClass="icon-blue"></app-stat-card>
<app-stat-card [label]="'routers.sshUser' | translate" [value]="routerItem?.effective_username || '-'" [hint]="'routers.sshUserHint' | translate" [tag]="'routers.accessTag' | translate" severity="secondary" icon="pi pi-user" iconClass="icon-violet"></app-stat-card>
</div>

View File

@@ -252,7 +252,7 @@ export class RouterDetailPageComponent implements OnInit {
}
this.deletingRouter = true;
this.api.http.delete(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe({
next: () => this.router.navigate(['/routers']),
next: () => this.router.navigate(['/devices']),
complete: () => {
this.deletingRouter = false;
}

View File

@@ -96,7 +96,7 @@
</span>
<span class="form-field">
<label>{{ 'routers.deviceType' | translate }}</label>
<p-dropdown [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
<label>{{ 'routers.host' | translate }}</label>
@@ -138,7 +138,7 @@
</span>
<span class="form-field form-field--full" *ngIf="selectedDeviceType === 'routeros'">
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
<textarea pInputTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
<textarea pTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
</div>

View File

@@ -5,8 +5,8 @@ import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { DropdownModule } from 'primeng/dropdown';
import { InputTextareaModule } from 'primeng/inputtextarea';
import { SelectModule } from 'primeng/select';
import { TextareaModule } from 'primeng/textarea';
import { InputTextModule } from 'primeng/inputtext';
import { TableModule } from 'primeng/table';
import { TagModule } from 'primeng/tag';
@@ -42,9 +42,9 @@ interface RouterItem {
TranslateModule,
ButtonModule,
DialogModule,
DropdownModule,
SelectModule,
InputTextModule,
InputTextareaModule,
TextareaModule,
TableModule,
TagModule,
PageHeaderComponent,
@@ -163,7 +163,7 @@ export class RoutersPageComponent implements OnInit {
}
open(id: number) {
this.router.navigate(['/routers', id]);
this.router.navigate(['/devices', id]);
}
deviceTypeLabel(item: RouterItem): string {
@@ -174,7 +174,7 @@ export class RoutersPageComponent implements OnInit {
return item.effective_username || item.ssh_user || '—';
}
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warning' | 'secondary' | 'info' } {
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
if (item.device_type === 'switchos') {
if (item.uses_global_switchos_credentials) {
return { value: this.ui.instant('routers.defaultCredentials'), severity: 'info' };
@@ -187,15 +187,15 @@ export class RoutersPageComponent implements OnInit {
return {
value: item.ssh_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'),
severity: item.ssh_password ? 'warning' : 'secondary'
severity: item.ssh_password ? 'warn' : 'secondary'
};
}
secondaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warning' | 'secondary' | 'info' } {
secondaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
if (item.device_type === 'switchos') {
return {
value: item.has_effective_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'),
severity: item.has_effective_password ? 'warning' : 'secondary'
severity: item.has_effective_password ? 'warn' : 'secondary'
};
}

View File

@@ -48,7 +48,7 @@
<div class="scheduler-card__grid">
<span class="form-field">
<label>{{ 'settings.scheduleMode' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.export.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.export.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field" *ngIf="scheduleEditors.export.mode !== 'custom' && scheduleEditors.export.mode !== 'disabled'">
<label>{{ 'settings.scheduleTime' | translate }}</label>
@@ -60,7 +60,7 @@
</span>
<span class="form-field" *ngIf="scheduleEditors.export.mode === 'weekly'">
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.export.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.export.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field form-field--full" *ngIf="scheduleEditors.export.mode === 'custom'">
<label>{{ 'settings.exportCron' | translate }}</label>
@@ -81,7 +81,7 @@
<div class="scheduler-card__grid">
<span class="form-field">
<label>{{ 'settings.scheduleMode' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.binary.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.binary.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field" *ngIf="scheduleEditors.binary.mode !== 'custom' && scheduleEditors.binary.mode !== 'disabled'">
<label>{{ 'settings.scheduleTime' | translate }}</label>
@@ -93,7 +93,7 @@
</span>
<span class="form-field" *ngIf="scheduleEditors.binary.mode === 'weekly'">
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.binary.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.binary.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field form-field--full" *ngIf="scheduleEditors.binary.mode === 'custom'">
<label>{{ 'settings.binaryCron' | translate }}</label>
@@ -122,7 +122,7 @@
</span>
<span class="form-field">
<label>{{ 'settings.scheduleMode' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.retention.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.retention.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field" *ngIf="scheduleEditors.retention.mode !== 'custom' && scheduleEditors.retention.mode !== 'disabled'">
<label>{{ 'settings.scheduleTime' | translate }}</label>
@@ -134,7 +134,7 @@
</span>
<span class="form-field" *ngIf="scheduleEditors.retention.mode === 'weekly'">
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
<p-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.retention.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.retention.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field form-field--full" *ngIf="scheduleEditors.retention.mode === 'custom'">
<label>{{ 'settings.retentionCron' | translate }}</label>
@@ -180,11 +180,11 @@
<div class="form-grid-2">
<span class="form-field">
<label>{{ 'topbar.languageSelector' | translate }}</label>
<p-dropdown [appendTo]="'body'" [autoDisplayFirst]="false" formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value" (onChange)="previewLanguage($event.value)"></p-dropdown>
<p-select [appendTo]="'body'" formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value" (onChange)="previewLanguage($event.value)"></p-select>
</span>
<span class="form-field">
<label>{{ 'settings.fontFamily' | translate }}</label>
<p-dropdown [appendTo]="'body'" formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" (onChange)="previewFont()"></p-dropdown>
<p-select [appendTo]="'body'" formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" (onChange)="previewFont()"></p-select>
</span>
</div>
</div>
@@ -283,7 +283,7 @@
<div class="form-field form-field--full">
<label>{{ 'settings.globalSshPrivateKey' | translate }}</label>
<textarea
pInputTextarea
pTextarea
formControlName="global_ssh_key"
rows="14"
[placeholder]="(hasStoredSshKey && !sshKeyVisible && !clearStoredSshKey) ? ('settings.globalSshPrivateKeyHiddenPlaceholder' | translate) : ('settings.globalSshPrivateKeyPlaceholder' | translate)"

View File

@@ -3,9 +3,9 @@ import { Component, OnDestroy, OnInit, effect, inject } from '@angular/core';
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { DropdownModule } from 'primeng/dropdown';
import { SelectModule } from 'primeng/select';
import { InputTextModule } from 'primeng/inputtext';
import { InputTextareaModule } from 'primeng/inputtextarea';
import { TextareaModule } from 'primeng/textarea';
import { TagModule } from 'primeng/tag';
import { Subject, finalize, forkJoin, takeUntil } from 'rxjs';
@@ -71,7 +71,7 @@ interface SettingsResponse {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, TranslateModule, ButtonModule, DropdownModule, InputTextModule, InputTextareaModule, TagModule, PageHeaderComponent],
imports: [CommonModule, FormsModule, ReactiveFormsModule, TranslateModule, ButtonModule, SelectModule, InputTextModule, TextareaModule, TagModule, PageHeaderComponent],
templateUrl: './settings-page.component.html'
})
export class SettingsPageComponent implements OnInit, OnDestroy {

View File

@@ -1,6 +1,6 @@
<app-page-header [eyebrow]="'switchosBeta.eyebrow' | translate" [title]="'switchosBeta.title' | translate" [subtitle]="'switchosBeta.subtitle' | translate">
<div header-actions class="header-actions-row">
<p-tag severity="warning" [value]="'switchosBeta.betaTag' | translate"></p-tag>
<p-tag severity="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
</div>
</app-page-header>
@@ -10,7 +10,7 @@
<strong>{{ 'switchosBeta.warningHeadline' | translate }}</strong>
<p>{{ 'switchosBeta.warningBody' | translate }}</p>
</div>
<p-tag severity="warning" [value]="'switchosBeta.betaTag' | translate"></p-tag>
<p-tag severity="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
</div>
</app-section-card>

View File

@@ -9,5 +9,5 @@
<i [class]="icon"></i>
</div>
</div>
<p-tag *ngIf="tag" [value]="tag" [severity]="severity"></p-tag>
<p-tag *ngIf="tag" [value]="tag" [severity]="tagSeverity"></p-tag>
</p-card>

View File

@@ -16,5 +16,9 @@ export class StatCardComponent {
@Input() tag = '';
@Input() icon = 'pi pi-chart-bar';
@Input() iconClass = '';
@Input() severity: 'success' | 'info' | 'warning' | 'danger' | 'secondary' | 'contrast' | undefined = 'info';
@Input() severity: 'success' | 'info' | 'warning' | 'warn' | 'danger' | 'secondary' | 'contrast' | undefined = 'info';
get tagSeverity(): 'success' | 'secondary' | 'info' | 'warn' | 'danger' | 'contrast' | null | undefined {
return this.severity === 'warning' ? 'warn' : this.severity;
}
}

View File

@@ -3,8 +3,8 @@
"menu": "Menu"
},
"sidebar": {
"title": "MikroTik backup",
"subtitle": "RouterOS manager"
"title": "Mikrotik Backup System",
"subtitle": "Device backup platform"
},
"topbar": {
"caption": "mikrotik / control center",
@@ -127,7 +127,7 @@
"storageViewMixHint": "Split of all snapshots into text exports and binary backups.",
"storageViewActivity": "7-day activity",
"storageViewActivityHint": "Number of new backups created during the last seven days.",
"storageViewRouters": "Top routers",
"storageViewRouters": "Top devices",
"storageViewRoutersHint": "Devices with the highest number of snapshots in the repository.",
"storageChartEmpty": "There is not enough data to draw this chart yet.",
"storageSnapshotTitle": "Repository metrics",
@@ -299,8 +299,8 @@
"compareSubtitle": "Pick two .rsc files and launch the diff without digging through the whole table.",
"exportPoolLabel": "exports ready to compare",
"compareSelectionHint": "Pick an older and a newer file",
"compareReadySameRouter": "Pair ready · router {{router}}",
"compareReadyMixedRouters": "Pair ready · mixed routers"
"compareReadySameRouter": "Pair ready · device {{router}}",
"compareReadyMixedRouters": "Pair ready · mixed devices"
},
"settings": {
"title": "Settings",
@@ -436,8 +436,8 @@
"selectedBackupsDeleted": "Selected backups deleted.",
"diffLoaded": "Diff loaded.",
"archivePrepared": "Archive prepared.",
"exportedRouters": "Export completed for {{count}} routers.",
"binaryCompletedRouters": "Binary backup completed for {{count}} routers.",
"exportedRouters": "Export completed for {{count}} devices.",
"binaryCompletedRouters": "Binary backup completed for {{count}} devices.",
"routerCreated": "Router created.",
"routerUpdated": "Router updated.",
"routerDeleted": "Router deleted.",

View File

@@ -4,7 +4,7 @@
},
"sidebar": {
"title": "copia de MikroTik",
"subtitle": "gestor de RouterOS"
"subtitle": "gestor de RouterOS/SwitchOS"
},
"topbar": {
"caption": "mikrotik / centro de control",

View File

@@ -4,7 +4,7 @@
},
"sidebar": {
"title": "MikroTik-backup",
"subtitle": "RouterOS-behandler"
"subtitle": "RouterOS/SwitchOS-behandler"
},
"topbar": {
"caption": "mikrotik / kontrollsenter",

View File

@@ -3,8 +3,8 @@
"menu": "Menu"
},
"sidebar": {
"title": "kopie MikroTik",
"subtitle": "manager RouterOS"
"title": "Mikrotik Backup System",
"subtitle": "Device backup platform"
},
"topbar": {
"caption": "mikrotik / control center",
@@ -369,7 +369,7 @@
"binaryPlannerHint": "Oddzielne okno dla pełnych backupów binarnych, gdy potrzebujesz punktów odtworzenia.",
"retentionPlannerHint": "Retencja czyści stare backupy i logi według osobnego planu.",
"connectionTestsTitle": "Automatyczne testy połączeń",
"connectionTestsHint": "Aplikacja może sama odświeżać status routerów. Ustaw 0, aby wyłączyć automatyczne testy.",
"connectionTestsHint": "Aplikacja może sama odświeżać status urządzeń. Ustaw 0, aby wyłączyć automatyczne testy.",
"connectionTestIntervalMinutes": "Test co X minut",
"connectionTestsEverySummary": "Co {{minutes}} min",
"connectionTestsDisabledHint": "Automatyczne testy połączeń są wyłączone.",
@@ -436,8 +436,8 @@
"selectedBackupsDeleted": "Wybrane backupy zostały usunięte.",
"diffLoaded": "Załadowano diff.",
"archivePrepared": "Archiwum zostało przygotowane.",
"exportedRouters": "Wykonano eksport dla {{count}} routerów.",
"binaryCompletedRouters": "Wykonano backup binarny dla {{count}} routerów.",
"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.",

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>RouterOS Backup Manager Next</title>
<title>Mikrotik Backup System</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>

View File

@@ -1,32 +1,40 @@
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { importProvidersFrom } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { ConfirmationService, MessageService } from 'primeng/api';
import { providePrimeNG } from 'primeng/config';
import AppPreset from './app/core/theme-preset';
import { provideRouter } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
export function httpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}
bootstrapApplication(AppComponent, {
providers: [
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes),
providePrimeNG({
theme: {
preset: AppPreset,
options: {
darkModeSelector: '.dark-theme',
cssLayer: false
}
}
}),
provideTranslateService({
loader: provideTranslateHttpLoader({
prefix: './assets/i18n/',
suffix: '.json'
}),
lang: 'en',
fallbackLang: 'en'
}),
MessageService,
ConfirmationService,
importProvidersFrom(
TranslateModule.forRoot({
defaultLanguage: 'pl',
loader: { provide: TranslateLoader, useFactory: httpLoaderFactory, deps: [HttpClient] }
})
)
ConfirmationService
]
}).catch((err) => console.error(err));

View File

@@ -96,26 +96,12 @@ body.dark-theme .topbar {
position: relative;
}
.topbar__lang-select,
.auth-toolbar__select {
appearance: none;
-webkit-appearance: none;
min-width: 10.5rem;
min-height: 2.4rem;
padding: 0.55rem 2.15rem 0.55rem 0.95rem;
border-radius: 999px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
color: var(--text-main);
font: inherit;
cursor: pointer;
}
.topbar__lang-picker::after,
.auth-toolbar__select-wrap::after {
content: '\e902';
font-family: 'primeicons';
position: absolute;
right: 0.85rem;
right: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
@@ -123,15 +109,48 @@ body.dark-theme .topbar {
font-size: 0.8rem;
}
.topbar__lang-select,
.auth-toolbar__select {
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 10.5rem;
min-height: 2.95rem;
padding: 0.72rem 2.7rem 0.72rem 1rem;
border-radius: 14px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-1) 88%, white 12%);
color: var(--text-main);
font: inherit;
font-size: 0.96rem;
font-weight: 500;
line-height: 1.35;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
}
.topbar__lang-select:hover,
.auth-toolbar__select:hover {
border-color: color-mix(in srgb, var(--accent) 56%, var(--border-strong));
}
.topbar__lang-select:focus,
.auth-toolbar__select:focus {
outline: none;
border-color: color-mix(in srgb, var(--blue) 72%, var(--border-strong));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 18%, transparent);
}
.topbar__lang-select option,
.auth-toolbar__select option {
color: #111417;
background: #ffffff;
color: var(--text-main);
background: var(--surface-1);
}
body.dark-theme .topbar__lang-select,
body.dark-theme .auth-toolbar__select {
background: rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.03);
border-color: var(--border-color);
}
body.dark-theme .topbar__lang-select option,
@@ -140,24 +159,6 @@ body.dark-theme .auth-toolbar__select option {
background: #1c2631;
}
.app-auth-view {
min-height: 100vh;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.app-auth-view__content {
min-height: 0;
display: grid;
}
.app-auth-view__content > * {
min-width: 0;
}
.layout-footer--auth {
padding-top: 0.5rem;
}
@media (max-width: 991px) {
.layout-shell,

View File

@@ -262,7 +262,7 @@ body.dark-theme .topbar{
width: 42px;
height: 42px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 10px;
border-radius: 12px;
display: grid;
place-items: center;
font-family: var(--font-title);
@@ -301,7 +301,7 @@ body.dark-theme .topbar{
gap: 0.85rem;
min-height: 44px;
padding: 0.75rem 0.85rem;
border-radius: 12px;
border-radius: 14px;
color: rgba(230, 238, 245, 0.84);
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
@@ -337,11 +337,6 @@ body.dark-theme .topbar{
border: 1px solid rgba(255, 255, 255, 0.08);
}
.layout-sidebar app-sidebar{
height: 100%;
display: flex;
flex-direction: column;
}
.page-header{
display: flex;
@@ -759,7 +754,7 @@ body.dark-theme .app-table .p-datatable-tbody > tr:hover > td{
}
.p-dropdown-panel .p-dropdown-items{
padding: 0.35rem;
padding: 0.45rem;
}
.p-dropdown-panel .p-dropdown-item{
@@ -1997,48 +1992,6 @@ body.dark-theme .settings-actions--sticky{
}
}
/* Normalize PrimeNG dropdown labels so selected values inherit the field style
instead of rendering like nested inputs. */
.p-dropdown{
display: flex;
align-items: center;
min-height: 2.75rem;
}
.p-dropdown .p-dropdown-label, .p-dropdown .p-dropdown-label.p-inputtext, .p-multiselect .p-multiselect-label{
width: 100%;
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
display: flex;
align-items: center;
font-size: inherit;
font-weight: 500;
line-height: 1.25;
letter-spacing: 0;
text-transform: none;
color: var(--text-main);
}
.p-dropdown .p-dropdown-label.p-placeholder, .p-multiselect .p-multiselect-label.p-placeholder{
color: var(--text-soft);
font-weight: 400;
}
.p-dropdown .p-dropdown-trigger, .p-multiselect .p-multiselect-trigger{
display: grid;
place-items: center;
align-self: stretch;
color: var(--text-soft);
}
.p-dropdown-panel .p-dropdown-item, .p-multiselect-panel .p-multiselect-item{
font-size: 0.84rem;
line-height: 1.35;
}
/* patch set: settings, dashboard, repository, logs */
.inline-summary{
@@ -2270,12 +2223,6 @@ body.dark-theme .inline-summary{
}
.app-auth-view{
min-height: 100vh;
display: flex;
flex-direction: column;
}
.layout-footer a{
@@ -2312,10 +2259,6 @@ body.dark-theme .inline-summary{
box-shadow: 0 0 0 4px rgba(240, 180, 91, 0.14);
}
.layout-footer--auth{
margin-top: auto;
justify-content: center;
}
.api-connection-banner{
position: fixed;
@@ -2415,45 +2358,7 @@ body.dark-theme .api-connection-banner{
font-size: 0.78rem;
}
/* --- language selector + settings layout --- */
.topbar__lang-picker{
position: relative;
}
.topbar__lang-select{
min-height: 2.5rem;
padding: 0.55rem 2.15rem 0.55rem 0.85rem;
border-radius: 999px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
color: var(--text-main);
font: inherit;
appearance: none;
cursor: pointer;
box-shadow: var(--shadow-sm);
color-scheme: light;
}
.topbar__lang-select{
min-width: 132px;
}
.topbar__lang-select option{
background: var(--surface-1);
color: var(--text-main);
}
body.dark-theme .topbar__lang-select{
background: rgba(15, 21, 29, 0.92);
color: var(--text-main);
border-color: var(--border-color);
color-scheme: dark;
}
body.dark-theme .topbar__lang-select option{
background: #1d2733;
color: #dae4ec;
}
/* --- settings layout --- */
.settings-page-shell{
display: grid;
@@ -3584,3 +3489,329 @@ body.dark-theme .p-confirm-dialog .p-confirm-dialog-icon{
min-width: 0;
}
}
/* PrimeNG v20 compatibility bridge */
.p-select,
.p-dropdown,
.p-multiselect,
.p-inputtext,
.p-textarea,
.p-inputtextarea,
textarea.p-inputtextarea,
textarea.p-textarea {
width: 100%;
}
.p-button:not(.p-button-secondary):not(.p-button-help):not(.p-button-danger):not(.p-button-text):not(.p-button-outlined) {
background: var(--primary);
border-color: var(--primary);
color: #f8fbff;
}
body.dark-theme .p-button:not(.p-button-secondary):not(.p-button-help):not(.p-button-danger):not(.p-button-text):not(.p-button-outlined) {
background: var(--primary);
border-color: var(--primary);
color: #17212b;
}
.p-select,
.p-dropdown,
.p-textarea,
.p-inputtextarea,
.p-inputtext,
textarea.p-inputtextarea,
textarea.p-textarea {
border-radius: 12px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-1) 90%, white 10%);
color: var(--text-main);
box-shadow: none;
}
body.dark-theme .p-select,
body.dark-theme .p-dropdown,
body.dark-theme .p-textarea,
body.dark-theme .p-inputtextarea,
body.dark-theme .p-inputtext,
body.dark-theme textarea.p-inputtextarea,
body.dark-theme textarea.p-textarea {
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
border-color: var(--border-color);
}
.p-select,
.p-dropdown {
display: flex;
align-items: center;
min-height: 2.95rem;
}
.p-select .p-select-label,
.p-select .p-select-label.p-placeholder,
.p-dropdown .p-dropdown-label,
.p-dropdown .p-dropdown-label.p-inputtext,
.p-dropdown .p-dropdown-label.p-placeholder,
.p-multiselect .p-multiselect-label,
.p-multiselect .p-multiselect-label.p-placeholder {
width: 100%;
min-height: 0;
padding: 0.78rem 0 0.78rem 1rem;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
display: flex;
align-items: center;
font-family: var(--font-body);
font-size: 0.96rem;
font-weight: 500;
line-height: 1.35;
letter-spacing: 0;
text-transform: none;
color: var(--text-main);
}
.p-select .p-select-label.p-placeholder,
.p-dropdown .p-dropdown-label.p-placeholder,
.p-multiselect .p-multiselect-label.p-placeholder {
font-weight: 400;
color: var(--text-soft);
}
.repository-toolbar .p-select .p-select-label,
.repository-compare .p-select .p-select-label,
.diff-configs-compare .p-select .p-select-label,
.repository-toolbar .p-dropdown .p-dropdown-label,
.repository-compare .p-dropdown .p-dropdown-label {
font-size: 0.95rem;
line-height: 1.3;
}
.p-select .p-select-dropdown,
.p-dropdown .p-dropdown-trigger,
.p-multiselect .p-multiselect-trigger {
width: 2.85rem;
display: grid;
place-items: center;
align-self: stretch;
color: var(--text-soft);
flex: 0 0 2.85rem;
}
.p-select.p-focus,
.p-dropdown:not(.p-disabled).p-focus,
.p-inputtext:enabled:focus,
.p-textarea:enabled:focus,
.p-inputtextarea:enabled:focus,
textarea.p-inputtextarea:enabled:focus,
textarea.p-textarea:enabled:focus {
border-color: color-mix(in srgb, var(--blue) 72%, var(--border-strong));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 18%, transparent);
}
body.dark-theme .auth-card .p-inputtext,
body.dark-theme .auth-card .p-password-input,
body.dark-theme .auth-card .p-select,
body.dark-theme .auth-card .p-textarea,
body.dark-theme .auth-card .p-inputtextarea {
color: var(--text-main);
border-color: var(--border-color);
background: rgba(255, 255, 255, 0.035);
}
body.dark-theme .auth-card .p-inputtext::placeholder,
body.dark-theme .auth-card .p-password-input::placeholder,
body.dark-theme .auth-card .p-textarea::placeholder,
body.dark-theme .auth-card .p-inputtextarea::placeholder {
color: var(--text-soft);
}
body.dark-theme .auth-card .p-inputtext:enabled:focus,
body.dark-theme .auth-card .p-password-input:enabled:focus,
body.dark-theme .auth-card .p-textarea:enabled:focus,
body.dark-theme .auth-card .p-inputtextarea:enabled:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 18%, transparent);
}
.p-select-overlay,
.p-dropdown-panel,
.p-multiselect-panel,
.p-component-overlay {
backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--surface-1) 97%, white 3%);
border: 1px solid var(--border-color);
color: var(--text-main);
box-shadow: var(--shadow-lg);
}
body.dark-theme .p-select-overlay,
body.dark-theme .p-dropdown-panel,
body.dark-theme .p-multiselect-panel,
body.dark-theme .p-component-overlay {
background: #1f2935;
border-color: rgba(146, 170, 194, 0.2);
}
.p-select-list,
.p-dropdown-panel .p-dropdown-items,
.p-multiselect-panel .p-multiselect-items {
padding: 0.35rem;
background: transparent;
}
body.dark-theme .p-select-list,
body.dark-theme .p-dropdown-panel .p-dropdown-items,
body.dark-theme .p-multiselect-panel .p-multiselect-items {
background: transparent;
}
.p-select-option,
.p-dropdown-panel .p-dropdown-item,
.p-multiselect-panel .p-multiselect-item {
border-radius: 10px;
font-size: 0.92rem;
line-height: 1.35;
color: var(--text-main);
transition: background-color 0.12s ease, color 0.12s ease;
}
.p-select-option.p-focus,
.p-select-option:hover,
.p-dropdown-panel .p-dropdown-item.p-focus,
.p-dropdown-panel .p-dropdown-item:hover,
.p-multiselect-panel .p-multiselect-item.p-focus,
.p-multiselect-panel .p-multiselect-item:hover {
background: #dbe5f0;
color: #13202c;
}
.p-select-option.p-select-option-selected,
.p-dropdown-panel .p-dropdown-item.p-highlight,
.p-multiselect-panel .p-multiselect-item.p-highlight {
background: #c8d8ea;
color: #13202c;
font-weight: 600;
}
body.dark-theme .p-select-option,
body.dark-theme .p-dropdown-panel .p-dropdown-item,
body.dark-theme .p-multiselect-panel .p-multiselect-item {
color: #dbe5ef;
}
body.dark-theme .p-select-option.p-focus,
body.dark-theme .p-select-option:hover,
body.dark-theme .p-dropdown-panel .p-dropdown-item.p-focus,
body.dark-theme .p-dropdown-panel .p-dropdown-item:hover,
body.dark-theme .p-multiselect-panel .p-multiselect-item.p-focus,
body.dark-theme .p-multiselect-panel .p-multiselect-item:hover {
background: #314355;
color: #f4f8fb;
}
body.dark-theme .p-select-option.p-select-option-selected,
body.dark-theme .p-dropdown-panel .p-dropdown-item.p-highlight,
body.dark-theme .p-multiselect-panel .p-multiselect-item.p-highlight {
background: #d9e1ea;
color: #13202c;
font-weight: 600;
}
.p-textarea,
.p-inputtextarea,
textarea.p-textarea,
textarea.p-inputtextarea {
padding: 0.82rem 0.9rem;
}
.router-dialog.p-dialog {
border: 1px solid var(--border-color);
background: var(--surface-1);
box-shadow: var(--shadow-lg);
}
body.dark-theme .router-dialog.p-dialog {
border-color: color-mix(in srgb, var(--border-color) 65%, transparent);
}
.router-dialog .p-dialog-header {
padding: 1.1rem 1.2rem 0.95rem;
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-1) 98%, transparent), color-mix(in srgb, var(--surface-0) 98%, transparent));
border-bottom: 1px solid var(--border-color);
}
.router-dialog .p-dialog-content {
padding: 0 1.2rem 1.2rem;
background: color-mix(in srgb, var(--surface-0) 98%, transparent);
}
.router-dialog .p-dialog-header-icons {
align-self: flex-start;
margin-top: 0.2rem;
}
.router-dialog-panel {
background: color-mix(in srgb, var(--surface-0) 96%, transparent);
border: 1px solid var(--border-color);
box-shadow: none;
}
.router-dialog .p-select,
.router-dialog .p-textarea,
.router-dialog .p-inputtextarea,
.router-dialog .p-inputtext {
background: color-mix(in srgb, var(--surface-1) 90%, transparent);
}
body.dark-theme .router-dialog .p-select,
body.dark-theme .router-dialog .p-textarea,
body.dark-theme .router-dialog .p-inputtextarea,
body.dark-theme .router-dialog .p-inputtext,
body.dark-theme .router-dialog-panel {
background: rgba(255, 255, 255, 0.03);
border-color: var(--border-color);
}
.repository-toolbar__search .p-input-icon-left {
position: relative;
display: block;
width: 100%;
}
.repository-toolbar__search .p-input-icon-left > i {
position: absolute;
top: 50%;
left: 0.9rem;
transform: translateY(-50%);
margin: 0;
z-index: 1;
color: var(--text-soft);
pointer-events: none;
line-height: 1;
}
.repository-toolbar__search .p-input-icon-left > .p-inputtext {
display: block;
width: 100%;
min-height: 2.75rem;
padding-left: 2.5rem;
}
body.dark-theme .p-toast .p-toast-summary,
body.dark-theme .p-toast .p-toast-detail,
body.dark-theme .p-toast .p-toast-message-icon,
body.dark-theme .p-toast .p-toast-icon-close {
color: var(--text-main);
}
.repository-table .p-paginator .p-paginator-pages .p-paginator-page,
.repository-table .p-paginator .p-paginator-next,
.repository-table .p-paginator .p-paginator-prev,
.repository-table .p-paginator .p-select {
min-width: 2rem;
height: 2rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +0,0 @@
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
root = Path('/mnt/data/appcheck/backend')
work = Path(tempfile.mkdtemp(prefix='rbmnext_'))
os.environ['DATABASE_URL'] = f"sqlite:///{work/'test.db'}"
os.environ['DATA_DIR'] = str(work/'data')
os.environ['SECRET_KEY'] = 'test-secret'
os.environ['DEFAULT_ADMIN_USERNAME'] = 'admin'
os.environ['DEFAULT_ADMIN_PASSWORD'] = 'admin'
os.environ['ALLOW_REGISTRATION'] = 'true'
os.environ['CORS_ORIGINS'] = '["http://localhost:4200"]'
os.chdir(root)
import sys
sys.path.insert(0, str(root))
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
results = []
def record(name, ok, detail=''):
results.append((name, ok, detail))
# health
r = client.get('/api/health')
record('health', r.status_code == 200 and r.json().get('status') == 'ok', str(r.status_code))
# login as default admin
r = client.post('/api/auth/login', data={'username':'admin','password':'admin'})
record('login_form', r.status_code == 200 and 'access_token' in r.json(), str(r.text))
token = r.json()['access_token']
headers = {'Authorization': f'Bearer {token}'}
# me
r = client.get('/api/auth/me', headers=headers)
record('auth_me', r.status_code == 200 and r.json()['username'] == 'admin', str(r.text))
# register new user and login json should fail (endpoint only form in current archive)
r = client.post('/api/auth/register', json={'username':'u1','password':'p1234'})
record('register', r.status_code == 200 and r.json()['username'] == 'u1', str(r.text))
# create router
router_payload = {
'name':'R1','host':'192.0.2.1','port':22,'ssh_user':'admin','ssh_password':'pass','ssh_key':''
}
r = client.post('/api/routers', json=router_payload, headers=headers)
record('create_router', r.status_code == 200 and r.json()['name'] == 'R1', str(r.text))
router_id = r.json()['id']
# list routers
r = client.get('/api/routers', headers=headers)
record('list_routers', r.status_code == 200 and len(r.json()) == 1, str(r.text))
# dashboard
r = client.get('/api/dashboard', headers=headers)
record('dashboard', r.status_code == 200 and r.json()['routers_count'] == 1, str(r.text))
# fake router tests and backups
with patch('app.services.router_service.router_service.test_connection', return_value={'model':'hAP ax3','uptime':'1d','hostname':'r1'}):
r = client.get(f'/api/routers/{router_id}/test-connection', headers=headers)
record('test_connection_route', r.status_code == 200 and r.json()['hostname'] == 'r1', str(r.text))
with patch('app.services.router_service.router_service.export', return_value='/system identity set name=r1\n'):
r = client.post(f'/api/backups/router/{router_id}/export', headers=headers, json={})
record('export_router', r.status_code == 200 and r.json()['backup_type'] == 'export', str(r.text))
export_id = r.json()['id']
with patch('app.services.router_service.router_service.binary_backup', side_effect=lambda router, base_name, path, key: Path(path).write_bytes(b'binary')):
r = client.post(f'/api/backups/router/{router_id}/binary', headers=headers, json={})
record('binary_backup', r.status_code == 200 and r.json()['backup_type'] == 'binary', str(r.text))
binary_id = r.json()['id']
# view export
r = client.get(f'/api/backups/{export_id}/view', headers=headers)
record('view_export', r.status_code == 200 and 'content' in r.json(), str(r.text))
# download requires auth header at HTTP level; endpoint itself works with auth header
r = client.get(f'/api/backups/{binary_id}/download', headers=headers)
record('download_with_auth', r.status_code == 200 and r.content == b'binary', str(r.status_code))
# diff html endpoint works with auth header
r = client.get(f'/api/backups/{export_id}/diff/{export_id}/html', headers=headers)
record('diff_html_with_auth', r.status_code == 200 and '<html' in r.text.lower(), str(r.status_code))
# settings roundtrip
r = client.get('/api/settings', headers=headers)
record('get_settings', r.status_code == 200 and 'backup_retention_days' in r.json(), str(r.text))
r = client.put('/api/settings', headers=headers, json={'backup_retention_days':10,'log_retention_days':5,'export_cron':'','binary_cron':'','retention_cron':'','enable_auto_export':False,'global_ssh_key':'','pushover_token':'','pushover_userkey':'','notify_failures_only':True,'smtp_host':'','smtp_port':587,'smtp_login':'','smtp_password':'','smtp_notifications_enabled':False,'recipient_email':None})
record('put_settings', r.status_code == 200 and r.json()['backup_retention_days'] == 10, str(r.text))
for name, ok, detail in results:
print(f"{name}: {'OK' if ok else 'FAIL'} {detail}")
if not all(ok for _,ok,_ in results):
raise SystemExit(1)