first commit
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package*.json /app/
|
||||
RUN npm ci || npm install
|
||||
COPY frontend /app
|
||||
RUN npm run build:prod
|
||||
|
||||
FROM nginx:mainline
|
||||
COPY frontend/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/routeros-backup-manager-next-ui/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
75
frontend/angular.json
Normal file
75
frontend/angular.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"mikrotik-backup-system-ui": {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/mikrotik-backup-system-ui",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/primeicons/primeicons.css",
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "3mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "10kb",
|
||||
"maximumError": "20kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"sourceMap": true,
|
||||
"extractLicenses": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "mikrotik-backup-system-ui:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "mikrotik-backup-system-ui:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "mikrotik-backup-system-ui:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
frontend/nginx/default.conf
Normal file
14
frontend/nginx/default.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
server_tokens off;
|
||||
etag off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
15080
frontend/package-lock.json
generated
Normal file
15080
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "mikrotik-backup-system-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.conf.json",
|
||||
"start:lan": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"test": "ng test",
|
||||
"build:prod": "ng build --configuration production",
|
||||
"build:dev": "ng build --configuration development"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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": "^20.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "~0.15.0",
|
||||
"@primeuix/themes": "^1.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"tree-kill": "^1.2.2"
|
||||
}
|
||||
}
|
||||
8
frontend/proxy.conf.json
Normal file
8
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "info"
|
||||
}
|
||||
}
|
||||
68
frontend/src/app/app.component.html
Normal file
68
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<p-toast position="top-right"></p-toast>
|
||||
<p-confirmDialog [style]="{ width: '28rem' }"></p-confirmDialog>
|
||||
|
||||
<div class="api-connection-banner" *ngIf="apiSnapshot().state === 'offline'">
|
||||
<div class="api-connection-banner__content">
|
||||
<strong>{{ 'footer.apiOfflineTitle' | translate }}</strong>
|
||||
<span>{{ 'footer.apiOfflineMessage' | translate }}</span>
|
||||
</div>
|
||||
<button type="button" class="api-connection-banner__action" (click)="refreshApiStatus()">{{ 'footer.retry' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="auth.isLoggedIn(); else authView">
|
||||
<div class="layout-shell" [class.layout-shell--collapsed]="layout.collapsed()">
|
||||
<div class="layout-overlay" [class.is-visible]="layout.mobileOpen()" (click)="layout.closeMobileSidebar()"></div>
|
||||
|
||||
<aside class="layout-sidebar" [class.is-open]="layout.mobileOpen()">
|
||||
<app-sidebar [collapsed]="layout.collapsed()" [items]="menuItems" (navigate)="layout.closeMobileSidebar()"></app-sidebar>
|
||||
</aside>
|
||||
|
||||
<div class="layout-main">
|
||||
<app-topbar
|
||||
[pageTitle]="currentPageTitle"
|
||||
[username]="auth.user()?.username || 'admin'"
|
||||
[lang]="language.current()"
|
||||
[languages]="languageOptions"
|
||||
[themeMode]="theme.mode()"
|
||||
(menuClick)="layout.toggleSidebar()"
|
||||
(themeClick)="toggleTheme()"
|
||||
(languageChange)="changeLanguage($event)"
|
||||
(logoutClick)="logout()"
|
||||
></app-topbar>
|
||||
|
||||
<main class="layout-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<footer class="layout-footer">
|
||||
<div class="layout-footer__author">
|
||||
<span class="layout-footer__author-label">{{ 'footer.authorLabel' | translate }}</span>
|
||||
<strong>{{ author }}</strong>
|
||||
<a [href]="authorUrl" target="_blank" rel="noreferrer">{{ authorHandle }}</a>
|
||||
</div>
|
||||
<span class="layout-footer__status" [ngClass]="apiStatusClass">{{ 'footer.apiLabel' | translate }}: {{ apiStateLabelKey() | translate }}</span>
|
||||
<span>{{ 'footer.apiLatencyLabel' | translate }}: {{ apiLatencyLabel() }}</span>
|
||||
<a href="/docs" target="_blank" rel="noreferrer">{{ 'footer.apiDocs' | translate }}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #authView>
|
||||
<div class="app-auth-view">
|
||||
<main class="app-auth-view__content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<footer class="layout-footer layout-footer--auth">
|
||||
<div class="layout-footer__author">
|
||||
<span class="layout-footer__author-label">{{ 'footer.authorLabel' | translate }}</span>
|
||||
<strong>{{ author }}</strong>
|
||||
<a [href]="authorUrl" target="_blank" rel="noreferrer">{{ authorHandle }}</a>
|
||||
</div>
|
||||
<span class="layout-footer__status" [ngClass]="apiStatusClass">{{ 'footer.apiLabel' | translate }}: {{ apiStateLabelKey() | translate }}</span>
|
||||
<span>{{ 'footer.apiLatencyLabel' | translate }}: {{ apiLatencyLabel() }}</span>
|
||||
<a href="/docs" target="_blank" rel="noreferrer">{{ 'footer.apiDocs' | translate }}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</ng-template>
|
||||
141
frontend/src/app/app.component.ts
Normal file
141
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { filter } from 'rxjs';
|
||||
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
import { FontService } from './core/services/font.service';
|
||||
import { LayoutService } from './core/services/layout.service';
|
||||
import { ThemeService } from './core/services/theme.service';
|
||||
import { APP_LANGUAGE_OPTIONS, AppLanguage, LanguageService } from './core/services/language.service';
|
||||
import { ApiStatusService } from './core/services/api-status.service';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
|
||||
import { AppSidebarComponent } from './shared/layout/app-sidebar.component';
|
||||
import { AppTopbarComponent, TopbarLanguageOption } from './shared/layout/app-topbar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, TranslateModule, ToastModule, ConfirmDialogModule, AppSidebarComponent, AppTopbarComponent],
|
||||
templateUrl: './app.component.html'
|
||||
})
|
||||
export class AppComponent {
|
||||
auth = inject(AuthService);
|
||||
theme = inject(ThemeService);
|
||||
language = inject(LanguageService);
|
||||
font = inject(FontService);
|
||||
router = inject(Router);
|
||||
layout = inject(LayoutService);
|
||||
apiStatus = inject(ApiStatusService);
|
||||
pageLabel = 'dashboard.title';
|
||||
readonly author = 'Mateusz Gruszczyński';
|
||||
readonly authorHandle = '@linuxiarz.pl';
|
||||
readonly authorUrl = 'https://linuxiarz.pl';
|
||||
readonly apiSnapshot = this.apiStatus.snapshot;
|
||||
readonly languageOptions: TopbarLanguageOption[] = APP_LANGUAGE_OPTIONS;
|
||||
readonly apiStateLabelKey = computed(() => {
|
||||
switch (this.apiSnapshot().state) {
|
||||
case 'online':
|
||||
return 'footer.apiOnline';
|
||||
case 'offline':
|
||||
return 'footer.apiOffline';
|
||||
default:
|
||||
return 'footer.apiChecking';
|
||||
}
|
||||
});
|
||||
readonly apiLatencyLabel = computed(() => {
|
||||
const latency = this.apiSnapshot().latencyMs;
|
||||
return latency === null ? '—' : `${latency} ms`;
|
||||
});
|
||||
|
||||
readonly menuItems = [
|
||||
{ label: 'nav.dashboard', link: '/', icon: 'pi pi-home', exact: true },
|
||||
{ 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 },
|
||||
{ label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false },
|
||||
{ label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false }
|
||||
];
|
||||
|
||||
get currentPageTitle(): string {
|
||||
return this.pageLabel;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.language.init();
|
||||
this.font.init();
|
||||
this.theme.init();
|
||||
this.apiStatus.startMonitoring();
|
||||
this.auth.restoreSession();
|
||||
this.updatePageLabel(this.router.url);
|
||||
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe((event) => {
|
||||
this.updatePageLabel((event as NavigationEnd).urlAfterRedirects);
|
||||
this.layout.closeMobileSidebar();
|
||||
});
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.theme.toggle();
|
||||
}
|
||||
|
||||
changeLanguage(lang: string) {
|
||||
const nextLanguage = lang as AppLanguage;
|
||||
const user = this.auth.user();
|
||||
if (!user) {
|
||||
this.language.set(nextLanguage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.language.setForAuthenticatedUser(nextLanguage);
|
||||
this.auth.updatePreferences({ preferred_language: nextLanguage, preferred_font: user.preferred_font }).subscribe();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.auth.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
refreshApiStatus() {
|
||||
this.apiStatus.probe();
|
||||
}
|
||||
|
||||
get apiStatusClass(): string {
|
||||
return `layout-footer__status--${this.apiSnapshot().state}`;
|
||||
}
|
||||
|
||||
private updatePageLabel(url: string) {
|
||||
if (url.startsWith('/devices/') || url.startsWith('/routers/')) {
|
||||
this.pageLabel = 'routers.detailTitle';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/devices') || url.startsWith('/routers')) {
|
||||
this.pageLabel = 'routers.title';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/files')) {
|
||||
this.pageLabel = 'files.title';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/diff-configs')) {
|
||||
this.pageLabel = 'diffConfigs.title';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/settings')) {
|
||||
this.pageLabel = 'settings.title';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/logs')) {
|
||||
this.pageLabel = 'logs.title';
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/change-password')) {
|
||||
this.pageLabel = 'auth.changePassword';
|
||||
return;
|
||||
}
|
||||
this.pageLabel = 'dashboard.title';
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/app.routes.ts
Normal file
29
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { ChangePasswordPageComponent } from './features/auth/change-password-page.component';
|
||||
import { LoginPageComponent } from './features/auth/login-page.component';
|
||||
import { RegisterPageComponent } from './features/auth/register-page.component';
|
||||
import { DashboardPageComponent } from './features/dashboard/dashboard-page.component';
|
||||
import { DiffConfigsPageComponent } from './features/diff-configs/diff-configs-page.component';
|
||||
import { FilesPageComponent } from './features/files/files-page.component';
|
||||
import { LogsPageComponent } from './features/logs/logs-page.component';
|
||||
import { RouterDetailPageComponent } from './features/routers/router-detail-page.component';
|
||||
import { RoutersPageComponent } from './features/routers/routers-page.component';
|
||||
import { SettingsPageComponent } from './features/settings/settings-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginPageComponent },
|
||||
{ path: 'register', component: RegisterPageComponent },
|
||||
{ path: 'change-password', canActivate: [authGuard], component: ChangePasswordPageComponent },
|
||||
{ path: '', canActivate: [authGuard], component: DashboardPageComponent },
|
||||
{ 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 },
|
||||
{ path: 'logs', canActivate: [authGuard], component: LogsPageComponent },
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
14
frontend/src/app/core/guards/auth.guard.ts
Normal file
14
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
if (!auth.isLoggedIn()) {
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
15
frontend/src/app/core/guards/guest.guard.ts
Normal file
15
frontend/src/app/core/guards/guest.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const guestGuard = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isLoggedIn()) {
|
||||
return router.createUrlTree(['/']);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
7
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
7
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = localStorage.getItem('routeros_token');
|
||||
if (!token) return next(req);
|
||||
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||
};
|
||||
83
frontend/src/app/core/services/api-status.service.ts
Normal file
83
frontend/src/app/core/services/api-status.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, fromEvent, interval, merge, of, Subscription, timeout } from 'rxjs';
|
||||
|
||||
type ApiConnectionState = 'checking' | 'online' | 'offline';
|
||||
|
||||
interface HealthResponse {
|
||||
status?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ApiStatusSnapshot {
|
||||
state: ApiConnectionState;
|
||||
latencyMs: number | null;
|
||||
checkedAt: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiStatusService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private started = false;
|
||||
private inFlight = false;
|
||||
private pollSubscription?: Subscription;
|
||||
private offlineSubscription?: Subscription;
|
||||
|
||||
readonly snapshot = signal<ApiStatusSnapshot>({
|
||||
state: 'checking',
|
||||
latencyMs: null,
|
||||
checkedAt: null
|
||||
});
|
||||
|
||||
startMonitoring() {
|
||||
if (this.started || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
this.pollSubscription = merge(of(0), interval(15000), fromEvent(window, 'focus'), fromEvent(window, 'online')).subscribe(() => {
|
||||
this.probe();
|
||||
});
|
||||
|
||||
this.offlineSubscription = fromEvent(window, 'offline').subscribe(() => {
|
||||
this.snapshot.set({
|
||||
state: 'offline',
|
||||
latencyMs: null,
|
||||
checkedAt: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
probe() {
|
||||
if (this.inFlight) {
|
||||
return;
|
||||
}
|
||||
this.inFlight = true;
|
||||
const startedAt = performance.now();
|
||||
const params = new HttpParams().set('_', String(Date.now()));
|
||||
|
||||
this.http
|
||||
.get<HealthResponse>('/api/health', { params })
|
||||
.pipe(
|
||||
timeout(4000),
|
||||
catchError(() => of(null))
|
||||
)
|
||||
.subscribe((response) => {
|
||||
const checkedAt = response?.timestamp || new Date().toISOString();
|
||||
if (response?.status === 'ok') {
|
||||
this.snapshot.set({
|
||||
state: 'online',
|
||||
latencyMs: Math.max(1, Math.round(performance.now() - startedAt)),
|
||||
checkedAt
|
||||
});
|
||||
} else {
|
||||
this.snapshot.set({
|
||||
state: 'offline',
|
||||
latencyMs: null,
|
||||
checkedAt
|
||||
});
|
||||
}
|
||||
this.inFlight = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
9
frontend/src/app/core/services/api.service.ts
Normal file
9
frontend/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
readonly baseUrl = '/api';
|
||||
|
||||
constructor(public http: HttpClient) {}
|
||||
}
|
||||
88
frontend/src/app/core/services/auth.service.ts
Normal file
88
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { AppFont, FontService } from './font.service';
|
||||
import { AppLanguage, LanguageService } from './language.service';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
preferred_language: AppLanguage;
|
||||
preferred_font: AppFont;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly api = '/api';
|
||||
private readonly tokenKey = 'routeros_token';
|
||||
private readonly language = inject(LanguageService);
|
||||
private readonly font = inject(FontService);
|
||||
|
||||
readonly user = signal<AuthUser | null>(null);
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
login(username: string, password: string) {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
return this.http
|
||||
.post<{ access_token: string; user: AuthUser }>(`${this.api}/auth/login`, body.toString(), {
|
||||
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
|
||||
})
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
localStorage.setItem(this.tokenKey, response.access_token);
|
||||
this.setUserAndApplyPreferences(response.user);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register(username: string, password: string) {
|
||||
return this.http.post<{ id: number; username: string }>(`${this.api}/auth/register`, { username, password });
|
||||
}
|
||||
|
||||
changePassword(current_password: string, new_password: string) {
|
||||
return this.http.post<{ message: string }>(`${this.api}/auth/change-password`, { current_password, new_password });
|
||||
}
|
||||
|
||||
restoreSession() {
|
||||
const token = this.token();
|
||||
if (!token) {
|
||||
this.user.set(null);
|
||||
this.language.resetToGuestPreference();
|
||||
return;
|
||||
}
|
||||
this.http.get<AuthUser>(`${this.api}/auth/me`).subscribe({
|
||||
next: (user) => this.setUserAndApplyPreferences(user),
|
||||
error: () => this.logout()
|
||||
});
|
||||
}
|
||||
|
||||
updatePreferences(preferences: { preferred_language: AppLanguage; preferred_font: AppFont }) {
|
||||
return this.http.put<AuthUser>(`${this.api}/auth/preferences`, preferences).pipe(
|
||||
tap((user) => {
|
||||
this.setUserAndApplyPreferences(user);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
this.user.set(null);
|
||||
this.language.resetToGuestPreference();
|
||||
}
|
||||
|
||||
token() {
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
return !!this.token();
|
||||
}
|
||||
|
||||
private setUserAndApplyPreferences(user: AuthUser) {
|
||||
this.user.set(user);
|
||||
this.language.applyForUser(user.preferred_language || 'pl');
|
||||
this.font.set(user.preferred_font || 'default');
|
||||
}
|
||||
}
|
||||
56
frontend/src/app/core/services/font.service.ts
Normal file
56
frontend/src/app/core/services/font.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
|
||||
export type AppFont = 'default' | 'adwaita_mono' | 'hack';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FontService {
|
||||
private readonly key = 'routeros_font';
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly supportedFonts: AppFont[] = ['default', 'adwaita_mono', 'hack'];
|
||||
private readonly fontState = signal<AppFont>('default');
|
||||
|
||||
readonly current = computed(() => this.fontState());
|
||||
|
||||
init() {
|
||||
const stored = localStorage.getItem(this.key) as AppFont | null;
|
||||
const font = stored && this.supportedFonts.includes(stored) ? stored : 'default';
|
||||
this.set(font);
|
||||
}
|
||||
|
||||
set(font: AppFont) {
|
||||
const nextFont = this.supportedFonts.includes(font) ? font : 'default';
|
||||
this.fontState.set(nextFont);
|
||||
localStorage.setItem(this.key, nextFont);
|
||||
this.applyFont(nextFont);
|
||||
}
|
||||
|
||||
private applyFont(font: AppFont) {
|
||||
const root = this.document.documentElement;
|
||||
const body = this.document.body;
|
||||
const families = this.fontFamilies(font);
|
||||
root.style.setProperty('--font-body', families.body);
|
||||
root.style.setProperty('--font-title', families.title);
|
||||
body.setAttribute('data-app-font', font);
|
||||
}
|
||||
|
||||
private fontFamilies(font: AppFont): { body: string; title: string } {
|
||||
switch (font) {
|
||||
case 'adwaita_mono':
|
||||
return {
|
||||
body: "'Adwaita Mono', 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace",
|
||||
title: "'Adwaita Mono', 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||
};
|
||||
case 'hack':
|
||||
return {
|
||||
body: "Hack, 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace",
|
||||
title: "Hack, 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
body: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
title: "'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
69
frontend/src/app/core/services/language.service.ts
Normal file
69
frontend/src/app/core/services/language.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
export type AppLanguage = 'pl' | 'en' | 'es' | 'no';
|
||||
|
||||
export interface AppLanguageOption {
|
||||
code: AppLanguage;
|
||||
label: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
export const APP_LANGUAGE_OPTIONS: AppLanguageOption[] = [
|
||||
{ code: 'pl', label: 'Polski', flag: '🇵🇱' },
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', label: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'no', label: 'Norsk', flag: '🇳🇴' }
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LanguageService {
|
||||
private readonly key = 'routeros_lang';
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly supportedLanguages = APP_LANGUAGE_OPTIONS.map((option) => option.code);
|
||||
private readonly langState = signal<AppLanguage>('pl');
|
||||
|
||||
readonly current = computed(() => this.langState());
|
||||
readonly options = APP_LANGUAGE_OPTIONS;
|
||||
|
||||
init() {
|
||||
this.translate.setDefaultLang('pl');
|
||||
this.apply(this.guestPreference());
|
||||
}
|
||||
|
||||
set(lang: AppLanguage) {
|
||||
const nextLang = this.read(lang) || 'pl';
|
||||
localStorage.setItem(this.key, nextLang);
|
||||
this.apply(nextLang);
|
||||
}
|
||||
|
||||
setForAuthenticatedUser(lang: AppLanguage) {
|
||||
const nextLang = this.read(lang) || 'pl';
|
||||
localStorage.setItem(this.key, nextLang);
|
||||
this.apply(nextLang);
|
||||
}
|
||||
|
||||
applyForUser(preferredLanguage?: AppLanguage | null) {
|
||||
this.apply(this.read(preferredLanguage || null) || 'pl');
|
||||
}
|
||||
|
||||
resetToGuestPreference() {
|
||||
this.apply(this.guestPreference());
|
||||
}
|
||||
|
||||
private guestPreference(): AppLanguage {
|
||||
return this.read(localStorage.getItem(this.key)) || 'pl';
|
||||
}
|
||||
|
||||
private apply(lang: AppLanguage) {
|
||||
this.langState.set(lang);
|
||||
this.translate.use(lang);
|
||||
}
|
||||
|
||||
private read(value: string | null | undefined): AppLanguage | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return this.supportedLanguages.includes(value as AppLanguage) ? (value as AppLanguage) : null;
|
||||
}
|
||||
}
|
||||
22
frontend/src/app/core/services/layout.service.ts
Normal file
22
frontend/src/app/core/services/layout.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutService {
|
||||
private readonly collapsedState = signal(false);
|
||||
private readonly mobileOpenState = signal(false);
|
||||
|
||||
readonly collapsed = computed(() => this.collapsedState());
|
||||
readonly mobileOpen = computed(() => this.mobileOpenState());
|
||||
|
||||
toggleSidebar() {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 992) {
|
||||
this.mobileOpenState.update((value) => !value);
|
||||
return;
|
||||
}
|
||||
this.collapsedState.update((value) => !value);
|
||||
}
|
||||
|
||||
closeMobileSidebar() {
|
||||
this.mobileOpenState.set(false);
|
||||
}
|
||||
}
|
||||
41
frontend/src/app/core/services/theme.service.ts
Normal file
41
frontend/src/app/core/services/theme.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
private readonly key = 'routeros_theme';
|
||||
private readonly modeState = signal<'light' | 'dark'>('dark');
|
||||
|
||||
readonly mode = computed(() => this.modeState());
|
||||
readonly isDark = computed(() => this.modeState() === 'dark');
|
||||
|
||||
init() {
|
||||
const mode = (localStorage.getItem(this.key) as 'light' | 'dark' | null) || 'dark';
|
||||
this.set(mode);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.set(this.modeState() === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
set(mode: 'light' | 'dark') {
|
||||
this.modeState.set(mode);
|
||||
|
||||
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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
81
frontend/src/app/core/services/ui.service.ts
Normal file
81
frontend/src/app/core/services/ui.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
|
||||
interface ConfirmOptions {
|
||||
messageKey: string;
|
||||
params?: Record<string, unknown>;
|
||||
headerKey?: string;
|
||||
acceptKey?: string;
|
||||
rejectKey?: string;
|
||||
acceptButtonStyleClass?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UiService {
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly confirmationService = inject(ConfirmationService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
success(detailKey: string, params?: Record<string, unknown>) {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: this.t('toast.success'),
|
||||
detail: this.t(detailKey, params)
|
||||
});
|
||||
}
|
||||
|
||||
info(detailKey: string, params?: Record<string, unknown>) {
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: this.t('toast.info'),
|
||||
detail: this.t(detailKey, params)
|
||||
});
|
||||
}
|
||||
|
||||
error(detailKey: string, params?: Record<string, unknown>) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: this.t('toast.error'),
|
||||
detail: this.t(detailKey, params)
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.messageService.clear();
|
||||
}
|
||||
|
||||
confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(value);
|
||||
}
|
||||
};
|
||||
|
||||
this.confirmationService.confirm({
|
||||
header: this.t(options.headerKey ?? 'confirm.header'),
|
||||
message: this.t(options.messageKey, options.params),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: this.t(options.acceptKey ?? 'common.confirm'),
|
||||
rejectLabel: this.t(options.rejectKey ?? 'common.cancel'),
|
||||
acceptButtonStyleClass: options.acceptButtonStyleClass ?? 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-text',
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
accept: () => finish(true),
|
||||
reject: () => finish(false)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
instant(key: string, params?: Record<string, unknown>) {
|
||||
return this.t(key, params);
|
||||
}
|
||||
|
||||
private t(key: string, params?: Record<string, unknown>): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
134
frontend/src/app/core/theme-preset.ts
Normal file
134
frontend/src/app/core/theme-preset.ts
Normal 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;
|
||||
@@ -0,0 +1,50 @@
|
||||
<app-page-header [eyebrow]="'auth.securityEyebrow' | translate" [title]="'auth.changePassword' | translate" [subtitle]="'auth.changePasswordSubtitle' | translate"></app-page-header>
|
||||
|
||||
<div class="change-password-shell">
|
||||
<app-section-card [title]="'auth.changePassword' | translate" [subtitle]="'auth.passwordPanelSubtitle' | translate">
|
||||
<div class="password-insights">
|
||||
<div class="password-strength">
|
||||
<span>{{ 'auth.passwordStrength' | translate }}</span>
|
||||
<strong>{{ passwordStrengthLabel }}</strong>
|
||||
</div>
|
||||
<div class="password-strength__track"><span [style.width.%]="passwordStrengthPercent"></span></div>
|
||||
<div class="password-checklist">
|
||||
<div class="password-checklist__item" [class.is-ready]="hasMinLength">
|
||||
<i class="pi" [class.pi-check-circle]="hasMinLength" [class.pi-circle]="!hasMinLength"></i>
|
||||
<span>{{ 'auth.ruleLength' | translate }}</span>
|
||||
</div>
|
||||
<div class="password-checklist__item" [class.is-ready]="hasDigit">
|
||||
<i class="pi" [class.pi-check-circle]="hasDigit" [class.pi-circle]="!hasDigit"></i>
|
||||
<span>{{ 'auth.ruleDigit' | translate }}</span>
|
||||
</div>
|
||||
<div class="password-checklist__item" [class.is-ready]="passwordsMatch">
|
||||
<i class="pi" [class.pi-check-circle]="passwordsMatch" [class.pi-circle]="!passwordsMatch"></i>
|
||||
<span>{{ 'auth.ruleMatch' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card [title]="'auth.changePassword' | translate" [subtitle]="'auth.changePasswordCardSubtitle' | translate">
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid change-password-form change-password-form--expanded">
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.currentPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="current_password" autocomplete="current-password" />
|
||||
</span>
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.newPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="new_password" autocomplete="new-password" />
|
||||
</span>
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.confirmPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="confirmPassword" autocomplete="new-password" />
|
||||
</span>
|
||||
<small class="form-field--full table-secondary">{{ passwordsMatch ? ('auth.passwordsMatchHint' | translate) : ('auth.passwordsMismatch' | translate) }}</small>
|
||||
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||
<a class="auth-link" routerLink="/">{{ 'auth.backToApp' | translate }}</a>
|
||||
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.changePassword' | translate"></button>
|
||||
</div>
|
||||
</form>
|
||||
</app-section-card>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, PageHeaderComponent, SectionCardComponent],
|
||||
templateUrl: './change-password-page.component.html'
|
||||
})
|
||||
export class ChangePasswordPageComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
error = '';
|
||||
submitting = false;
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
current_password: ['', Validators.required],
|
||||
new_password: ['', [Validators.required, Validators.minLength(4)]],
|
||||
confirmPassword: ['', [Validators.required, Validators.minLength(4)]]
|
||||
});
|
||||
|
||||
|
||||
get hasMinLength(): boolean {
|
||||
return (this.form.controls.new_password.value || '').length >= 8;
|
||||
}
|
||||
|
||||
get hasDigit(): boolean {
|
||||
return /[0-9]/.test(this.form.controls.new_password.value || '');
|
||||
}
|
||||
|
||||
get passwordStrengthPercent(): number {
|
||||
const value = this.form.controls.new_password.value || '';
|
||||
let score = 0;
|
||||
if (value.length >= 8) score += 35;
|
||||
if (/[A-Z]/.test(value)) score += 20;
|
||||
if (/[a-z]/.test(value)) score += 15;
|
||||
if (/[0-9]/.test(value)) score += 15;
|
||||
if (/[^A-Za-z0-9]/.test(value)) score += 15;
|
||||
return Math.min(100, score);
|
||||
}
|
||||
|
||||
get passwordStrengthLabel(): string {
|
||||
const score = this.passwordStrengthPercent;
|
||||
if (score >= 80) return this.ui.instant('auth.passwordStrong');
|
||||
if (score >= 50) return this.ui.instant('auth.passwordMedium');
|
||||
return this.ui.instant('auth.passwordWeak');
|
||||
}
|
||||
|
||||
get passwordsMatch(): boolean {
|
||||
const { new_password, confirmPassword } = this.form.getRawValue();
|
||||
return !!new_password && new_password === confirmPassword;
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
const { current_password, new_password, confirmPassword } = this.form.getRawValue();
|
||||
if (new_password !== confirmPassword) {
|
||||
this.error = this.ui.instant('auth.passwordsMismatch');
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.auth.changePassword(current_password, new_password).subscribe({
|
||||
next: () => {
|
||||
this.ui.success('toast.passwordChanged');
|
||||
setTimeout(() => this.router.navigate(['/']), 500);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = err?.error?.detail ?? this.ui.instant('auth.changePasswordFailed');
|
||||
this.submitting = false;
|
||||
},
|
||||
complete: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/features/auth/login-page.component.html
Normal file
29
frontend/src/app/features/auth/login-page.component.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="auth-shell auth-shell--compact">
|
||||
<app-auth-toolbar></app-auth-toolbar>
|
||||
|
||||
<div class="auth-card auth-card--wide">
|
||||
<div class="auth-card__header">
|
||||
<h2>{{ 'auth.login' | translate }}</h2>
|
||||
<p>{{ 'auth.loginSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid">
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.username' | translate }}</label>
|
||||
<input pInputText formControlName="username" autocomplete="username" />
|
||||
</span>
|
||||
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.password' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="password" autocomplete="current-password" />
|
||||
</span>
|
||||
|
||||
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||
|
||||
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||
<a class="auth-link" routerLink="/register">{{ 'auth.register' | translate }}</a>
|
||||
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.login' | translate"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
49
frontend/src/app/features/auth/login-page.component.ts
Normal file
49
frontend/src/app/features/auth/login-page.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { AuthToolbarComponent } from '../../shared/auth/auth-toolbar.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, AuthToolbarComponent],
|
||||
templateUrl: './login-page.component.html'
|
||||
})
|
||||
export class LoginPageComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
error = '';
|
||||
submitting = false;
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
username: ['', Validators.required],
|
||||
password: ['', Validators.required]
|
||||
});
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
this.submitting = true;
|
||||
const { username, password } = this.form.getRawValue();
|
||||
this.auth.login(username, password).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: (err) => {
|
||||
this.error = err?.error?.detail ?? this.ui.instant('auth.loginFailed');
|
||||
this.submitting = false;
|
||||
},
|
||||
complete: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
28
frontend/src/app/features/auth/register-page.component.html
Normal file
28
frontend/src/app/features/auth/register-page.component.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<div class="auth-shell auth-shell--compact">
|
||||
<app-auth-toolbar></app-auth-toolbar>
|
||||
<div class="auth-card auth-card--wide">
|
||||
<div class="auth-card__header">
|
||||
<h2>{{ 'auth.register' | translate }}</h2>
|
||||
</div>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid">
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'auth.username' | translate }}</label>
|
||||
<input pInputText formControlName="username" autocomplete="username" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'auth.password' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="password" autocomplete="new-password" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'auth.confirmPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="confirmPassword" autocomplete="new-password" />
|
||||
</span>
|
||||
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||
<small *ngIf="success" class="form-success form-field--full">{{ success }}</small>
|
||||
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||
<a class="auth-link" routerLink="/login">{{ 'auth.backToLogin' | translate }}</a>
|
||||
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.register' | translate"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
59
frontend/src/app/features/auth/register-page.component.ts
Normal file
59
frontend/src/app/features/auth/register-page.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { AuthToolbarComponent } from '../../shared/auth/auth-toolbar.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, AuthToolbarComponent],
|
||||
templateUrl: './register-page.component.html'
|
||||
})
|
||||
export class RegisterPageComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
error = '';
|
||||
success = '';
|
||||
submitting = false;
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
username: ['', [Validators.required, Validators.minLength(3)]],
|
||||
password: ['', [Validators.required, Validators.minLength(4)]],
|
||||
confirmPassword: ['', [Validators.required, Validators.minLength(4)]]
|
||||
});
|
||||
|
||||
submit() {
|
||||
if (this.form.invalid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
this.success = '';
|
||||
const { username, password, confirmPassword } = this.form.getRawValue();
|
||||
if (password !== confirmPassword) {
|
||||
this.error = this.ui.instant('auth.passwordsMismatch');
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.auth.register(username, password).subscribe({
|
||||
next: () => {
|
||||
this.success = this.ui.instant('auth.accountCreated');
|
||||
setTimeout(() => this.router.navigate(['/login']), 500);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = err?.error?.detail ?? this.ui.instant('auth.registrationFailed');
|
||||
this.submitting = false;
|
||||
},
|
||||
complete: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<app-page-header
|
||||
[eyebrow]="'dashboard.eyebrow' | translate"
|
||||
[title]="'dashboard.title' | translate"
|
||||
[subtitle]="'dashboard.subtitle' | translate"
|
||||
></app-page-header>
|
||||
|
||||
<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="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>
|
||||
|
||||
<app-section-card class="dashboard-operations-card" *ngIf="data" [title]="'dashboard.operationsTitle' | translate" [subtitle]="'dashboard.operationsSubtitle' | translate">
|
||||
<div class="operations-center">
|
||||
<div class="operations-center__actions">
|
||||
<button pButton type="button" icon="pi pi-upload" [label]="'dashboard.exportAll' | translate" [loading]="exporting" (click)="exportAll()"></button>
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="'dashboard.binaryAll' | translate" [loading]="runningBinary" (click)="binaryAll()"></button>
|
||||
</div>
|
||||
|
||||
<div class="operations-center__stats">
|
||||
<div class="metric-tile metric-tile--feature">
|
||||
<span>{{ 'dashboard.latestSnapshot' | translate }}</span>
|
||||
<strong>{{ latestBackupLabel }}</strong>
|
||||
<small>{{ latestBackupHint }}</small>
|
||||
</div>
|
||||
<div class="metric-tile metric-tile--feature">
|
||||
<span>{{ 'dashboard.coverageLabel' | translate }}</span>
|
||||
<strong>{{ coveragePercent }}%</strong>
|
||||
<small>{{ 'dashboard.coverageHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="metric-tile metric-tile--feature">
|
||||
<span>{{ 'dashboard.weeklyActivityLabel' | translate }}</span>
|
||||
<strong>{{ backupsLast7Days }}</strong>
|
||||
<small>{{ 'dashboard.weeklyActivityHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="metric-tile metric-tile--feature">
|
||||
<span>{{ 'dashboard.busiestRouterLabel' | translate }}</span>
|
||||
<strong>{{ busiestRouterLabel }}</strong>
|
||||
<small>{{ busiestRouterHint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<div class="dashboard-focus-grid" *ngIf="data">
|
||||
<app-section-card [title]="'dashboard.storageTitle' | translate" [subtitle]="'dashboard.storageSubtitle' | translate">
|
||||
<div class="dashboard-focus-block dashboard-focus-block--pie">
|
||||
<div class="dashboard-focus-pie" [style.background]="storageRingBackground">
|
||||
<div class="dashboard-focus-pie__inner">
|
||||
<strong>{{ formatPercent(usedPercent) }}</strong>
|
||||
<span>{{ 'dashboard.diskUsage' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-focus-summary">
|
||||
<p class="dashboard-focus-summary__lead">{{ 'dashboard.storageSubtitle' | translate }}</p>
|
||||
<div class="dashboard-focus-metrics">
|
||||
<div class="dashboard-focus-metric">
|
||||
<span>{{ 'dashboard.totalDisk' | translate }}</span>
|
||||
<strong>{{ formatBytes(data.storage.total) }}</strong>
|
||||
</div>
|
||||
<div class="dashboard-focus-metric">
|
||||
<span>{{ 'dashboard.usedSpace' | translate }}</span>
|
||||
<strong>{{ formatBytes(usedBytes) }}</strong>
|
||||
</div>
|
||||
<div class="dashboard-focus-metric">
|
||||
<span>{{ 'dashboard.freeSpace' | translate }}</span>
|
||||
<strong>{{ formatBytes(data.storage.free) }}</strong>
|
||||
</div>
|
||||
<div class="dashboard-focus-metric">
|
||||
<span>{{ 'dashboard.folderUsage' | translate }}</span>
|
||||
<strong>{{ formatBytes(data.storage.folder_used) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card [title]="'dashboard.storageViewActivity' | translate" [subtitle]="'dashboard.storageViewActivityHint' | translate">
|
||||
<ng-container *ngIf="backupActivityRows.length; else noSevenDayActivity">
|
||||
<div class="dashboard-focus-activity">
|
||||
<div class="dashboard-focus-activity__summary">
|
||||
<p>{{ 'dashboard.weeklyActivityHint' | translate }}</p>
|
||||
<strong>{{ backupsLast7Days }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-focus-columns">
|
||||
<div class="dashboard-focus-column" *ngFor="let item of backupActivityRows" [attr.title]="item.fullLabel + ': ' + item.value">
|
||||
<small>{{ item.label }}</small>
|
||||
<div class="dashboard-focus-column__track">
|
||||
<span [style.height.%]="item.height"></span>
|
||||
</div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</app-section-card>
|
||||
</div>
|
||||
|
||||
<ng-template #noSevenDayActivity>
|
||||
<div class="empty-state compact-empty dashboard-focus-empty">
|
||||
<i class="pi pi-history"></i>
|
||||
<p>{{ 'dashboard.noActivity' | translate }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
413
frontend/src/app/features/dashboard/dashboard-page.component.ts
Normal file
413
frontend/src/app/features/dashboard/dashboard-page.component.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||
|
||||
interface DashboardData {
|
||||
routers_count: number;
|
||||
export_count: number;
|
||||
binary_count: number;
|
||||
total_backups: number;
|
||||
recent_logs: { timestamp: string; message: string }[];
|
||||
storage: { total: number; used: number; free: number; folder_used: number; usage_percent: number };
|
||||
}
|
||||
|
||||
interface BackupInventoryItem {
|
||||
id: number;
|
||||
router_id: number;
|
||||
router_name?: string;
|
||||
backup_type: 'export' | 'binary';
|
||||
created_at: string;
|
||||
file_size?: number | null;
|
||||
}
|
||||
|
||||
interface RouterInventoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ChartTone = 'accent' | 'success' | 'info' | 'warning';
|
||||
|
||||
interface StorageChartRow {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
percent: number;
|
||||
tone: ChartTone;
|
||||
}
|
||||
|
||||
interface StorageActivityBar {
|
||||
label: string;
|
||||
fullLabel: string;
|
||||
value: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RouterBackupBar {
|
||||
label: string;
|
||||
value: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, ButtonModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||
templateUrl: './dashboard-page.component.html'
|
||||
})
|
||||
export class DashboardPageComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
data?: DashboardData;
|
||||
backups: BackupInventoryItem[] = [];
|
||||
routers: RouterInventoryItem[] = [];
|
||||
exporting = false;
|
||||
runningBinary = false;
|
||||
readonly activityPageSize = 6;
|
||||
activityPage = 0;
|
||||
|
||||
ngOnInit() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
forkJoin({
|
||||
dashboard: this.api.http.get<DashboardData>(`${this.api.baseUrl}/dashboard`),
|
||||
backups: this.api.http.get<BackupInventoryItem[]>(`${this.api.baseUrl}/backups`),
|
||||
routers: this.api.http.get<RouterInventoryItem[]>(`${this.api.baseUrl}/routers`)
|
||||
}).subscribe(({ dashboard, backups, routers }) => {
|
||||
this.data = dashboard;
|
||||
this.backups = backups;
|
||||
this.routers = routers;
|
||||
this.activityPage = 0;
|
||||
});
|
||||
}
|
||||
|
||||
exportAll() {
|
||||
if (this.exporting) {
|
||||
return;
|
||||
}
|
||||
this.exporting = true;
|
||||
this.api.http.post<any[]>(`${this.api.baseUrl}/backups/routers/export-all`, {}).subscribe({
|
||||
next: (result) => {
|
||||
this.ui.success('toast.exportedRouters', { count: result.filter((item) => item.status === 'ok').length });
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.exporting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
binaryAll() {
|
||||
if (this.runningBinary) {
|
||||
return;
|
||||
}
|
||||
this.runningBinary = true;
|
||||
this.api.http.post<any[]>(`${this.api.baseUrl}/backups/routers/binary-all`, {}).subscribe({
|
||||
next: (result) => {
|
||||
this.ui.success('toast.binaryCompletedRouters', { count: result.filter((item) => item.status === 'ok').length });
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.runningBinary = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get usedBytes(): number {
|
||||
const storage = this.data?.storage;
|
||||
if (!storage) {
|
||||
return 0;
|
||||
}
|
||||
const computed = Math.max(0, Number(storage.total || 0) - Number(storage.free || 0));
|
||||
if (computed > 0) {
|
||||
return computed;
|
||||
}
|
||||
return Math.max(0, Number(storage.used || 0));
|
||||
}
|
||||
|
||||
get usedPercent(): number {
|
||||
const total = Number(this.data?.storage.total || 0);
|
||||
return total > 0 ? (this.usedBytes / total) * 100 : 0;
|
||||
}
|
||||
|
||||
get freePercent(): number {
|
||||
const storage = this.data?.storage;
|
||||
const total = Number(storage?.total || 0);
|
||||
const free = Math.max(0, Number(storage?.free || 0));
|
||||
return total > 0 ? (free / total) * 100 : 0;
|
||||
}
|
||||
|
||||
get folderPercent(): number {
|
||||
const storage = this.data?.storage;
|
||||
const total = Number(storage?.total || 0);
|
||||
const folderUsed = Math.max(0, Number(storage?.folder_used || 0));
|
||||
return total > 0 ? (folderUsed / total) * 100 : 0;
|
||||
}
|
||||
|
||||
get storageCapacityRows(): StorageChartRow[] {
|
||||
const storage = this.data?.storage;
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ labelKey: 'dashboard.totalDisk', value: this.formatBytes(storage.total), percent: 100, tone: 'accent' },
|
||||
{ labelKey: 'dashboard.usedSpace', value: this.formatBytes(this.usedBytes), percent: this.usedPercent, tone: 'warning' },
|
||||
{ labelKey: 'dashboard.freeSpace', value: this.formatBytes(storage.free), percent: this.freePercent, tone: 'success' },
|
||||
{ labelKey: 'dashboard.folderUsage', value: this.formatBytes(storage.folder_used), percent: this.folderPercent, tone: 'info' }
|
||||
];
|
||||
}
|
||||
|
||||
get backupMixRows(): StorageChartRow[] {
|
||||
const exportCount = Number(this.data?.export_count || 0);
|
||||
const binaryCount = Number(this.data?.binary_count || 0);
|
||||
const total = exportCount + binaryCount;
|
||||
return [
|
||||
{
|
||||
labelKey: 'dashboard.exportsCard',
|
||||
value: String(exportCount),
|
||||
percent: total > 0 ? (exportCount / total) * 100 : 0,
|
||||
tone: 'accent'
|
||||
},
|
||||
{
|
||||
labelKey: 'dashboard.binaryCard',
|
||||
value: String(binaryCount),
|
||||
percent: total > 0 ? (binaryCount / total) * 100 : 0,
|
||||
tone: 'warning'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get backupActivityRows(): StorageActivityBar[] {
|
||||
if (!this.backups.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const backup of this.backups) {
|
||||
const value = new Date(backup.created_at);
|
||||
value.setHours(0, 0, 0, 0);
|
||||
const key = value.toISOString().slice(0, 10);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const items: StorageActivityBar[] = [];
|
||||
for (let offset = 6; offset >= 0; offset -= 1) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - offset);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
const label = `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
items.push({ label, fullLabel: key, value: counts.get(key) || 0, height: 0 });
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...items.map((item) => item.value), 0);
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
height: item.value > 0 && maxValue > 0 ? Math.max(16, Math.round((item.value / maxValue) * 100)) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
get routerBackupRows(): RouterBackupBar[] {
|
||||
if (!this.backups.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const counters = new Map<number, RouterBackupBar>();
|
||||
for (const backup of this.backups) {
|
||||
const entry = counters.get(backup.router_id) || { label: backup.router_name || `#${backup.router_id}`, value: 0, percent: 0 };
|
||||
entry.value += 1;
|
||||
counters.set(backup.router_id, entry);
|
||||
}
|
||||
|
||||
const sorted = Array.from(counters.values()).sort((a, b) => b.value - a.value).slice(0, 5);
|
||||
const maxValue = Math.max(...sorted.map((item) => item.value), 0);
|
||||
return sorted.map((item) => ({ ...item, percent: maxValue > 0 ? (item.value / maxValue) * 100 : 0 }));
|
||||
}
|
||||
|
||||
get averageBackupsPerRouter(): string {
|
||||
if (!this.data?.routers_count) {
|
||||
return '0';
|
||||
}
|
||||
return (this.data.total_backups / this.data.routers_count).toFixed(1);
|
||||
}
|
||||
|
||||
get coveragePercent(): number {
|
||||
if (!this.routers.length) {
|
||||
return 0;
|
||||
}
|
||||
const routersWithBackups = new Set(this.backups.map((item) => item.router_id)).size;
|
||||
return Math.round((routersWithBackups / this.routers.length) * 100);
|
||||
}
|
||||
|
||||
get exportsSharePercent(): number {
|
||||
if (!this.backups.length) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((this.backups.filter((item) => item.backup_type === 'export').length / this.backups.length) * 100);
|
||||
}
|
||||
|
||||
get backupsLast7Days(): number {
|
||||
const threshold = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
return this.backups.filter((item) => new Date(item.created_at).getTime() >= threshold).length;
|
||||
}
|
||||
|
||||
get latestBackupLabel(): string {
|
||||
const latest = this.latestBackup;
|
||||
if (!latest) {
|
||||
return this.ui.instant('dashboard.noneLabel');
|
||||
}
|
||||
return latest.router_name || `#${latest.router_id}`;
|
||||
}
|
||||
|
||||
get latestBackupHint(): string {
|
||||
const latest = this.latestBackup;
|
||||
if (!latest) {
|
||||
return this.ui.instant('dashboard.noActivity');
|
||||
}
|
||||
return `${latest.backup_type === 'export' ? this.ui.instant('files.exportType') : this.ui.instant('files.binaryType')} · ${this.relativeAge(latest.created_at)}`;
|
||||
}
|
||||
|
||||
get busiestRouterLabel(): string {
|
||||
const busiest = this.busiestRouter;
|
||||
if (!busiest) {
|
||||
return this.ui.instant('dashboard.noneLabel');
|
||||
}
|
||||
return busiest.name;
|
||||
}
|
||||
|
||||
get busiestRouterHint(): string {
|
||||
const busiest = this.busiestRouter;
|
||||
if (!busiest) {
|
||||
return this.ui.instant('dashboard.noActivity');
|
||||
}
|
||||
return this.ui.instant('dashboard.routerSnapshotsHint', { count: busiest.count });
|
||||
}
|
||||
|
||||
get activityToday(): number {
|
||||
if (!this.data?.recent_logs?.length) {
|
||||
return 0;
|
||||
}
|
||||
const today = new Date();
|
||||
return this.data.recent_logs.filter((log) => {
|
||||
const value = new Date(log.timestamp);
|
||||
return value.getFullYear() === today.getFullYear() && value.getMonth() === today.getMonth() && value.getDate() === today.getDate();
|
||||
}).length;
|
||||
}
|
||||
|
||||
get activityPageCount(): number {
|
||||
const total = this.data?.recent_logs?.length || 0;
|
||||
return Math.max(1, Math.ceil(total / this.activityPageSize));
|
||||
}
|
||||
|
||||
get pagedRecentLogs() {
|
||||
if (!this.data?.recent_logs?.length) {
|
||||
return [];
|
||||
}
|
||||
const start = this.activityPage * this.activityPageSize;
|
||||
return this.data.recent_logs.slice(start, start + this.activityPageSize);
|
||||
}
|
||||
|
||||
get activityRangeLabel(): string {
|
||||
const total = this.data?.recent_logs?.length || 0;
|
||||
if (!total) {
|
||||
return '0 / 0';
|
||||
}
|
||||
const start = this.activityPage * this.activityPageSize + 1;
|
||||
const end = Math.min(total, start + this.activityPageSize - 1);
|
||||
return `${start}-${end} / ${total}`;
|
||||
}
|
||||
|
||||
previousActivityPage() {
|
||||
this.activityPage = Math.max(0, this.activityPage - 1);
|
||||
}
|
||||
|
||||
nextActivityPage() {
|
||||
this.activityPage = Math.min(this.activityPageCount - 1, this.activityPage + 1);
|
||||
}
|
||||
|
||||
get storageRingBackground(): string {
|
||||
const safePercent = Math.min(100, Math.max(0, this.usedPercent));
|
||||
return `conic-gradient(var(--accent) 0deg ${safePercent * 3.6}deg, rgba(129, 149, 167, 0.18) ${safePercent * 3.6}deg 360deg)`;
|
||||
}
|
||||
|
||||
formatBytes(value: number): string {
|
||||
if (!value) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
formatPercent(value: number): string {
|
||||
return `${Number(value || 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
relativeAge(value: string): string {
|
||||
const diff = Date.now() - new Date(value).getTime();
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
if (hours < 1) {
|
||||
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||
}
|
||||
if (hours < 24) {
|
||||
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return this.ui.instant('files.daysAgo', { value: days });
|
||||
}
|
||||
|
||||
activityTone(message: string): 'success' | 'danger' | 'warning' | 'info' {
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('fail') || normalized.includes('error')) return 'danger';
|
||||
if (normalized.includes('cleanup') || normalized.includes('retention')) return 'warning';
|
||||
if (normalized.includes('upload') || normalized.includes('email')) return 'info';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
activityIcon(message: string): string {
|
||||
const tone = this.activityTone(message);
|
||||
if (tone === 'danger') return 'pi pi-exclamation-triangle';
|
||||
if (tone === 'warning') return 'pi pi-broom';
|
||||
if (tone === 'info') return 'pi pi-send';
|
||||
return 'pi pi-check';
|
||||
}
|
||||
|
||||
activityLabel(message: string): string {
|
||||
const tone = this.activityTone(message);
|
||||
if (tone === 'danger') return this.ui.instant('dashboard.activityFailure');
|
||||
if (tone === 'warning') return this.ui.instant('dashboard.activityMaintenance');
|
||||
if (tone === 'info') return this.ui.instant('dashboard.activityDelivery');
|
||||
return this.ui.instant('dashboard.activitySuccess');
|
||||
}
|
||||
|
||||
private get latestBackup(): BackupInventoryItem | undefined {
|
||||
return this.backups.slice().sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
}
|
||||
|
||||
private get busiestRouter(): { name: string; count: number } | null {
|
||||
if (!this.backups.length) {
|
||||
return null;
|
||||
}
|
||||
const counters = new Map<number, { name: string; count: number }>();
|
||||
for (const backup of this.backups) {
|
||||
const entry = counters.get(backup.router_id) || { name: backup.router_name || `#${backup.router_id}`, count: 0 };
|
||||
entry.count += 1;
|
||||
counters.set(backup.router_id, entry);
|
||||
}
|
||||
return Array.from(counters.values()).sort((a, b) => b.count - a.count)[0] || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<app-page-header
|
||||
[eyebrow]="'diffConfigs.eyebrow' | translate"
|
||||
[title]="'diffConfigs.title' | translate"
|
||||
[subtitle]="'diffConfigs.subtitle' | translate"
|
||||
></app-page-header>
|
||||
|
||||
<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="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>
|
||||
|
||||
<app-section-card [title]="'diffConfigs.workspaceTitle' | translate" [subtitle]="'diffConfigs.workspaceSubtitle' | translate">
|
||||
<div class="diff-workspace">
|
||||
<div class="diff-workspace__toolbar">
|
||||
<span class="form-field diff-workspace__router">
|
||||
<label>{{ 'files.routerLabel' | translate }}</label>
|
||||
<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>
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-star" [label]="'files.compareLatestPair' | translate" (click)="fillLatestPair()" [disabled]="availableExportsCount < 2"></button>
|
||||
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'files.compareSelected' | translate" (click)="openStructuredDiff()" [disabled]="!compareReady" [loading]="compareBusy"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-workspace__pair">
|
||||
<div class="diff-pick-card" [class.is-selected]="!!compareLeft">
|
||||
<div class="diff-pick-card__header">
|
||||
<strong>{{ 'files.compareOlder' | translate }}</strong>
|
||||
<p-tag [value]="compareLeft ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareLeft ? 'success' : 'secondary'"></p-tag>
|
||||
</div>
|
||||
<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>
|
||||
<div class="dialog-actions">
|
||||
<button pButton type="button" severity="secondary" size="small" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-sort-alt" styleClass="diff-workspace__swap" (click)="swapCompare()" [disabled]="!compareLeftId && !compareRightId"></button>
|
||||
|
||||
<div class="diff-pick-card" [class.is-selected]="!!compareRight">
|
||||
<div class="diff-pick-card__header">
|
||||
<strong>{{ 'files.compareNewer' | translate }}</strong>
|
||||
<p-tag [value]="compareRight ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareRight ? 'success' : 'secondary'"></p-tag>
|
||||
</div>
|
||||
<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>
|
||||
<div class="dialog-actions">
|
||||
<button pButton type="button" severity="secondary" size="small" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card class="diff-configs-table-section" [title]="'diffConfigs.tableTitle' | translate" [subtitle]="'diffConfigs.tableSubtitle' | translate">
|
||||
<p-table [value]="exportFiles" [loading]="loading" [rows]="8" [paginator]="exportFiles.length > 8" responsiveLayout="scroll" styleClass="app-table repository-table">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th>{{ 'files.fileColumn' | translate }}</th>
|
||||
<th>{{ 'files.routerColumn' | translate }}</th>
|
||||
<th>{{ 'files.createdColumn' | translate }}</th>
|
||||
<th>{{ 'files.compareColumn' | translate }}</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-item>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-primary">{{ item.file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-primary">{{ item.router_name || item.router_id }}</div>
|
||||
<small class="table-secondary">ID {{ item.router_id }}</small>
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--labels table-actions--stack">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-left" [label]="'files.setOlder' | translate" (click)="assignCompare('left', item)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-right" [label]="'files.setNewer' | translate" (click)="assignCompare('right', item)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
|
||||
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||
<pre class="code-preview preview-dialog__content">{{ viewedExport }}</pre>
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1420px, 96vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||
<div class="diff-layout" *ngIf="diffData as diff">
|
||||
<div class="diff-layout__summary">
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-stats" *ngIf="diff.stats">
|
||||
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||
</div>
|
||||
<div class="dialog-actions preview-dialog__actions">
|
||||
<button pButton type="button" severity="help" icon="pi pi-external-link" [label]="'files.openHtmlDiff' | translate" (click)="openHtmlDiff()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="github-diff" *ngIf="diff.lines?.length; else plainDiffFallback">
|
||||
<div class="github-diff__row" *ngFor="let line of diff.lines" [attr.data-type]="line.type">
|
||||
<div class="github-diff__cell github-diff__cell--left">
|
||||
<span class="github-diff__number">{{ line.left_number || '' }}</span>
|
||||
<pre>{{ line.left_text || ' ' }}</pre>
|
||||
</div>
|
||||
<div class="github-diff__cell github-diff__cell--right">
|
||||
<span class="github-diff__number">{{ line.right_number || '' }}</span>
|
||||
<pre>{{ line.right_text || ' ' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #plainDiffFallback>
|
||||
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||
</ng-template>
|
||||
</div>
|
||||
</p-dialog>
|
||||
@@ -0,0 +1,248 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpParams } from '@angular/common/http';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||
|
||||
interface BackupFile {
|
||||
id: number;
|
||||
router_id: number;
|
||||
router_name?: string;
|
||||
file_name: string;
|
||||
backup_type: 'export' | 'binary';
|
||||
created_at: string;
|
||||
checksum?: string | null;
|
||||
file_size?: number | null;
|
||||
}
|
||||
|
||||
interface BackupDiffLine {
|
||||
type: 'context' | 'added' | 'removed' | 'modified';
|
||||
left_number?: number | null;
|
||||
right_number?: number | null;
|
||||
left_text: string;
|
||||
right_text: string;
|
||||
}
|
||||
|
||||
interface BackupDiffStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
context: number;
|
||||
}
|
||||
|
||||
interface BackupDiffResponse {
|
||||
left_backup_id: number;
|
||||
right_backup_id: number;
|
||||
left_file_name?: string | null;
|
||||
right_file_name?: string | null;
|
||||
diff_text: string;
|
||||
lines: BackupDiffLine[];
|
||||
stats?: BackupDiffStats | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||
templateUrl: './diff-configs-page.component.html'
|
||||
})
|
||||
export class DiffConfigsPageComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
files: BackupFile[] = [];
|
||||
routers: { id: number; name: string }[] = [];
|
||||
routerId: number | null = null;
|
||||
compareLeftId: number | null = null;
|
||||
compareRightId: number | null = null;
|
||||
previewVisible = false;
|
||||
diffVisible = false;
|
||||
compareBusy = false;
|
||||
loading = false;
|
||||
previewTitle = '';
|
||||
viewedExport = '';
|
||||
diffData: BackupDiffResponse | null = null;
|
||||
lastCompared: { left: number; right: number } | null = null;
|
||||
|
||||
get routerOptions() {
|
||||
return [{ label: this.ui.instant('files.allRouters'), value: null }, ...this.routers.map((item) => ({ label: item.name, value: item.id }))];
|
||||
}
|
||||
|
||||
get exportFiles(): BackupFile[] {
|
||||
return this.files
|
||||
.filter((item) => item.backup_type === 'export' && (this.routerId === null || item.router_id === this.routerId))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
}
|
||||
|
||||
get compareOptions() {
|
||||
return this.exportFiles.map((item) => ({
|
||||
label: `${item.router_name || item.router_id} · ${item.file_name}`,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
get compareLeft(): BackupFile | undefined {
|
||||
return this.files.find((item) => item.id === this.compareLeftId);
|
||||
}
|
||||
|
||||
get compareRight(): BackupFile | undefined {
|
||||
return this.files.find((item) => item.id === this.compareRightId);
|
||||
}
|
||||
|
||||
get compareReady(): boolean {
|
||||
return !!this.compareLeftId && !!this.compareRightId && this.compareLeftId !== this.compareRightId;
|
||||
}
|
||||
|
||||
get availableExportsCount(): number {
|
||||
return this.exportFiles.length;
|
||||
}
|
||||
|
||||
get selectedRouterLabel(): string {
|
||||
if (this.routerId === null) {
|
||||
return this.ui.instant('files.allRouters');
|
||||
}
|
||||
return this.routers.find((item) => item.id === this.routerId)?.name || `#${this.routerId}`;
|
||||
}
|
||||
|
||||
get lastDiffLabel(): string {
|
||||
if (!this.diffData?.left_file_name || !this.diffData?.right_file_name) {
|
||||
return this.ui.instant('diffConfigs.noneSelected');
|
||||
}
|
||||
return `${this.diffData.left_file_name} → ${this.diffData.right_file_name}`;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.api.http.get<any[]>(`${this.api.baseUrl}/routers`).subscribe((routers) => {
|
||||
this.routers = routers.map((item) => ({ id: item.id, name: item.name }));
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.loading = true;
|
||||
let params = new HttpParams().set('backup_type', 'export').set('sort_by', 'created_at').set('order', 'desc');
|
||||
if (this.routerId !== null) {
|
||||
params = params.set('router_id', String(this.routerId));
|
||||
}
|
||||
this.api.http.get<BackupFile[]>(`${this.api.baseUrl}/backups`, { params }).subscribe({
|
||||
next: (files) => {
|
||||
this.files = files;
|
||||
if (this.compareLeftId && !this.files.some((item) => item.id === this.compareLeftId)) {
|
||||
this.compareLeftId = null;
|
||||
}
|
||||
if (this.compareRightId && !this.files.some((item) => item.id === this.compareRightId)) {
|
||||
this.compareRightId = null;
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assignCompare(side: 'left' | 'right', item: BackupFile) {
|
||||
if (side === 'left') {
|
||||
this.compareLeftId = item.id;
|
||||
return;
|
||||
}
|
||||
this.compareRightId = item.id;
|
||||
}
|
||||
|
||||
fillLatestPair() {
|
||||
if (this.exportFiles.length < 2) {
|
||||
return;
|
||||
}
|
||||
const [right, left] = this.exportFiles;
|
||||
this.compareLeftId = left.id;
|
||||
this.compareRightId = right.id;
|
||||
}
|
||||
|
||||
swapCompare() {
|
||||
const left = this.compareLeftId;
|
||||
this.compareLeftId = this.compareRightId;
|
||||
this.compareRightId = left;
|
||||
}
|
||||
|
||||
viewExport(item: BackupFile) {
|
||||
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${item.id}/view`).subscribe((response) => {
|
||||
this.viewedExport = response.content;
|
||||
this.previewTitle = item.file_name;
|
||||
this.ui.clear();
|
||||
this.previewVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
openStructuredDiff() {
|
||||
if (!this.compareReady || this.compareBusy || !this.compareLeftId || !this.compareRightId) {
|
||||
return;
|
||||
}
|
||||
this.compareBusy = true;
|
||||
const [left, right] = this.sortPairByDate(this.compareLeftId, this.compareRightId);
|
||||
this.lastCompared = { left, right };
|
||||
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${left}/diff/${right}`).subscribe({
|
||||
next: (response) => {
|
||||
this.diffData = response;
|
||||
this.ui.clear();
|
||||
this.diffVisible = true;
|
||||
},
|
||||
complete: () => {
|
||||
this.compareBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openHtmlDiff() {
|
||||
if (!this.lastCompared) {
|
||||
return;
|
||||
}
|
||||
this.api.http
|
||||
.get(`${this.api.baseUrl}/backups/${this.lastCompared.left}/diff/${this.lastCompared.right}/html`, { responseType: 'text' })
|
||||
.subscribe((html) => {
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
relativeAge(value: string): string {
|
||||
const diff = Date.now() - new Date(value).getTime();
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
if (hours < 1) {
|
||||
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||
}
|
||||
if (hours < 24) {
|
||||
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return this.ui.instant('files.daysAgo', { value: days });
|
||||
}
|
||||
|
||||
checksumShort(value?: string | null): string {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
private sortPairByDate(firstId: number, secondId: number): [number, number] {
|
||||
const first = this.files.find((item) => item.id === firstId);
|
||||
const second = this.files.find((item) => item.id === secondId);
|
||||
if (!first || !second) {
|
||||
return [firstId, secondId];
|
||||
}
|
||||
return new Date(first.created_at).getTime() <= new Date(second.created_at).getTime() ? [firstId, secondId] : [secondId, firstId];
|
||||
}
|
||||
}
|
||||
191
frontend/src/app/features/files/files-page.component.html
Normal file
191
frontend/src/app/features/files/files-page.component.html
Normal file
@@ -0,0 +1,191 @@
|
||||
<app-page-header [eyebrow]="'files.eyebrow' | translate" [title]="'files.title' | translate" [subtitle]="'files.subtitle' | translate">
|
||||
<div header-actions class="header-actions-row">
|
||||
<button pButton type="button" icon="pi pi-download" [label]="'files.downloadZip' | translate" [loading]="bulkBusy && selectedIds.length > 0" (click)="bulkDownload()" [disabled]="selectedIds.length===0"></button>
|
||||
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" [loading]="bulkBusy && selectedIds.length > 0" (click)="bulkDelete()" [disabled]="selectedIds.length===0"></button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<div class="stats-grid compact-grid">
|
||||
<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="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
|
||||
</div>
|
||||
|
||||
<app-section-card [title]="'files.filtersTitle' | translate" [subtitle]="'files.filtersSubtitle' | translate">
|
||||
<div class="repository-toolbar">
|
||||
<span class="form-field repository-toolbar__search">
|
||||
<label>{{ 'files.searchLabel' | translate }}</label>
|
||||
<span class="p-input-icon-left">
|
||||
<i class="pi pi-search"></i>
|
||||
<input pInputText [(ngModel)]="search" [placeholder]="'files.searchPlaceholder' | translate" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="form-field">
|
||||
<label>{{ 'files.typeLabel' | translate }}</label>
|
||||
<p-select [options]="typeOptions" [(ngModel)]="backupType" optionLabel="label" optionValue="value"></p-select>
|
||||
</span>
|
||||
|
||||
<span class="form-field">
|
||||
<label>{{ 'files.routerLabel' | translate }}</label>
|
||||
<p-select [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value"></p-select>
|
||||
</span>
|
||||
|
||||
<span class="form-field">
|
||||
<label>{{ 'files.dateLabel' | translate }}</label>
|
||||
<input pInputText type="date" [(ngModel)]="createdOn" [placeholder]="'files.datePlaceholder' | translate" />
|
||||
</span>
|
||||
|
||||
<span class="form-field">
|
||||
<label>{{ 'files.sortLabel' | translate }}</label>
|
||||
<p-select [options]="sortOptions" [(ngModel)]="sortBy" optionLabel="label" optionValue="value"></p-select>
|
||||
</span>
|
||||
|
||||
<span class="form-field">
|
||||
<label>{{ 'files.orderLabel' | translate }}</label>
|
||||
<p-select [options]="orderOptions" [(ngModel)]="order" optionLabel="label" optionValue="value"></p-select>
|
||||
</span>
|
||||
|
||||
<div class="filters-actions repository-toolbar__actions">
|
||||
<button pButton type="button" [label]="'common.apply' | translate" icon="pi pi-filter" [loading]="loading" (click)="load()"></button>
|
||||
<button pButton type="button" severity="secondary" [label]="'common.reset' | translate" icon="pi pi-refresh" (click)="resetFilters()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repository-compare">
|
||||
<div class="repository-compare__header">
|
||||
<div>
|
||||
<strong>{{ 'files.compareTitle' | translate }}</strong>
|
||||
<p>{{ 'files.compareSubtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="repository-compare__status">
|
||||
<p-tag [value]="exportCount + ' ' + ('files.exportPoolLabel' | translate)" severity="success"></p-tag>
|
||||
<p-tag [value]="compareContextLabel" [severity]="compareReady ? 'info' : 'secondary'"></p-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repository-compare__grid">
|
||||
<div class="compare-strip__slot repository-compare__slot">
|
||||
<label>{{ 'files.compareOlder' | translate }}</label>
|
||||
<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-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">
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-star" [label]="'files.compareLatestPair' | translate" (click)="fillLatestPair()" [disabled]="exportFiles.length < 2"></button>
|
||||
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'files.compareSelected' | translate" (click)="openStructuredDiff()" [disabled]="!compareReady" [loading]="compareBusy"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card class="repository-table-section" [title]="'files.tableTitle' | translate" [subtitle]="'files.tableSubtitle' | translate">
|
||||
<p-table [value]="files" [(selection)]="selected" dataKey="id" [rows]="10" [loading]="loading" [paginator]="files.length > 10" responsiveLayout="scroll" styleClass="app-table repository-table">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th style="width:3rem"></th>
|
||||
<th>{{ 'files.fileColumn' | translate }}</th>
|
||||
<th>{{ 'files.routerColumn' | translate }}</th>
|
||||
<th>{{ 'files.typeColumn' | translate }}</th>
|
||||
<th>{{ 'files.createdColumn' | translate }}</th>
|
||||
<th>{{ 'files.sizeColumn' | translate }}</th>
|
||||
<th>{{ 'files.compareColumn' | translate }}</th>
|
||||
<th>{{ 'files.actionsColumn' | translate }}</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-item>
|
||||
<tr [attr.data-compare-role]="compareRole(item)">
|
||||
<td><p-tableCheckbox [value]="item"></p-tableCheckbox></td>
|
||||
<td>
|
||||
<div class="table-primary">{{ item.file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<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' : '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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-primary">{{ formatBytes(item.file_size) }}</div>
|
||||
<small class="table-secondary">{{ fileExtension(item) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--stack" *ngIf="item.backup_type === 'export'; else noCompare">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-left" [label]="'files.setOlder' | translate" (click)="assignCompare('left', item)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-right" [label]="'files.setNewer' | translate" (click)="assignCompare('right', item)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-code" [label]="'files.latestForRouter' | translate" (click)="compareClosestForRouter(item)"></button>
|
||||
</div>
|
||||
<ng-template #noCompare>
|
||||
<small class="table-secondary">{{ 'files.binaryNoCompare' | translate }}</small>
|
||||
</ng-template>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--labels table-actions--stack">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||
<button *ngIf="item.backup_type==='export'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||
<button *ngIf="item.backup_type==='binary' && item.device_type==='routeros'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="deleteOne(item.id)"></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
|
||||
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||
<pre class="code-preview preview-dialog__content">{{ viewedExport }}</pre>
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1420px, 96vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||
<div class="diff-layout" *ngIf="diffData as diff">
|
||||
<div class="diff-layout__summary">
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-stats" *ngIf="diff.stats">
|
||||
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||
</div>
|
||||
<div class="dialog-actions preview-dialog__actions">
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-align-left" [label]="'files.openPlainDiff' | translate" (click)="diffText = diff.diff_text"></button>
|
||||
<button pButton type="button" severity="help" icon="pi pi-external-link" [label]="'files.openHtmlDiff' | translate" (click)="openHtmlDiff()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="github-diff" *ngIf="diff.lines?.length; else plainDiffFallback">
|
||||
<div class="github-diff__row" *ngFor="let line of diff.lines" [attr.data-type]="line.type">
|
||||
<div class="github-diff__cell github-diff__cell--left">
|
||||
<span class="github-diff__number">{{ line.left_number || '' }}</span>
|
||||
<pre>{{ line.left_text || ' ' }}</pre>
|
||||
</div>
|
||||
<div class="github-diff__cell github-diff__cell--right">
|
||||
<span class="github-diff__number">{{ line.right_number || '' }}</span>
|
||||
<pre>{{ line.right_text || ' ' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #plainDiffFallback>
|
||||
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||
</ng-template>
|
||||
</div>
|
||||
</p-dialog>
|
||||
457
frontend/src/app/features/files/files-page.component.ts
Normal file
457
frontend/src/app/features/files/files-page.component.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||
|
||||
type DeviceType = 'routeros' | 'switchos';
|
||||
|
||||
interface BackupFile {
|
||||
id: number;
|
||||
router_id: number;
|
||||
router_name?: string;
|
||||
device_type: DeviceType;
|
||||
file_name: string;
|
||||
backup_type: 'export' | 'binary';
|
||||
created_at: string;
|
||||
checksum?: string | null;
|
||||
file_size?: number | null;
|
||||
}
|
||||
|
||||
interface BackupDiffLine {
|
||||
type: 'context' | 'added' | 'removed' | 'modified';
|
||||
left_number?: number | null;
|
||||
right_number?: number | null;
|
||||
left_text: string;
|
||||
right_text: string;
|
||||
}
|
||||
|
||||
interface BackupDiffStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
context: number;
|
||||
}
|
||||
|
||||
interface BackupDiffResponse {
|
||||
left_backup_id: number;
|
||||
right_backup_id: number;
|
||||
left_file_name?: string | null;
|
||||
right_file_name?: string | null;
|
||||
diff_text: string;
|
||||
lines: BackupDiffLine[];
|
||||
stats?: BackupDiffStats | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, InputTextModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||
templateUrl: './files-page.component.html'
|
||||
})
|
||||
export class FilesPageComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
files: BackupFile[] = [];
|
||||
selected: BackupFile[] = [];
|
||||
routers: { id: number; name: string }[] = [];
|
||||
search = '';
|
||||
backupType: 'export' | 'binary' | '' = '';
|
||||
routerId: number | null = null;
|
||||
createdOn = '';
|
||||
sortBy = 'created_at';
|
||||
order: 'asc' | 'desc' = 'desc';
|
||||
diffText = '';
|
||||
viewedExport = '';
|
||||
previewTitle = '';
|
||||
loading = false;
|
||||
bulkBusy = false;
|
||||
compareBusy = false;
|
||||
previewVisible = false;
|
||||
diffVisible = false;
|
||||
compareLeftId: number | null = null;
|
||||
compareRightId: number | null = null;
|
||||
diffData: BackupDiffResponse | null = null;
|
||||
lastCompared: { left: number; right: number } | null = null;
|
||||
|
||||
get typeOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('files.allTypes'), value: '' },
|
||||
{ label: this.ui.instant('files.exportType'), value: 'export' },
|
||||
{ label: this.ui.instant('files.binaryType'), value: 'binary' }
|
||||
];
|
||||
}
|
||||
|
||||
get sortOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('files.sortNewest'), value: 'created_at' },
|
||||
{ label: this.ui.instant('files.sortName'), value: 'file_name' },
|
||||
{ label: this.ui.instant('files.sortRouter'), value: 'router_name' },
|
||||
{ label: this.ui.instant('files.sortType'), value: 'backup_type' }
|
||||
];
|
||||
}
|
||||
|
||||
get orderOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('common.desc'), value: 'desc' },
|
||||
{ label: this.ui.instant('common.asc'), value: 'asc' }
|
||||
];
|
||||
}
|
||||
|
||||
get routerOptions() {
|
||||
return [{ label: this.ui.instant('files.allRouters'), value: null }, ...this.routers.map((r) => ({ label: r.name, value: r.id }))];
|
||||
}
|
||||
|
||||
get compareOptions() {
|
||||
return this.exportFiles.map((item) => ({
|
||||
label: `${item.router_name || item.router_id} · ${item.file_name}`,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
get exportFiles(): BackupFile[] {
|
||||
return this.files
|
||||
.filter((item) => item.backup_type === 'export')
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
}
|
||||
|
||||
get selectedIds(): number[] {
|
||||
return this.selected.map((item) => item.id);
|
||||
}
|
||||
|
||||
get exportCount(): number {
|
||||
return this.files.filter((item) => item.backup_type === 'export').length;
|
||||
}
|
||||
|
||||
get binaryCount(): number {
|
||||
return this.files.filter((item) => item.backup_type === 'binary').length;
|
||||
}
|
||||
|
||||
get compareLeft(): BackupFile | undefined {
|
||||
return this.files.find((item) => item.id === this.compareLeftId);
|
||||
}
|
||||
|
||||
get compareRight(): BackupFile | undefined {
|
||||
return this.files.find((item) => item.id === this.compareRightId);
|
||||
}
|
||||
|
||||
get compareReady(): boolean {
|
||||
return !!this.compareLeftId && !!this.compareRightId && this.compareLeftId !== this.compareRightId;
|
||||
}
|
||||
|
||||
get compareContextLabel(): string {
|
||||
if (!this.compareReady) {
|
||||
return this.ui.instant('files.compareSelectionHint');
|
||||
}
|
||||
const left = this.compareLeft;
|
||||
const right = this.compareRight;
|
||||
const routerName = left?.router_name || right?.router_name || '';
|
||||
if (left && right && left.router_id === right.router_id) {
|
||||
return this.ui.instant('files.compareReadySameRouter', { router: routerName || left.router_id });
|
||||
}
|
||||
return this.ui.instant('files.compareReadyMixedRouters');
|
||||
}
|
||||
|
||||
compareRole(item: BackupFile): 'left' | 'right' | '' {
|
||||
if (item.id === this.compareLeftId) {
|
||||
return 'left';
|
||||
}
|
||||
if (item.id === this.compareRightId) {
|
||||
return 'right';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.api.http.get<any[]>(`${this.api.baseUrl}/routers`).subscribe((routers) => {
|
||||
this.routers = routers.map((item) => ({ id: item.id, name: item.name }));
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.loading = true;
|
||||
let params = new HttpParams().set('sort_by', this.sortBy).set('order', this.order);
|
||||
if (this.search.trim()) params = params.set('search', this.search.trim());
|
||||
if (this.backupType) params = params.set('backup_type', this.backupType);
|
||||
if (this.routerId !== null) params = params.set('router_id', String(this.routerId));
|
||||
if (this.createdOn) params = params.set('created_on', this.createdOn);
|
||||
|
||||
this.api.http.get<BackupFile[]>(`${this.api.baseUrl}/backups`, { params }).subscribe({
|
||||
next: (files) => {
|
||||
this.files = files;
|
||||
this.selected = [];
|
||||
if (this.compareLeftId && !this.files.some((item) => item.id === this.compareLeftId)) {
|
||||
this.compareLeftId = null;
|
||||
}
|
||||
if (this.compareRightId && !this.files.some((item) => item.id === this.compareRightId)) {
|
||||
this.compareRightId = null;
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.search = '';
|
||||
this.backupType = '';
|
||||
this.routerId = null;
|
||||
this.createdOn = '';
|
||||
this.sortBy = 'created_at';
|
||||
this.order = 'desc';
|
||||
this.load();
|
||||
}
|
||||
|
||||
download(id: number) {
|
||||
this.api.http
|
||||
.get(`${this.api.baseUrl}/backups/${id}/download`, { observe: 'response', responseType: 'blob' })
|
||||
.subscribe((response) => this.openBlob(response, `backup-${id}`));
|
||||
}
|
||||
|
||||
viewExport(item: BackupFile) {
|
||||
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${item.id}/view`).subscribe((response) => {
|
||||
this.viewedExport = response.content;
|
||||
this.previewTitle = item.file_name;
|
||||
this.ui.clear();
|
||||
this.previewVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
sendEmail(id: number) {
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/${id}/email`, {}).subscribe(() => {
|
||||
this.ui.success('toast.backupSentEmail');
|
||||
});
|
||||
}
|
||||
|
||||
upload(item: BackupFile) {
|
||||
if (item.device_type !== 'routeros') {
|
||||
return;
|
||||
}
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/router/${item.router_id}/upload/${item.id}`, {}).subscribe(() => {
|
||||
this.ui.success('toast.binaryUploaded');
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOne(id: number) {
|
||||
const accepted = await this.ui.confirm({
|
||||
messageKey: 'confirm.deleteBackup',
|
||||
acceptKey: 'common.delete'
|
||||
});
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.api.http.delete(`${this.api.baseUrl}/backups/${id}`).subscribe(() => {
|
||||
this.ui.success('toast.backupDeleted');
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
assignCompare(side: 'left' | 'right', item: BackupFile) {
|
||||
if (item.backup_type !== 'export') {
|
||||
return;
|
||||
}
|
||||
if (side === 'left') {
|
||||
this.compareLeftId = item.id;
|
||||
} else {
|
||||
this.compareRightId = item.id;
|
||||
}
|
||||
}
|
||||
|
||||
fillLatestPair() {
|
||||
if (this.exportFiles.length < 2) {
|
||||
return;
|
||||
}
|
||||
const latest = this.exportFiles[this.exportFiles.length - 1];
|
||||
const previous = this.exportFiles[this.exportFiles.length - 2];
|
||||
this.compareLeftId = previous.id;
|
||||
this.compareRightId = latest.id;
|
||||
}
|
||||
|
||||
compareClosestForRouter(item: BackupFile) {
|
||||
const candidates = this.exportFiles.filter((entry) => entry.router_id === item.router_id);
|
||||
if (candidates.length < 2) {
|
||||
return;
|
||||
}
|
||||
const targetIndex = candidates.findIndex((entry) => entry.id === item.id);
|
||||
const older = candidates[Math.max(0, targetIndex - 1)];
|
||||
const newer = candidates[Math.min(candidates.length - 1, targetIndex + 1)];
|
||||
const left = older && older.id !== item.id ? older : item;
|
||||
const right = newer && newer.id !== item.id ? newer : item;
|
||||
if (left.id === right.id) {
|
||||
return;
|
||||
}
|
||||
this.setComparePair(left.id, right.id);
|
||||
this.openStructuredDiff();
|
||||
}
|
||||
|
||||
swapCompare() {
|
||||
const left = this.compareLeftId;
|
||||
this.compareLeftId = this.compareRightId;
|
||||
this.compareRightId = left;
|
||||
}
|
||||
|
||||
openStructuredDiff() {
|
||||
if (!this.compareReady || this.compareBusy || !this.compareLeftId || !this.compareRightId) {
|
||||
return;
|
||||
}
|
||||
this.compareBusy = true;
|
||||
const [left, right] = this.sortPairByDate(this.compareLeftId, this.compareRightId);
|
||||
this.lastCompared = { left, right };
|
||||
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${left}/diff/${right}`).subscribe({
|
||||
next: (response) => {
|
||||
this.diffData = response;
|
||||
this.diffText = response.diff_text;
|
||||
this.ui.clear();
|
||||
this.diffVisible = true;
|
||||
},
|
||||
complete: () => {
|
||||
this.compareBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openHtmlDiff() {
|
||||
if (!this.lastCompared) {
|
||||
return;
|
||||
}
|
||||
this.api.http
|
||||
.get(`${this.api.baseUrl}/backups/${this.lastCompared.left}/diff/${this.lastCompared.right}/html`, { responseType: 'text' })
|
||||
.subscribe((html) => {
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
async bulkDelete() {
|
||||
if (!this.selectedIds.length || this.bulkBusy) {
|
||||
return;
|
||||
}
|
||||
const accepted = await this.ui.confirm({
|
||||
messageKey: 'confirm.deleteSelectedFiles',
|
||||
params: { count: this.selectedIds.length },
|
||||
acceptKey: 'common.delete'
|
||||
});
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.bulkBusy = true;
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/bulk`, { action: 'delete', backup_ids: this.selectedIds }).subscribe({
|
||||
next: () => {
|
||||
this.ui.success('toast.selectedBackupsDeleted');
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.bulkBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bulkDownload() {
|
||||
if (!this.selectedIds.length || this.bulkBusy) {
|
||||
return;
|
||||
}
|
||||
this.bulkBusy = true;
|
||||
this.api.http
|
||||
.post(`${this.api.baseUrl}/backups/bulk`, { action: 'download', backup_ids: this.selectedIds }, { responseType: 'blob' })
|
||||
.subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'routeros-backups.zip';
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
this.ui.success('toast.archivePrepared');
|
||||
},
|
||||
complete: () => {
|
||||
this.bulkBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(value?: number | null): string {
|
||||
if (!value) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
relativeAge(value: string): string {
|
||||
const diff = Date.now() - new Date(value).getTime();
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
if (hours < 1) {
|
||||
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||
}
|
||||
if (hours < 24) {
|
||||
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return this.ui.instant('files.daysAgo', { value: days });
|
||||
}
|
||||
|
||||
checksumShort(value?: string | null): string {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
deviceLabel(item: BackupFile): string {
|
||||
return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros');
|
||||
}
|
||||
|
||||
fileExtension(item: BackupFile): string {
|
||||
const parts = item.file_name.split('.');
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '—';
|
||||
}
|
||||
|
||||
private setComparePair(firstId: number, secondId: number) {
|
||||
const [left, right] = this.sortPairByDate(firstId, secondId);
|
||||
this.compareLeftId = left;
|
||||
this.compareRightId = right;
|
||||
}
|
||||
|
||||
private sortPairByDate(firstId: number, secondId: number): [number, number] {
|
||||
const first = this.files.find((item) => item.id === firstId);
|
||||
const second = this.files.find((item) => item.id === secondId);
|
||||
if (!first || !second) {
|
||||
return [firstId, secondId];
|
||||
}
|
||||
return new Date(first.created_at).getTime() <= new Date(second.created_at).getTime() ? [firstId, secondId] : [secondId, firstId];
|
||||
}
|
||||
|
||||
private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
|
||||
const disposition = response.headers.get('content-disposition') || '';
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = match?.[1] || fallbackName;
|
||||
const url = URL.createObjectURL(response.body || new Blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/features/logs/logs-page.component.html
Normal file
25
frontend/src/app/features/logs/logs-page.component.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<app-page-header [eyebrow]="'logs.eyebrow' | translate" [title]="'logs.title' | translate" [subtitle]="'logs.subtitle' | translate">
|
||||
<div header-actions class="header-actions-row">
|
||||
<input pInputText type="number" [(ngModel)]="days" [placeholder]="'logs.daysPlaceholder' | translate" class="header-number-input" />
|
||||
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'logs.deleteOlderThan' | translate" [loading]="cleaning" (click)="cleanup()"></button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<div class="inline-summary inline-summary--soft inline-summary--tight">
|
||||
<div class="inline-summary__item">
|
||||
<strong>{{ retentionDays }} {{ 'logs.daysSuffix' | translate }}</strong>
|
||||
<span>{{ 'logs.retentionInfoLabel' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-section-card [title]="'logs.tableTitle' | translate" [subtitle]="'logs.tableSubtitle' | translate">
|
||||
<p-table [value]="logs" responsiveLayout="scroll" styleClass="app-table">
|
||||
<ng-template pTemplate="header"><tr><th>{{ 'logs.timestampColumn' | translate }}</th><th>{{ 'logs.messageColumn' | translate }}</th></tr></ng-template>
|
||||
<ng-template pTemplate="body" let-log>
|
||||
<tr>
|
||||
<td>{{ log.timestamp }}</td>
|
||||
<td><div class="table-primary">{{ log.message }}</div></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
63
frontend/src/app/features/logs/logs-page.component.ts
Normal file
63
frontend/src/app/features/logs/logs-page.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TableModule } from 'primeng/table';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, InputTextModule, TableModule, PageHeaderComponent, SectionCardComponent],
|
||||
templateUrl: './logs-page.component.html'
|
||||
})
|
||||
export class LogsPageComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
logs: any[] = [];
|
||||
days = 7;
|
||||
retentionDays = 7;
|
||||
cleaning = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.load();
|
||||
this.api.http.get<any>(`${this.api.baseUrl}/settings`).subscribe((settings) => {
|
||||
this.retentionDays = Number(settings?.log_retention_days || 7);
|
||||
this.days = this.retentionDays;
|
||||
});
|
||||
}
|
||||
|
||||
load() {
|
||||
this.api.http.get<any[]>(`${this.api.baseUrl}/logs`).subscribe((r) => (this.logs = r));
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
if (this.cleaning) {
|
||||
return;
|
||||
}
|
||||
const accepted = await this.ui.confirm({
|
||||
messageKey: 'confirm.deleteLogsOlderThan',
|
||||
params: { days: this.days },
|
||||
acceptKey: 'common.delete'
|
||||
});
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.cleaning = true;
|
||||
this.api.http.delete(`${this.api.baseUrl}/logs/older-than/${this.days}`).subscribe({
|
||||
next: () => {
|
||||
this.ui.success('toast.logsDeletedOlderThan', { days: this.days });
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.cleaning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<app-page-header
|
||||
[eyebrow]="'routers.profileEyebrow' | translate"
|
||||
[title]="routerItem?.name || ('routers.detailTitle' | translate)"
|
||||
[subtitle]="subtitle"
|
||||
>
|
||||
<div header-actions class="header-actions-row">
|
||||
<button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" (click)="runExport()"></button>
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" (click)="runBinary()"></button>
|
||||
<button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button>
|
||||
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<div class="stats-grid compact-grid">
|
||||
<app-stat-card [label]="'routers.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="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>
|
||||
|
||||
<div class="dashboard-grid router-detail-grid router-detail-grid--inspection">
|
||||
<app-section-card [title]="'routers.deviceStatusTitle' | translate" [subtitle]="'routers.deviceStatusSubtitle' | translate">
|
||||
<div class="router-status-panel" *ngIf="connection; else noConnection">
|
||||
<div class="metric-grid-2">
|
||||
<div class="metric-tile"><span>{{ 'routers.connectionStateTitle' | translate }}</span><strong>{{ connection.success ? ('common.ok' | translate) : ('common.failed' | translate) }}</strong></div>
|
||||
<div class="metric-tile"><span>{{ 'routers.lastTestAt' | translate }}</span><strong>{{ connection.tested_at | date:'short' }}</strong></div>
|
||||
<div class="metric-tile"><span>{{ 'routers.hostname' | translate }}</span><strong>{{ connection.hostname }}</strong></div>
|
||||
<div class="metric-tile"><span>{{ 'routers.model' | translate }}</span><strong>{{ connection.model }}</strong></div>
|
||||
<div class="metric-tile"><span>{{ 'routers.version' | translate }}</span><strong>{{ connection.version || 'n/a' }}</strong></div>
|
||||
<div class="metric-tile"><span>{{ 'routers.uptime' | translate }}</span><strong>{{ connection.uptime }}</strong></div>
|
||||
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.httpStatus' | translate }}</span><strong>{{ connection.http_status || '—' }}</strong></div>
|
||||
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.serverHeader' | translate }}</span><strong>{{ connection.server || '—' }}</strong></div>
|
||||
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.authMode' | translate }}</span><strong>{{ connection.auth_mode || '—' }}</strong></div>
|
||||
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.backupEndpoint' | translate }}</span><strong>{{ connection.backup_available ? ('routers.backupAvailable' | translate) : ('routers.backupUnavailable' | translate) }}</strong></div>
|
||||
</div>
|
||||
<div class="router-status-error" *ngIf="!connection.success && connection.error">
|
||||
<strong>{{ 'routers.lastError' | translate }}</strong>
|
||||
<span>{{ connection.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #noConnection>
|
||||
<div class="empty-state compact-empty">
|
||||
<i class="pi pi-sitemap"></i>
|
||||
<p>{{ 'routers.noConnection' | translate }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-section-card>
|
||||
|
||||
<div class="router-detail-inspection-stack" *ngIf="!isSwitchos">
|
||||
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
|
||||
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
|
||||
<div>
|
||||
<strong>{{ previewTitle }}</strong>
|
||||
<small>{{ 'routers.previewModalHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button pButton type="button" severity="info" icon="pi pi-eye" [label]="'routers.openPreviewModal' | translate" (click)="openPreviewModal()"></button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #noPreview>
|
||||
<div class="empty-state compact-empty">
|
||||
<i class="pi pi-eye"></i>
|
||||
<p>{{ 'routers.noPreview' | translate }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card [title]="'routers.diffTitle' | translate" [subtitle]="'routers.diffSubtitle' | translate">
|
||||
<div class="router-modal-summary" *ngIf="hasDiff && diffData; else noDiff">
|
||||
<div>
|
||||
<strong>{{ diffData.left_file_name }} → {{ diffData.right_file_name }}</strong>
|
||||
<small>{{ 'routers.diffModalHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'routers.openDiffModal' | translate" (click)="openDiffModal()"></button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #noDiff>
|
||||
<div class="empty-state compact-empty">
|
||||
<i class="pi pi-code"></i>
|
||||
<p>{{ 'routers.noDiff' | translate }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-section-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos">
|
||||
<app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate">
|
||||
<p-table [value]="exportBackups" responsiveLayout="scroll" styleClass="app-table">
|
||||
<ng-template pTemplate="header">
|
||||
<tr><th>{{ 'files.fileColumn' | translate }}</th><th>{{ 'files.createdColumn' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-item let-i="rowIndex">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-primary">{{ item.file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.exportType' | translate }}</small>
|
||||
</td>
|
||||
<td>{{ item.created_at }}</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--labels table-actions--tight">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||
<button pButton *ngIf="i > 0" type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-code" [label]="'common.diff' | translate" (click)="compareToLatest(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid router-detail-grid router-detail-grid--stack">
|
||||
<app-section-card [title]="'routers.binaryTableTitle' | translate" [subtitle]="'routers.binaryTableSubtitle' | translate">
|
||||
<p-table [value]="binaryBackups" responsiveLayout="scroll" styleClass="app-table">
|
||||
<ng-template pTemplate="header">
|
||||
<tr><th>{{ 'files.fileColumn' | translate }}</th><th>{{ 'files.createdColumn' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-item>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-primary">{{ item.file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.binaryType' | translate }}</small>
|
||||
</td>
|
||||
<td>{{ item.created_at }}</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--labels table-actions--tight">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||
<button *ngIf="!isSwitchos" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
</div>
|
||||
|
||||
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||
<pre class="code-preview preview-dialog__content">{{ exportContent }}</pre>
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1200px, 94vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||
<div class="diff-layout" *ngIf="diffData as diff; else plainDiffOnly">
|
||||
<div class="diff-layout__summary">
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||
<div>
|
||||
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||
</div>
|
||||
<div class="diff-stats" *ngIf="diff.stats">
|
||||
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||
</div>
|
||||
<ng-template #plainDiffOnly>
|
||||
<pre class="code-preview preview-dialog__content">{{ diffText }}</pre>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
@@ -0,0 +1,346 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpResponse } from '@angular/common/http';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||
|
||||
type DeviceType = 'routeros' | 'switchos';
|
||||
|
||||
interface DeviceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
device_type: DeviceType;
|
||||
effective_username?: string | null;
|
||||
supports_export: boolean;
|
||||
supports_restore_upload: boolean;
|
||||
last_connection_status?: boolean | null;
|
||||
last_connection_tested_at?: string | null;
|
||||
last_connection_error?: string | null;
|
||||
last_connection_hostname?: string | null;
|
||||
last_connection_model?: string | null;
|
||||
last_connection_version?: string | null;
|
||||
last_connection_uptime?: string | null;
|
||||
last_connection_transport?: string | null;
|
||||
last_connection_server?: string | null;
|
||||
last_connection_auth_mode?: string | null;
|
||||
last_connection_http_status?: string | null;
|
||||
last_connection_backup_available?: boolean | null;
|
||||
}
|
||||
|
||||
interface BackupItem {
|
||||
id: number;
|
||||
file_name: string;
|
||||
backup_type: 'export' | 'binary';
|
||||
created_at: string;
|
||||
device_type: DeviceType;
|
||||
}
|
||||
|
||||
interface ConnectionSnapshot {
|
||||
success: boolean;
|
||||
tested_at: string;
|
||||
hostname: string;
|
||||
model: string;
|
||||
version?: string | null;
|
||||
uptime: string;
|
||||
error?: string | null;
|
||||
transport?: string | null;
|
||||
server?: string | null;
|
||||
auth_mode?: string | null;
|
||||
http_status?: string | null;
|
||||
backup_available?: boolean | null;
|
||||
}
|
||||
|
||||
interface BackupDiffStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
context: number;
|
||||
}
|
||||
|
||||
interface BackupDiffResponse {
|
||||
left_backup_id: number;
|
||||
right_backup_id: number;
|
||||
left_file_name?: string | null;
|
||||
right_file_name?: string | null;
|
||||
diff_text: string;
|
||||
stats?: BackupDiffStats | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, ButtonModule, DialogModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||
templateUrl: './router-detail-page.component.html'
|
||||
})
|
||||
export class RouterDetailPageComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
routerId!: number;
|
||||
routerItem: DeviceItem | null = null;
|
||||
backups: BackupItem[] = [];
|
||||
connection: ConnectionSnapshot | null = null;
|
||||
exportContent = '';
|
||||
diffText = '';
|
||||
previewTitle = '';
|
||||
previewVisible = false;
|
||||
diffVisible = false;
|
||||
diffData: BackupDiffResponse | null = null;
|
||||
exporting = false;
|
||||
runningBinary = false;
|
||||
testing = false;
|
||||
deletingRouter = false;
|
||||
|
||||
get isSwitchos(): boolean {
|
||||
return this.routerItem?.device_type === 'switchos';
|
||||
}
|
||||
|
||||
get exportBackups(): BackupItem[] {
|
||||
return this.backups.filter((item) => item.backup_type === 'export');
|
||||
}
|
||||
|
||||
get binaryBackups(): BackupItem[] {
|
||||
return this.backups.filter((item) => item.backup_type === 'binary');
|
||||
}
|
||||
|
||||
get connectionStateLabel(): string {
|
||||
if (!this.connection) {
|
||||
return this.ui.instant('common.idle');
|
||||
}
|
||||
return this.connection.success ? this.ui.instant('common.ok') : this.ui.instant('common.failed');
|
||||
}
|
||||
|
||||
get hasPreview(): boolean {
|
||||
return !!this.exportContent;
|
||||
}
|
||||
|
||||
get hasDiff(): boolean {
|
||||
return !!this.diffText;
|
||||
}
|
||||
|
||||
get subtitle(): string {
|
||||
if (!this.routerItem) {
|
||||
return this.ui.instant('routers.detailSubtitle');
|
||||
}
|
||||
const suffix = this.routerItem.effective_username ? ` · ${this.routerItem.effective_username}` : '';
|
||||
return `${this.routerItem.host}:${this.routerItem.port}${suffix}`;
|
||||
}
|
||||
|
||||
get deviceTypeLabel(): string {
|
||||
return this.ui.instant(this.isSwitchos ? 'routers.switchos' : 'routers.routeros');
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.routerId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => {
|
||||
this.routerItem = routerItem;
|
||||
this.connection = this.mapStoredConnection(routerItem);
|
||||
});
|
||||
this.api.http.get<BackupItem[]>(`${this.api.baseUrl}/backups/router/${this.routerId}`).subscribe((r) => (this.backups = r));
|
||||
}
|
||||
|
||||
runExport() {
|
||||
if (this.exporting || this.isSwitchos) {
|
||||
return;
|
||||
}
|
||||
this.exporting = true;
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/export`, {}).subscribe({
|
||||
next: () => {
|
||||
this.ui.success('toast.exportCreated');
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.exporting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runBinary() {
|
||||
if (this.runningBinary) {
|
||||
return;
|
||||
}
|
||||
this.runningBinary = true;
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/binary`, {}).subscribe({
|
||||
next: () => {
|
||||
this.ui.success('toast.binaryCreated');
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.runningBinary = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testConnection() {
|
||||
if (this.testing) {
|
||||
return;
|
||||
}
|
||||
this.testing = true;
|
||||
this.api.http.get<ConnectionSnapshot>(`${this.api.baseUrl}/routers/${this.routerId}/test-connection`).subscribe({
|
||||
next: (result) => {
|
||||
this.connection = result;
|
||||
this.syncStoredConnection(result);
|
||||
if (result.success) {
|
||||
this.ui.success('toast.connectionSuccessful');
|
||||
} else {
|
||||
this.ui.error('toast.connectionFailed');
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.testing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
compareToLatest(id: number) {
|
||||
const latest = this.exportBackups[0];
|
||||
if (!latest || latest.id === id) {
|
||||
return;
|
||||
}
|
||||
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${id}/diff/${latest.id}`).subscribe((response) => {
|
||||
this.diffData = response;
|
||||
this.diffText = response.diff_text;
|
||||
this.ui.clear();
|
||||
this.diffVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const accepted = await this.ui.confirm({ messageKey: 'confirm.deleteBackup', acceptKey: 'common.delete' });
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.api.http.delete(`${this.api.baseUrl}/backups/${id}`).subscribe(() => {
|
||||
this.ui.success('toast.backupDeleted');
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
upload(id: number) {
|
||||
if (this.isSwitchos) {
|
||||
return;
|
||||
}
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/upload/${id}`, {}).subscribe(() => {
|
||||
this.ui.success('toast.binaryUploaded');
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRouter() {
|
||||
if (this.deletingRouter) {
|
||||
return;
|
||||
}
|
||||
const accepted = await this.ui.confirm({ messageKey: 'confirm.deleteRouterWithFiles', acceptKey: 'common.delete' });
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.deletingRouter = true;
|
||||
this.api.http.delete(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe({
|
||||
next: () => this.router.navigate(['/devices']),
|
||||
complete: () => {
|
||||
this.deletingRouter = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
download(id: number) {
|
||||
this.api.http
|
||||
.get(`${this.api.baseUrl}/backups/${id}/download`, { observe: 'response', responseType: 'blob' })
|
||||
.subscribe((response) => this.openBlob(response, `backup-${id}`));
|
||||
}
|
||||
|
||||
viewExport(id: number) {
|
||||
const backup = this.exportBackups.find((item) => item.id === id);
|
||||
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${id}/view`).subscribe((r) => {
|
||||
this.exportContent = r.content;
|
||||
this.previewTitle = backup?.file_name || this.ui.instant('routers.previewTitle');
|
||||
this.ui.clear();
|
||||
this.previewVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
sendEmail(id: number) {
|
||||
this.api.http.post(`${this.api.baseUrl}/backups/${id}/email`, {}).subscribe(() => {
|
||||
this.ui.success('toast.backupSentEmail');
|
||||
});
|
||||
}
|
||||
|
||||
openPreviewModal() {
|
||||
this.ui.clear();
|
||||
this.previewVisible = true;
|
||||
}
|
||||
|
||||
openDiffModal() {
|
||||
this.ui.clear();
|
||||
this.diffVisible = true;
|
||||
}
|
||||
|
||||
private mapStoredConnection(routerItem: DeviceItem): ConnectionSnapshot | null {
|
||||
if (!routerItem?.last_connection_tested_at) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
success: Boolean(routerItem.last_connection_status),
|
||||
tested_at: routerItem.last_connection_tested_at,
|
||||
hostname: routerItem.last_connection_hostname || routerItem.name,
|
||||
model: routerItem.last_connection_model || 'Unknown',
|
||||
version: routerItem.last_connection_version,
|
||||
uptime: routerItem.last_connection_uptime || 'Unknown',
|
||||
error: routerItem.last_connection_error || null,
|
||||
transport: routerItem.last_connection_transport || null,
|
||||
server: routerItem.last_connection_server || null,
|
||||
auth_mode: routerItem.last_connection_auth_mode || null,
|
||||
http_status: routerItem.last_connection_http_status || null,
|
||||
backup_available: routerItem.last_connection_backup_available ?? null
|
||||
};
|
||||
}
|
||||
|
||||
private syncStoredConnection(result: ConnectionSnapshot) {
|
||||
if (!this.routerItem) {
|
||||
return;
|
||||
}
|
||||
this.routerItem = {
|
||||
...this.routerItem,
|
||||
last_connection_status: result.success,
|
||||
last_connection_tested_at: result.tested_at,
|
||||
last_connection_hostname: result.hostname,
|
||||
last_connection_model: result.model,
|
||||
last_connection_version: result.version,
|
||||
last_connection_uptime: result.uptime,
|
||||
last_connection_error: result.error,
|
||||
last_connection_transport: result.transport,
|
||||
last_connection_server: result.server,
|
||||
last_connection_auth_mode: result.auth_mode,
|
||||
last_connection_http_status: result.http_status,
|
||||
last_connection_backup_available: result.backup_available
|
||||
};
|
||||
}
|
||||
|
||||
private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
|
||||
const disposition = response.headers.get('content-disposition') || '';
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = match?.[1] || fallbackName;
|
||||
const url = URL.createObjectURL(response.body || new Blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
156
frontend/src/app/features/routers/routers-page.component.html
Normal file
156
frontend/src/app/features/routers/routers-page.component.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<app-page-header [eyebrow]="'routers.eyebrow' | translate" [title]="'routers.title' | translate" [subtitle]="'routers.subtitle' | translate">
|
||||
<div header-actions class="header-actions-row">
|
||||
<button pButton type="button" icon="pi pi-plus" (click)="openCreate()" [label]="'routers.add' | translate"></button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<div class="inline-summary inline-summary--soft">
|
||||
<div class="inline-summary__item">
|
||||
<strong>{{ routers.length }}</strong>
|
||||
<span>{{ 'routers.registeredDevices' | translate }}</span>
|
||||
</div>
|
||||
<div class="inline-summary__divider"></div>
|
||||
<div class="inline-summary__item">
|
||||
<strong>{{ routerOsCount }}</strong>
|
||||
<span>{{ 'routers.routeros' | translate }}</span>
|
||||
</div>
|
||||
<div class="inline-summary__divider"></div>
|
||||
<div class="inline-summary__item">
|
||||
<strong>{{ switchOsCount }}</strong>
|
||||
<span>{{ 'routers.switchos' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
|
||||
<p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table">
|
||||
<ng-template pTemplate="header">
|
||||
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-routerItem>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-primary">{{ routerItem.name }}</div>
|
||||
<small class="table-secondary">{{ deviceTypeLabel(routerItem) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-primary">{{ routerItem.host }}:{{ routerItem.port }}</div>
|
||||
<small class="table-secondary">{{ accessUser(routerItem) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="inline-tags">
|
||||
<p-tag [value]="primaryAccessTag(routerItem).value" [severity]="primaryAccessTag(routerItem).severity"></p-tag>
|
||||
<p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions table-actions--labels">
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="edit(routerItem)"></button>
|
||||
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(routerItem.id)"></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</app-section-card>
|
||||
|
||||
<p-dialog [(visible)]="visible" [modal]="true" [draggable]="false" [resizable]="false" [style]="{ width: 'min(760px, 96vw)' }" styleClass="router-dialog">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="router-dialog-header">
|
||||
<div class="router-dialog-header__icon">
|
||||
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-sitemap' : 'pi-server'"></i>
|
||||
</div>
|
||||
<div class="router-dialog-header__text">
|
||||
<div class="router-dialog-header__eyebrow">
|
||||
{{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }}
|
||||
</div>
|
||||
<div class="router-dialog-header__title">{{ dialogTitle }}</div>
|
||||
<small>
|
||||
{{
|
||||
selectedDeviceType === 'switchos'
|
||||
? ('routers.switchDialogSubtitle' | translate)
|
||||
: ('routers.routerDialogSubtitle' | translate)
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="router-dialog-form">
|
||||
<section class="router-dialog-panel">
|
||||
<div class="router-dialog-panel__header">
|
||||
<div>
|
||||
<strong>{{ 'routers.connectionSectionTitle' | translate }}</strong>
|
||||
<p>{{ 'routers.connectionSectionHint' | translate }}</p>
|
||||
</div>
|
||||
<span class="router-dialog-pill">
|
||||
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-globe' : 'pi-shield'"></i>
|
||||
{{ selectedDeviceType === 'switchos' ? 'HTTP' : 'SSH' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2 router-dialog-grid">
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.name' | translate }}</label>
|
||||
<input pInputText formControlName="name" placeholder="core-router-waw" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.deviceType' | translate }}</label>
|
||||
<p-select [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-select>
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.host' | translate }}</label>
|
||||
<input pInputText formControlName="host" placeholder="10.0.0.1" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.port' | translate }}</label>
|
||||
<input pInputText type="number" formControlName="port" [placeholder]="selectedDeviceType === 'switchos' ? '80' : '22'" />
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="router-dialog-panel">
|
||||
<div class="router-dialog-panel__header">
|
||||
<div>
|
||||
<strong>{{ 'routers.credentialsSectionTitle' | translate }}</strong>
|
||||
<p>
|
||||
{{
|
||||
selectedDeviceType === 'switchos'
|
||||
? ('routers.switchDialogSubtitle' | translate)
|
||||
: ('routers.routerDialogSubtitle' | translate)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span class="router-dialog-pill">
|
||||
<i class="pi pi-key"></i>
|
||||
{{ selectedDeviceType === 'switchos' ? ('routers.defaultCredentials' | translate) : 'SSH' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2 router-dialog-grid">
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.sshUser' | translate }}</label>
|
||||
<input pInputText formControlName="ssh_user" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchUserPlaceholder' | translate) : 'admin'" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'routers.sshPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="ssh_password" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchPasswordPlaceholder' | translate) : ('routers.optionalPassword' | translate)" />
|
||||
</span>
|
||||
<span class="form-field form-field--full" *ngIf="selectedDeviceType === 'routeros'">
|
||||
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
|
||||
<textarea pTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="router-dialog-note" *ngIf="selectedDeviceType === 'switchos'">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>{{ 'routers.switchDefaultsHint' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dialog-actions router-dialog-actions">
|
||||
<button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="visible=false"></button>
|
||||
<button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
|
||||
</div>
|
||||
</form>
|
||||
</p-dialog>
|
||||
217
frontend/src/app/features/routers/routers-page.component.ts
Normal file
217
frontend/src/app/features/routers/routers-page.component.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
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';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
|
||||
type DeviceType = 'routeros' | 'switchos';
|
||||
|
||||
interface RouterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
device_type: DeviceType;
|
||||
ssh_user?: string | null;
|
||||
ssh_password?: string | null;
|
||||
ssh_key?: string | null;
|
||||
effective_username?: string | null;
|
||||
uses_global_ssh_key?: boolean;
|
||||
has_effective_ssh_key?: boolean;
|
||||
uses_global_switchos_credentials?: boolean;
|
||||
has_effective_password?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SelectModule,
|
||||
InputTextModule,
|
||||
TextareaModule,
|
||||
TableModule,
|
||||
TagModule,
|
||||
PageHeaderComponent,
|
||||
SectionCardComponent
|
||||
],
|
||||
templateUrl: './routers-page.component.html'
|
||||
})
|
||||
export class RoutersPageComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
visible = false;
|
||||
editingId: number | null = null;
|
||||
saving = false;
|
||||
routers: RouterItem[] = [];
|
||||
readonly deviceTypeOptions = [
|
||||
{ label: 'RouterOS', value: 'routeros' },
|
||||
{ label: 'SwitchOS', value: 'switchos' }
|
||||
];
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
device_type: ['routeros' as DeviceType, Validators.required],
|
||||
host: ['', Validators.required],
|
||||
port: [22, Validators.required],
|
||||
ssh_user: ['admin'],
|
||||
ssh_password: '',
|
||||
ssh_key: ''
|
||||
});
|
||||
|
||||
get dialogTitle(): string {
|
||||
return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle');
|
||||
}
|
||||
|
||||
get selectedDeviceType(): DeviceType {
|
||||
return this.form.controls.device_type.value;
|
||||
}
|
||||
|
||||
get routerOsCount(): number {
|
||||
return this.routers.filter((item) => item.device_type === 'routeros').length;
|
||||
}
|
||||
|
||||
get switchOsCount(): number {
|
||||
return this.routers.filter((item) => item.device_type === 'switchos').length;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.form.controls.device_type.valueChanges.subscribe((deviceType) => {
|
||||
this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType);
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => (this.routers = r));
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
this.editingId = null;
|
||||
this.form.reset({ name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' });
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
edit(item: RouterItem) {
|
||||
this.editingId = item.id;
|
||||
this.form.reset({
|
||||
name: item.name,
|
||||
device_type: item.device_type,
|
||||
host: item.host,
|
||||
port: item.port,
|
||||
ssh_user: item.ssh_user ?? '',
|
||||
ssh_password: item.ssh_password ?? '',
|
||||
ssh_key: item.ssh_key ?? ''
|
||||
});
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid || this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
const payload = this.form.getRawValue();
|
||||
if (payload.device_type === 'switchos') {
|
||||
payload.ssh_key = '';
|
||||
}
|
||||
const request$ = this.editingId
|
||||
? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, payload)
|
||||
: this.api.http.post(`${this.api.baseUrl}/routers`, payload);
|
||||
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.ui.success(this.editingId ? 'toast.routerUpdated' : 'toast.routerCreated');
|
||||
this.visible = false;
|
||||
this.load();
|
||||
},
|
||||
complete: () => {
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const accepted = await this.ui.confirm({
|
||||
messageKey: 'confirm.deleteRouterWithFiles',
|
||||
acceptKey: 'common.delete'
|
||||
});
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
this.api.http.delete(`${this.api.baseUrl}/routers/${id}`).subscribe(() => {
|
||||
this.ui.success('toast.routerDeleted');
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
open(id: number) {
|
||||
this.router.navigate(['/devices', id]);
|
||||
}
|
||||
|
||||
deviceTypeLabel(item: RouterItem): string {
|
||||
return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros');
|
||||
}
|
||||
|
||||
accessUser(item: RouterItem): string {
|
||||
return item.effective_username || item.ssh_user || '—';
|
||||
}
|
||||
|
||||
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
|
||||
if (item.device_type === 'switchos') {
|
||||
if (item.uses_global_switchos_credentials) {
|
||||
return { value: this.ui.instant('routers.defaultCredentials'), severity: 'info' };
|
||||
}
|
||||
if (item.has_effective_password) {
|
||||
return { value: this.ui.instant('routers.localCredentials'), severity: 'success' };
|
||||
}
|
||||
return { value: this.ui.instant('routers.noCredentials'), severity: 'secondary' };
|
||||
}
|
||||
|
||||
return {
|
||||
value: item.ssh_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'),
|
||||
severity: item.ssh_password ? 'warn' : 'secondary'
|
||||
};
|
||||
}
|
||||
|
||||
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 ? 'warn' : 'secondary'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: item.has_effective_ssh_key
|
||||
? this.ui.instant(item.uses_global_ssh_key ? 'routers.globalKeyMode' : 'routers.keyMode')
|
||||
: this.ui.instant('routers.noKey'),
|
||||
severity: item.has_effective_ssh_key ? 'success' : 'secondary'
|
||||
};
|
||||
}
|
||||
|
||||
private applyDeviceDefaults(deviceType: DeviceType) {
|
||||
if (deviceType === 'switchos') {
|
||||
this.form.patchValue({ port: 80, ssh_key: '', ssh_user: this.form.controls.ssh_user.value || '' }, { emitEvent: false });
|
||||
return;
|
||||
}
|
||||
this.form.patchValue({ port: 22, ssh_user: this.form.controls.ssh_user.value || 'admin' }, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
327
frontend/src/app/features/settings/settings-page.component.html
Normal file
327
frontend/src/app/features/settings/settings-page.component.html
Normal file
@@ -0,0 +1,327 @@
|
||||
<app-page-header [eyebrow]="'settings.eyebrow' | translate" [title]="'settings.title' | translate" [subtitle]="'settings.subtitle' | translate">
|
||||
<div header-actions class="header-actions-row">
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-envelope" [label]="'settings.testEmail' | translate" [loading]="testingEmail" (click)="testEmail()"></button>
|
||||
<button pButton type="button" severity="help" icon="pi pi-send" [label]="'settings.testPushover' | translate" [loading]="testingPushover" (click)="testPushover()"></button>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<div class="settings-status-grid" *ngIf="schedulerStatus as status">
|
||||
<div class="settings-status-card" *ngFor="let job of status.jobs" [attr.data-valid]="job.valid">
|
||||
<div class="settings-status-card__header">
|
||||
<strong>{{ job.label | translate }}</strong>
|
||||
<p-tag [value]="job.enabled ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="job.enabled ? 'success' : 'secondary'"></p-tag>
|
||||
</div>
|
||||
<div class="settings-status-card__body">
|
||||
<div class="settings-status-card__description">{{ job.description | translate: job.description_params }}</div>
|
||||
<small>{{ job.valid ? ((job.next_runs[0] | date:'short') || ('settings.noNextRun' | translate)) : job.error }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="settings-page-shell">
|
||||
<div class="settings-page-columns">
|
||||
<div class="settings-page-main">
|
||||
<details class="settings-collapse">
|
||||
<summary>
|
||||
<span>{{ 'settings.automationTitle' | translate }}</span>
|
||||
<small>{{ 'settings.automationSubtitle' | translate }}</small>
|
||||
</summary>
|
||||
<div class="settings-collapse__body">
|
||||
<div class="settings-automation-intro">
|
||||
<div>
|
||||
<strong>{{ 'settings.automationPlannerTitle' | translate }}</strong>
|
||||
<p>{{ 'settings.automationPlannerSubtitle' | translate }}</p>
|
||||
</div>
|
||||
<p-tag [value]="'settings.automationPlannerTag' | translate" severity="info"></p-tag>
|
||||
</div>
|
||||
|
||||
<div class="settings-scheduler-stack">
|
||||
<div class="scheduler-card">
|
||||
<div class="scheduler-card__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.exportScheduleTitle' | translate }}</strong>
|
||||
<small>{{ scheduleSummary(scheduleEditors.export) }}</small>
|
||||
</div>
|
||||
<p-tag [value]="scheduleEnabled(scheduleEditors.export) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.export)"></p-tag>
|
||||
</div>
|
||||
<div class="scheduler-card__hint">{{ 'settings.exportPlannerHint' | translate }}</div>
|
||||
<div class="scheduler-card__grid">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||
<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>
|
||||
<div class="time-picker">
|
||||
<input pInputText [(ngModel)]="scheduleEditors.export.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.export)" />
|
||||
<span>:</span>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.export.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.export)" />
|
||||
</div>
|
||||
</span>
|
||||
<span class="form-field" *ngIf="scheduleEditors.export.mode === 'weekly'">
|
||||
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||
<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>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.export.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 2 * * *" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scheduler-card">
|
||||
<div class="scheduler-card__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.binaryScheduleTitle' | translate }}</strong>
|
||||
<small>{{ scheduleSummary(scheduleEditors.binary) }}</small>
|
||||
</div>
|
||||
<p-tag [value]="scheduleEnabled(scheduleEditors.binary) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.binary)"></p-tag>
|
||||
</div>
|
||||
<div class="scheduler-card__hint">{{ 'settings.binaryPlannerHint' | translate }}</div>
|
||||
<div class="scheduler-card__grid">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||
<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>
|
||||
<div class="time-picker">
|
||||
<input pInputText [(ngModel)]="scheduleEditors.binary.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.binary)" />
|
||||
<span>:</span>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.binary.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.binary)" />
|
||||
</div>
|
||||
</span>
|
||||
<span class="form-field" *ngIf="scheduleEditors.binary.mode === 'weekly'">
|
||||
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||
<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>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.binary.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 3 * * 0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scheduler-card">
|
||||
<div class="scheduler-card__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.retentionTitle' | translate }}</strong>
|
||||
<small>{{ scheduleSummary(scheduleEditors.retention) }}</small>
|
||||
</div>
|
||||
<p-tag [value]="scheduleEnabled(scheduleEditors.retention) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.retention)"></p-tag>
|
||||
</div>
|
||||
<div class="scheduler-card__hint">{{ 'settings.retentionPlannerHint' | translate }}</div>
|
||||
<div class="scheduler-card__grid">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.backupRetentionDays' | translate }}</label>
|
||||
<input pInputText type="number" formControlName="backup_retention_days" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.logRetentionDays' | translate }}</label>
|
||||
<input pInputText type="number" formControlName="log_retention_days" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||
<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>
|
||||
<div class="time-picker">
|
||||
<input pInputText [(ngModel)]="scheduleEditors.retention.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.retention)" />
|
||||
<span>:</span>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.retention.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.retention)" />
|
||||
</div>
|
||||
</span>
|
||||
<span class="form-field" *ngIf="scheduleEditors.retention.mode === 'weekly'">
|
||||
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||
<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>
|
||||
<input pInputText [(ngModel)]="scheduleEditors.retention.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 4 * * *" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scheduler-card scheduler-card--subtle">
|
||||
<div class="scheduler-card__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.connectionTestsTitle' | translate }}</strong>
|
||||
<small>{{ connectionTestSummary() }}</small>
|
||||
</div>
|
||||
<p-tag [value]="form.controls.connection_test_interval_minutes.value > 0 ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="connectionTestSeverity()"></p-tag>
|
||||
</div>
|
||||
<div class="scheduler-card__hint">{{ 'settings.connectionTestsHint' | translate }}</div>
|
||||
<div class="scheduler-card__grid scheduler-card__grid--compact">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.connectionTestIntervalMinutes' | translate }}</label>
|
||||
<input pInputText type="number" min="0" formControlName="connection_test_interval_minutes" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-collapse" open>
|
||||
<summary>
|
||||
<span>{{ 'settings.interfaceTitle' | translate }}</span>
|
||||
<small>{{ 'settings.interfaceSubtitle' | translate }}</small>
|
||||
</summary>
|
||||
<div class="settings-collapse__body">
|
||||
<div class="settings-interface-intro">
|
||||
<div>
|
||||
<strong>{{ 'settings.interfacePreferencesTitle' | translate }}</strong>
|
||||
<p>{{ 'settings.interfacePreferencesHint' | translate }}</p>
|
||||
</div>
|
||||
<p-tag [value]="'settings.interfacePreferencesTag' | translate" severity="info"></p-tag>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<span class="form-field">
|
||||
<label>{{ 'topbar.languageSelector' | translate }}</label>
|
||||
<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-select [appendTo]="'body'" formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" (onChange)="previewFont()"></p-select>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-collapse">
|
||||
<summary>
|
||||
<span>{{ 'settings.notificationsTitle' | translate }}</span>
|
||||
<small>{{ 'settings.notificationsSubtitle' | translate }}</small>
|
||||
</summary>
|
||||
<div class="settings-collapse__body">
|
||||
<div class="settings-toggle-grid">
|
||||
<div class="settings-toggle">
|
||||
<div>
|
||||
<strong>{{ 'settings.smtpEnabled' | translate }}</strong>
|
||||
<small>{{ 'settings.smtpEnabledHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="choice-toggle" role="group" [attr.aria-label]="'settings.smtpEnabled' | translate">
|
||||
<button type="button" class="choice-toggle__btn" [class.is-active]="form.controls.smtp_notifications_enabled.value" (click)="setBooleanSetting('smtp_notifications_enabled', true)">{{ 'common.enabled' | translate }}</button>
|
||||
<button type="button" class="choice-toggle__btn" [class.is-active]="!form.controls.smtp_notifications_enabled.value" (click)="setBooleanSetting('smtp_notifications_enabled', false)">{{ 'common.disabled' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-toggle">
|
||||
<div>
|
||||
<strong>{{ 'settings.failuresOnly' | translate }}</strong>
|
||||
<small>{{ 'settings.failuresOnlyHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="choice-toggle" role="group" [attr.aria-label]="'settings.failuresOnly' | translate">
|
||||
<button type="button" class="choice-toggle__btn" [class.is-active]="form.controls.notify_failures_only.value" (click)="setBooleanSetting('notify_failures_only', true)">{{ 'common.enabled' | translate }}</button>
|
||||
<button type="button" class="choice-toggle__btn" [class.is-active]="!form.controls.notify_failures_only.value" (click)="setBooleanSetting('notify_failures_only', false)">{{ 'common.disabled' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.smtpHost' | translate }}</label>
|
||||
<input pInputText formControlName="smtp_host" placeholder="smtp.example.com" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.smtpPort' | translate }}</label>
|
||||
<input pInputText type="number" formControlName="smtp_port" placeholder="587" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.smtpLogin' | translate }}</label>
|
||||
<input pInputText formControlName="smtp_login" placeholder="alerts@example.com" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.smtpPassword' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="smtp_password" placeholder="••••••••" />
|
||||
</span>
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'settings.recipientEmail' | translate }}</label>
|
||||
<input pInputText formControlName="recipient_email" placeholder="netops@example.com" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.pushoverToken' | translate }}</label>
|
||||
<input pInputText formControlName="pushover_token" [placeholder]="'settings.pushoverTokenPlaceholder' | translate" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.pushoverUserKey' | translate }}</label>
|
||||
<input pInputText formControlName="pushover_userkey" [placeholder]="'settings.pushoverUserKeyPlaceholder' | translate" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="settings-page-side">
|
||||
<details class="settings-collapse settings-collapse--sticky" open>
|
||||
<summary>
|
||||
<span>{{ 'settings.sshDefaultsTitle' | translate }}</span>
|
||||
<small>{{ 'settings.sshDefaultsSubtitle' | translate }}</small>
|
||||
</summary>
|
||||
<div class="settings-collapse__body">
|
||||
<div class="settings-ssh-panel">
|
||||
<div class="settings-ssh-panel__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.globalSshPrivateKey' | translate }}</strong>
|
||||
<p>{{ 'settings.sshKeyHelper' | translate }}</p>
|
||||
</div>
|
||||
<p-tag *ngIf="hasStoredSshKey && !clearStoredSshKey" [value]="'settings.sshKeyStoredTag' | translate" severity="success"></p-tag>
|
||||
<p-tag *ngIf="clearStoredSshKey" [value]="'settings.sshKeyWillBeRemovedTag' | translate" severity="danger"></p-tag>
|
||||
</div>
|
||||
|
||||
<div class="settings-ssh-lock" *ngIf="hasStoredSshKey && !sshKeyVisible && !clearStoredSshKey">
|
||||
<p>{{ 'settings.sshRevealHint' | translate }}</p>
|
||||
<div class="form-field form-field--full">
|
||||
<label>{{ 'settings.revealSshPassword' | translate }}</label>
|
||||
<input pInputText type="password" [(ngModel)]="sshRevealPassword" [ngModelOptions]="{ standalone: true }" [placeholder]="'settings.revealSshPasswordPlaceholder' | translate" />
|
||||
</div>
|
||||
<div class="header-actions-row">
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-lock-open" [label]="'settings.revealSshKey' | translate" [loading]="unlockingSshKey" (click)="unlockSshKey()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field form-field--full">
|
||||
<label>{{ 'settings.globalSshPrivateKey' | translate }}</label>
|
||||
<textarea
|
||||
pTextarea
|
||||
formControlName="global_ssh_key"
|
||||
rows="14"
|
||||
[placeholder]="(hasStoredSshKey && !sshKeyVisible && !clearStoredSshKey) ? ('settings.globalSshPrivateKeyHiddenPlaceholder' | translate) : ('settings.globalSshPrivateKeyPlaceholder' | translate)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="header-actions-row settings-ssh-actions">
|
||||
<button *ngIf="hasStoredSshKey && sshKeyVisible && !clearStoredSshKey" pButton type="button" severity="secondary" icon="pi pi-eye-slash" [label]="'settings.hideSshKey' | translate" (click)="hideSshKey()"></button>
|
||||
<button *ngIf="hasStoredSshKey || form.controls.global_ssh_key.value" pButton type="button" severity="danger" icon="pi pi-trash" [label]="'settings.clearSshKey' | translate" (click)="clearSshKey()"></button>
|
||||
</div>
|
||||
|
||||
<small class="settings-ssh-note" *ngIf="clearStoredSshKey">{{ 'settings.sshKeyClearNotice' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="settings-ssh-panel">
|
||||
<div class="settings-ssh-panel__header">
|
||||
<div>
|
||||
<strong>{{ 'settings.switchosDefaultsTitle' | translate }}</strong>
|
||||
<p>{{ 'settings.switchosDefaultsHint' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.defaultSwitchosUsername' | translate }}</label>
|
||||
<input pInputText formControlName="default_switchos_username" placeholder="admin" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'settings.defaultSwitchosPassword' | translate }}</label>
|
||||
<input pInputText formControlName="default_switchos_password" placeholder="••••••••" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions settings-actions settings-actions--sticky">
|
||||
<button pButton type="submit" icon="pi pi-save" [label]="'settings.save' | translate" [disabled]="form.invalid || saving" [loading]="saving"></button>
|
||||
</div>
|
||||
</form>
|
||||
499
frontend/src/app/features/settings/settings-page.component.ts
Normal file
499
frontend/src/app/features/settings/settings-page.component.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
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 { SelectModule } from 'primeng/select';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TextareaModule } from 'primeng/textarea';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
import { Subject, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { AppFont, FontService } from '../../core/services/font.service';
|
||||
import { APP_LANGUAGE_OPTIONS, AppLanguage, LanguageService } from '../../core/services/language.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
|
||||
interface SchedulerJobStatus {
|
||||
key: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
cron?: string | null;
|
||||
description: string;
|
||||
description_params?: Record<string, string | number> | null;
|
||||
valid: boolean;
|
||||
next_runs: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface SchedulerStatusResponse {
|
||||
timezone: string;
|
||||
running: boolean;
|
||||
jobs: SchedulerJobStatus[];
|
||||
}
|
||||
|
||||
type ScheduleMode = 'disabled' | 'daily' | 'weekly' | 'custom';
|
||||
type BooleanSettingControl = 'smtp_notifications_enabled' | 'notify_failures_only';
|
||||
|
||||
interface ScheduleEditor {
|
||||
mode: ScheduleMode;
|
||||
hour: string;
|
||||
minute: string;
|
||||
weekday: string;
|
||||
cron: string;
|
||||
}
|
||||
|
||||
interface SettingsResponse {
|
||||
backup_retention_days: number;
|
||||
log_retention_days: number;
|
||||
export_cron: string;
|
||||
binary_cron: string;
|
||||
retention_cron: string;
|
||||
enable_auto_export: boolean;
|
||||
connection_test_interval_minutes: number;
|
||||
global_ssh_key: string | null;
|
||||
has_global_ssh_key: boolean;
|
||||
default_switchos_username: string | null;
|
||||
default_switchos_password: string | null;
|
||||
has_default_switchos_credentials: boolean;
|
||||
pushover_token: string | null;
|
||||
pushover_userkey: string | null;
|
||||
notify_failures_only: boolean;
|
||||
smtp_host: string | null;
|
||||
smtp_port: number;
|
||||
smtp_login: string | null;
|
||||
smtp_password: string | null;
|
||||
smtp_notifications_enabled: boolean;
|
||||
recipient_email: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, TranslateModule, ButtonModule, SelectModule, InputTextModule, TextareaModule, TagModule, PageHeaderComponent],
|
||||
templateUrl: './settings-page.component.html'
|
||||
})
|
||||
export class SettingsPageComponent implements OnInit, OnDestroy {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly ui = inject(UiService);
|
||||
private readonly language = inject(LanguageService);
|
||||
private readonly font = inject(FontService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
saving = false;
|
||||
testingEmail = false;
|
||||
testingPushover = false;
|
||||
unlockingSshKey = false;
|
||||
schedulerStatus?: SchedulerStatusResponse;
|
||||
hasStoredSshKey = false;
|
||||
sshKeyVisible = false;
|
||||
clearStoredSshKey = false;
|
||||
sshRevealPassword = '';
|
||||
|
||||
readonly scheduleEditors: Record<'export' | 'binary' | 'retention', ScheduleEditor> = {
|
||||
export: this.createEditor(),
|
||||
binary: this.createEditor(),
|
||||
retention: this.createEditor()
|
||||
};
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
backup_retention_days: [7, Validators.required],
|
||||
log_retention_days: [7, Validators.required],
|
||||
export_cron: '',
|
||||
binary_cron: '',
|
||||
retention_cron: '',
|
||||
enable_auto_export: false,
|
||||
connection_test_interval_minutes: [0, Validators.min(0)],
|
||||
global_ssh_key: '',
|
||||
default_switchos_username: '',
|
||||
default_switchos_password: '',
|
||||
pushover_token: '',
|
||||
pushover_userkey: '',
|
||||
notify_failures_only: true,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_login: '',
|
||||
smtp_password: '',
|
||||
smtp_notifications_enabled: false,
|
||||
recipient_email: '',
|
||||
preferred_language: 'pl' as AppLanguage,
|
||||
preferred_font: 'default' as AppFont
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const user = this.auth.user();
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
this.form.patchValue(
|
||||
{
|
||||
preferred_language: user.preferred_language || 'pl',
|
||||
preferred_font: user.preferred_font || 'default'
|
||||
},
|
||||
{ emitEvent: false }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get scheduleModeOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('settings.scheduleDisabled'), value: 'disabled' },
|
||||
{ label: this.ui.instant('settings.scheduleDaily'), value: 'daily' },
|
||||
{ label: this.ui.instant('settings.scheduleWeekly'), value: 'weekly' },
|
||||
{ label: this.ui.instant('settings.scheduleCustom'), value: 'custom' }
|
||||
];
|
||||
}
|
||||
|
||||
get weekdayOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('settings.weekdayMonday'), value: '1' },
|
||||
{ label: this.ui.instant('settings.weekdayTuesday'), value: '2' },
|
||||
{ label: this.ui.instant('settings.weekdayWednesday'), value: '3' },
|
||||
{ label: this.ui.instant('settings.weekdayThursday'), value: '4' },
|
||||
{ label: this.ui.instant('settings.weekdayFriday'), value: '5' },
|
||||
{ label: this.ui.instant('settings.weekdaySaturday'), value: '6' },
|
||||
{ label: this.ui.instant('settings.weekdaySunday'), value: '0' }
|
||||
];
|
||||
}
|
||||
|
||||
readonly languageOptions = APP_LANGUAGE_OPTIONS.map((option) => ({
|
||||
label: `${option.flag} ${option.label}`,
|
||||
value: option.code
|
||||
}));
|
||||
|
||||
get fontOptions() {
|
||||
return [
|
||||
{ label: this.ui.instant('settings.fontDefault'), value: 'default' },
|
||||
{ label: 'Adwaita Mono', value: 'adwaita_mono' },
|
||||
{ label: 'Hack', value: 'hack' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.form.controls.preferred_language.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((selectedLanguage) => {
|
||||
this.previewLanguage(selectedLanguage as AppLanguage | null);
|
||||
});
|
||||
this.reloadSettings();
|
||||
this.loadSchedulerStatus();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid || this.saving) {
|
||||
return;
|
||||
}
|
||||
this.syncEditorsToForm();
|
||||
this.saving = true;
|
||||
|
||||
const preferredLanguage = this.form.controls.preferred_language.value as AppLanguage;
|
||||
const preferredFont = this.form.controls.preferred_font.value as AppFont;
|
||||
|
||||
forkJoin({
|
||||
settings: this.api.http.put(`${this.api.baseUrl}/settings`, this.buildPayload()),
|
||||
preferences: this.auth.updatePreferences({ preferred_language: preferredLanguage, preferred_font: preferredFont })
|
||||
})
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.saving = false;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.font.set(preferredFont);
|
||||
this.ui.success('toast.settingsSaved');
|
||||
this.reloadSettings();
|
||||
this.loadSchedulerStatus();
|
||||
},
|
||||
error: () => {
|
||||
this.ui.error('toast.settingsSaveFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testEmail() {
|
||||
if (this.testingEmail) {
|
||||
return;
|
||||
}
|
||||
this.testingEmail = true;
|
||||
this.api.http
|
||||
.post(`${this.api.baseUrl}/settings/test-email`, {})
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.testingEmail = false;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: () => this.ui.success('toast.testEmailSent'),
|
||||
error: () => {
|
||||
this.ui.error('toast.testEmailFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testPushover() {
|
||||
if (this.testingPushover) {
|
||||
return;
|
||||
}
|
||||
this.testingPushover = true;
|
||||
this.api.http
|
||||
.post(`${this.api.baseUrl}/settings/test-pushover`, {})
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.testingPushover = false;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: () => this.ui.success('toast.testPushoverSent'),
|
||||
error: () => {
|
||||
this.ui.error('toast.testPushoverFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unlockSshKey() {
|
||||
if (this.unlockingSshKey) {
|
||||
return;
|
||||
}
|
||||
if (!this.sshRevealPassword.trim()) {
|
||||
this.ui.error('settings.sshRevealPasswordRequired');
|
||||
return;
|
||||
}
|
||||
this.unlockingSshKey = true;
|
||||
this.api.http
|
||||
.post<{ global_ssh_key: string | null }>(`${this.api.baseUrl}/settings/reveal-ssh-key`, { password: this.sshRevealPassword })
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.unlockingSshKey = false;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.form.controls.global_ssh_key.setValue(response.global_ssh_key || '');
|
||||
this.sshKeyVisible = true;
|
||||
this.clearStoredSshKey = false;
|
||||
this.ui.success('toast.sshKeyUnlocked');
|
||||
},
|
||||
error: () => {
|
||||
this.ui.error('settings.sshRevealPasswordInvalid');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hideSshKey() {
|
||||
if (!this.hasStoredSshKey) {
|
||||
return;
|
||||
}
|
||||
this.sshKeyVisible = false;
|
||||
this.form.controls.global_ssh_key.setValue('');
|
||||
this.sshRevealPassword = '';
|
||||
}
|
||||
|
||||
clearSshKey() {
|
||||
this.clearStoredSshKey = true;
|
||||
this.hasStoredSshKey = false;
|
||||
this.sshKeyVisible = true;
|
||||
this.form.controls.global_ssh_key.setValue('');
|
||||
}
|
||||
|
||||
schedulerJob(key: string): SchedulerJobStatus | undefined {
|
||||
return this.schedulerStatus?.jobs.find((item) => item.key === key);
|
||||
}
|
||||
|
||||
scheduleSummary(editor: ScheduleEditor): string {
|
||||
if (editor.mode === 'disabled') {
|
||||
return this.ui.instant('settings.scheduleDisabledHint');
|
||||
}
|
||||
if (editor.mode === 'daily') {
|
||||
return this.ui.instant('settings.scheduleDailySummary', { time: `${editor.hour}:${editor.minute}` });
|
||||
}
|
||||
if (editor.mode === 'weekly') {
|
||||
const weekday = this.weekdayOptions.find((item) => item.value === editor.weekday)?.label || '';
|
||||
return this.ui.instant('settings.scheduleWeeklySummary', { weekday, time: `${editor.hour}:${editor.minute}` });
|
||||
}
|
||||
return editor.cron || this.ui.instant('settings.scheduleCustomEmpty');
|
||||
}
|
||||
|
||||
connectionTestSummary(): string {
|
||||
const minutes = Number(this.form.controls.connection_test_interval_minutes.value || 0);
|
||||
return minutes > 0 ? this.ui.instant('settings.connectionTestsEverySummary', { minutes }) : this.ui.instant('settings.connectionTestsDisabledHint');
|
||||
}
|
||||
|
||||
scheduleEnabled(editor: ScheduleEditor): boolean {
|
||||
return editor.mode !== 'disabled';
|
||||
}
|
||||
|
||||
scheduleSeverity(editor: ScheduleEditor): 'success' | 'secondary' {
|
||||
return this.scheduleEnabled(editor) ? 'success' : 'secondary';
|
||||
}
|
||||
|
||||
connectionTestSeverity(): 'success' | 'secondary' {
|
||||
return Number(this.form.controls.connection_test_interval_minutes.value || 0) > 0 ? 'success' : 'secondary';
|
||||
}
|
||||
|
||||
setBooleanSetting(controlName: BooleanSettingControl, value: boolean) {
|
||||
this.form.controls[controlName].setValue(value);
|
||||
this.form.controls[controlName].markAsDirty();
|
||||
}
|
||||
|
||||
normalizeTime(editor: ScheduleEditor) {
|
||||
editor.hour = this.padTime(editor.hour, 23);
|
||||
editor.minute = this.padTime(editor.minute, 59);
|
||||
}
|
||||
|
||||
previewFont() {
|
||||
this.font.set(this.form.controls.preferred_font.value as AppFont);
|
||||
}
|
||||
|
||||
previewLanguage(selectedLanguage?: AppLanguage | null) {
|
||||
const nextLanguage = (selectedLanguage || this.form.controls.preferred_language.value || 'pl') as AppLanguage;
|
||||
this.form.controls.preferred_language.setValue(nextLanguage, { emitEvent: false });
|
||||
if (this.auth.user()) {
|
||||
this.language.setForAuthenticatedUser(nextLanguage);
|
||||
return;
|
||||
}
|
||||
this.language.set(nextLanguage);
|
||||
}
|
||||
|
||||
private createEditor(): ScheduleEditor {
|
||||
return { mode: 'disabled', hour: '02', minute: '00', weekday: '1', cron: '' };
|
||||
}
|
||||
|
||||
private reloadSettings() {
|
||||
this.api.http.get<SettingsResponse>(`${this.api.baseUrl}/settings`).subscribe((response) => {
|
||||
this.hasStoredSshKey = response.has_global_ssh_key;
|
||||
this.sshKeyVisible = !response.has_global_ssh_key;
|
||||
this.clearStoredSshKey = false;
|
||||
this.sshRevealPassword = '';
|
||||
const user = this.auth.user();
|
||||
this.form.patchValue({
|
||||
backup_retention_days: response.backup_retention_days,
|
||||
log_retention_days: response.log_retention_days,
|
||||
export_cron: response.export_cron || '',
|
||||
binary_cron: response.binary_cron || '',
|
||||
retention_cron: response.retention_cron || '',
|
||||
enable_auto_export: response.enable_auto_export,
|
||||
connection_test_interval_minutes: Number(response.connection_test_interval_minutes || 0),
|
||||
global_ssh_key: '',
|
||||
default_switchos_username: response.default_switchos_username || '',
|
||||
default_switchos_password: response.default_switchos_password || '',
|
||||
pushover_token: response.pushover_token || '',
|
||||
pushover_userkey: response.pushover_userkey || '',
|
||||
notify_failures_only: response.notify_failures_only,
|
||||
smtp_host: response.smtp_host || '',
|
||||
smtp_port: Number(response.smtp_port || 587),
|
||||
smtp_login: response.smtp_login || '',
|
||||
smtp_password: response.smtp_password || '',
|
||||
smtp_notifications_enabled: response.smtp_notifications_enabled,
|
||||
recipient_email: response.recipient_email || '',
|
||||
preferred_language: (user?.preferred_language || this.language.current() || 'pl') as AppLanguage,
|
||||
preferred_font: (user?.preferred_font || 'default') as AppFont
|
||||
}, { emitEvent: false });
|
||||
this.hydrateEditors();
|
||||
});
|
||||
}
|
||||
|
||||
private buildPayload() {
|
||||
const raw = this.form.getRawValue();
|
||||
const normalizedKey = raw.global_ssh_key.trim();
|
||||
return {
|
||||
backup_retention_days: Number(raw.backup_retention_days || 0),
|
||||
log_retention_days: Number(raw.log_retention_days || 0),
|
||||
export_cron: (raw.export_cron || '').trim(),
|
||||
binary_cron: (raw.binary_cron || '').trim(),
|
||||
retention_cron: (raw.retention_cron || '').trim(),
|
||||
enable_auto_export: Boolean(raw.enable_auto_export),
|
||||
connection_test_interval_minutes: Number(raw.connection_test_interval_minutes || 0),
|
||||
global_ssh_key: normalizedKey || null,
|
||||
default_switchos_username: this.normalizeOptionalText(raw.default_switchos_username),
|
||||
default_switchos_password: this.normalizeOptionalText(raw.default_switchos_password),
|
||||
pushover_token: this.normalizeOptionalText(raw.pushover_token),
|
||||
pushover_userkey: this.normalizeOptionalText(raw.pushover_userkey),
|
||||
notify_failures_only: Boolean(raw.notify_failures_only),
|
||||
smtp_host: this.normalizeOptionalText(raw.smtp_host),
|
||||
smtp_port: Number(raw.smtp_port || 587),
|
||||
smtp_login: this.normalizeOptionalText(raw.smtp_login),
|
||||
smtp_password: this.normalizeOptionalText(raw.smtp_password),
|
||||
smtp_notifications_enabled: Boolean(raw.smtp_notifications_enabled),
|
||||
recipient_email: this.normalizeOptionalText(raw.recipient_email),
|
||||
clear_global_ssh_key: this.clearStoredSshKey
|
||||
};
|
||||
}
|
||||
|
||||
private hydrateEditors() {
|
||||
const exportEditor = this.editorFromCron(this.form.controls.export_cron.value);
|
||||
if (!this.form.controls.enable_auto_export.value) {
|
||||
exportEditor.mode = 'disabled';
|
||||
}
|
||||
this.scheduleEditors.export = exportEditor;
|
||||
this.scheduleEditors.binary = this.editorFromCron(this.form.controls.binary_cron.value);
|
||||
this.scheduleEditors.retention = this.editorFromCron(this.form.controls.retention_cron.value);
|
||||
}
|
||||
|
||||
private syncEditorsToForm() {
|
||||
this.normalizeTime(this.scheduleEditors.export);
|
||||
this.normalizeTime(this.scheduleEditors.binary);
|
||||
this.normalizeTime(this.scheduleEditors.retention);
|
||||
this.form.patchValue({
|
||||
export_cron: this.editorToCron(this.scheduleEditors.export),
|
||||
binary_cron: this.editorToCron(this.scheduleEditors.binary),
|
||||
retention_cron: this.editorToCron(this.scheduleEditors.retention),
|
||||
enable_auto_export: this.scheduleEditors.export.mode !== 'disabled'
|
||||
});
|
||||
}
|
||||
|
||||
private editorFromCron(cron: string): ScheduleEditor {
|
||||
const normalized = (cron || '').trim();
|
||||
if (!normalized) {
|
||||
return this.createEditor();
|
||||
}
|
||||
|
||||
const parts = normalized.split(/\s+/);
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||
if (day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return { mode: 'daily', hour: this.padTime(hour, 23), minute: this.padTime(minute, 59), weekday: '1', cron: normalized };
|
||||
}
|
||||
if (day === '*' && month === '*' && /^[0-7]$/.test(dayOfWeek)) {
|
||||
return { mode: 'weekly', hour: this.padTime(hour, 23), minute: this.padTime(minute, 59), weekday: dayOfWeek, cron: normalized };
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'custom', hour: '02', minute: '00', weekday: '1', cron: normalized };
|
||||
}
|
||||
|
||||
private editorToCron(editor: ScheduleEditor): string {
|
||||
if (editor.mode === 'disabled') {
|
||||
return '';
|
||||
}
|
||||
if (editor.mode === 'daily') {
|
||||
return `${editor.minute} ${editor.hour} * * *`;
|
||||
}
|
||||
if (editor.mode === 'weekly') {
|
||||
return `${editor.minute} ${editor.hour} * * ${editor.weekday}`;
|
||||
}
|
||||
return editor.cron.trim();
|
||||
}
|
||||
|
||||
private padTime(value: string, max: number): string {
|
||||
const numeric = Math.min(max, Math.max(0, Number(value || 0)));
|
||||
return String(Math.floor(numeric)).padStart(2, '0');
|
||||
}
|
||||
|
||||
private normalizeOptionalText(value: string | null | undefined): string | null {
|
||||
const normalized = (value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
private loadSchedulerStatus() {
|
||||
this.api.http.get<SchedulerStatusResponse>(`${this.api.baseUrl}/settings/scheduler-status`).subscribe((status) => {
|
||||
this.schedulerStatus = status;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<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="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
|
||||
</div>
|
||||
</app-page-header>
|
||||
|
||||
<app-section-card [title]="'switchosBeta.warningTitle' | translate" [subtitle]="'switchosBeta.warningSubtitle' | translate">
|
||||
<div class="beta-banner">
|
||||
<div>
|
||||
<strong>{{ 'switchosBeta.warningHeadline' | translate }}</strong>
|
||||
<p>{{ 'switchosBeta.warningBody' | translate }}</p>
|
||||
</div>
|
||||
<p-tag severity="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
|
||||
</div>
|
||||
</app-section-card>
|
||||
|
||||
<div class="metric-grid-2 swos-beta-grid">
|
||||
<app-section-card [title]="'switchosBeta.formTitle' | translate" [subtitle]="'switchosBeta.formSubtitle' | translate">
|
||||
<form [formGroup]="form" class="form-grid-2">
|
||||
<span class="form-field">
|
||||
<label>{{ 'switchosBeta.label' | translate }}</label>
|
||||
<input pInputText formControlName="label" [placeholder]="'switchosBeta.labelPlaceholder' | translate" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'switchosBeta.host' | translate }}</label>
|
||||
<input pInputText formControlName="host" [placeholder]="'switchosBeta.hostPlaceholder' | translate" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'switchosBeta.port' | translate }}</label>
|
||||
<input pInputText type="number" formControlName="port" placeholder="80" />
|
||||
</span>
|
||||
<span class="form-field">
|
||||
<label>{{ 'switchosBeta.username' | translate }}</label>
|
||||
<input pInputText formControlName="username" placeholder="admin" />
|
||||
</span>
|
||||
<span class="form-field form-field--full">
|
||||
<label>{{ 'switchosBeta.password' | translate }}</label>
|
||||
<input pInputText type="password" formControlName="password" [placeholder]="'switchosBeta.passwordPlaceholder' | translate" />
|
||||
</span>
|
||||
<div class="dialog-actions swos-beta-actions">
|
||||
<button pButton type="button" severity="secondary" icon="pi pi-search" [label]="'switchosBeta.probeButton' | translate" [loading]="probing" (click)="probe()"></button>
|
||||
<button pButton type="button" icon="pi pi-download" [label]="'switchosBeta.downloadButton' | translate" [loading]="downloading" (click)="download()"></button>
|
||||
</div>
|
||||
</form>
|
||||
</app-section-card>
|
||||
|
||||
<app-section-card [title]="'switchosBeta.resultTitle' | translate" [subtitle]="'switchosBeta.resultSubtitle' | translate">
|
||||
<div class="empty-state" *ngIf="!probeResult && !lastError">{{ 'switchosBeta.resultEmpty' | translate }}</div>
|
||||
|
||||
<div class="swos-beta-result" *ngIf="probeResult">
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.baseUrl' | translate }}</span>
|
||||
<strong>{{ probeResult.base_url }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.httpStatus' | translate }}</span>
|
||||
<strong>{{ probeResult.status_code }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.authMode' | translate }}</span>
|
||||
<strong>{{ probeResult.auth_mode }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.pageTitle' | translate }}</span>
|
||||
<strong>{{ probeResult.page_title || '—' }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.serverHeader' | translate }}</span>
|
||||
<strong>{{ probeResult.server || '—' }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-result__item">
|
||||
<span>{{ 'switchosBeta.backupEndpoint' | translate }}</span>
|
||||
<strong>{{ (probeResult.backup_endpoint_ok ? 'switchosBeta.available' : 'switchosBeta.unavailable') | translate }}</strong>
|
||||
</div>
|
||||
<div class="swos-beta-note" *ngIf="probeResult.note">{{ probeResult.note }}</div>
|
||||
</div>
|
||||
|
||||
<div class="beta-error" *ngIf="lastError">{{ lastError }}</div>
|
||||
</app-section-card>
|
||||
</div>
|
||||
131
frontend/src/app/features/swos-beta/swos-beta-page.component.ts
Normal file
131
frontend/src/app/features/swos-beta/swos-beta-page.component.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { finalize } from 'rxjs';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||
|
||||
interface SwosBetaProbeResult {
|
||||
success: boolean;
|
||||
base_url: string;
|
||||
status_code: number;
|
||||
auth_mode: string;
|
||||
page_title?: string | null;
|
||||
content_type?: string | null;
|
||||
server?: string | null;
|
||||
save_backup_visible: boolean;
|
||||
backup_endpoint_ok: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, ButtonModule, InputTextModule, TagModule, PageHeaderComponent, SectionCardComponent],
|
||||
templateUrl: './swos-beta-page.component.html'
|
||||
})
|
||||
export class SwosBetaPageComponent {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly ui = inject(UiService);
|
||||
|
||||
probing = false;
|
||||
downloading = false;
|
||||
lastError = '';
|
||||
probeResult?: SwosBetaProbeResult;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
label: '',
|
||||
host: ['', Validators.required],
|
||||
port: [80, [Validators.required, Validators.min(1), Validators.max(65535)]],
|
||||
username: ['admin', Validators.required],
|
||||
password: ''
|
||||
});
|
||||
|
||||
get formValue() {
|
||||
return this.form.getRawValue();
|
||||
}
|
||||
|
||||
probe() {
|
||||
if (this.form.invalid || this.probing) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastError = '';
|
||||
this.probing = true;
|
||||
this.api.http
|
||||
.post<SwosBetaProbeResult>(`${this.api.baseUrl}/swos-beta/probe`, this.formValue)
|
||||
.pipe(finalize(() => (this.probing = false)))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.probeResult = result;
|
||||
this.ui.success('toast.swosBetaProbeOk');
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.probeResult = undefined;
|
||||
this.lastError = this.extractError(error);
|
||||
this.ui.error('toast.swosBetaProbeFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
download() {
|
||||
if (this.form.invalid || this.downloading) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastError = '';
|
||||
this.downloading = true;
|
||||
this.api.http
|
||||
.post(`${this.api.baseUrl}/swos-beta/download`, this.formValue, {
|
||||
observe: 'response',
|
||||
responseType: 'blob'
|
||||
})
|
||||
.pipe(finalize(() => (this.downloading = false)))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.saveBlob(response);
|
||||
this.ui.success('toast.swosBetaDownloadOk');
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.lastError = this.extractError(error);
|
||||
this.ui.error('toast.swosBetaDownloadFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveBlob(response: HttpResponse<Blob>) {
|
||||
const blob = response.body || new Blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = this.extractFilename(response.headers.get('content-disposition'));
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private extractFilename(disposition: string | null): string {
|
||||
const match = disposition?.match(/filename="?([^\"]+)"?/i);
|
||||
return match?.[1] || 'switchos-backup.swb';
|
||||
}
|
||||
|
||||
private extractError(error: HttpErrorResponse): string {
|
||||
const detail = error.error?.detail;
|
||||
if (typeof detail === 'string' && detail.trim()) {
|
||||
return detail.trim();
|
||||
}
|
||||
if (typeof error.error === 'string' && error.error.trim()) {
|
||||
return error.error.trim();
|
||||
}
|
||||
return this.ui.instant('switchosBeta.genericError');
|
||||
}
|
||||
}
|
||||
22
frontend/src/app/shared/auth/auth-toolbar.component.html
Normal file
22
frontend/src/app/shared/auth/auth-toolbar.component.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="auth-toolbar">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
class="auth-toolbar__btn"
|
||||
[icon]="theme.mode() === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
(click)="theme.toggle()"
|
||||
></button>
|
||||
|
||||
<label class="auth-toolbar__select-wrap">
|
||||
<select
|
||||
class="auth-toolbar__select"
|
||||
[ngModel]="language.current()"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="changeLanguage($event)"
|
||||
>
|
||||
<option *ngFor="let option of languageOptions; trackBy: trackByLanguageCode" [ngValue]="option.code">
|
||||
{{ option.flag }} {{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
28
frontend/src/app/shared/auth/auth-toolbar.component.ts
Normal file
28
frontend/src/app/shared/auth/auth-toolbar.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
|
||||
import { AppLanguage, AppLanguageOption, LanguageService } from '../../core/services/language.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-toolbar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ButtonModule],
|
||||
templateUrl: './auth-toolbar.component.html'
|
||||
})
|
||||
export class AuthToolbarComponent {
|
||||
readonly theme = inject(ThemeService);
|
||||
readonly language = inject(LanguageService);
|
||||
|
||||
readonly languageOptions = this.language.options;
|
||||
|
||||
trackByLanguageCode(_: number, option: AppLanguageOption) {
|
||||
return option.code;
|
||||
}
|
||||
|
||||
changeLanguage(lang: AppLanguage | string) {
|
||||
this.language.set(lang as AppLanguage);
|
||||
}
|
||||
}
|
||||
27
frontend/src/app/shared/layout/app-sidebar.component.html
Normal file
27
frontend/src/app/shared/layout/app-sidebar.component.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand__logo">
|
||||
<img src="https://mikrotik.com/logo/library/logo/SVG/MT_Symbol_Black.svg" alt="MikroTik" />
|
||||
</div>
|
||||
<div class="sidebar-brand__text" *ngIf="!collapsed">
|
||||
<h2>{{ 'sidebar.title' | translate }}</h2>
|
||||
<p>{{ 'sidebar.subtitle' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" *ngIf="!collapsed">
|
||||
<div class="sidebar-section__label">{{ 'app.menu' | translate }}</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a
|
||||
*ngFor="let item of items"
|
||||
[routerLink]="item.link"
|
||||
routerLinkActive="is-active"
|
||||
[routerLinkActiveOptions]="{ exact: item.exact ?? true }"
|
||||
class="sidebar-nav__item"
|
||||
(click)="navigate.emit()"
|
||||
>
|
||||
<i [class]="item.icon"></i>
|
||||
<span *ngIf="!collapsed">{{ item.label | translate }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
16
frontend/src/app/shared/layout/app-sidebar.component.ts
Normal file
16
frontend/src/app/shared/layout/app-sidebar.component.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
|
||||
templateUrl: './app-sidebar.component.html'
|
||||
})
|
||||
export class AppSidebarComponent {
|
||||
@Input() collapsed = false;
|
||||
@Input() items: Array<{ label: string; link: string; icon: string; exact?: boolean }> = [];
|
||||
@Output() navigate = new EventEmitter<void>();
|
||||
}
|
||||
42
frontend/src/app/shared/layout/app-topbar.component.html
Normal file
42
frontend/src/app/shared/layout/app-topbar.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<header class="topbar">
|
||||
<div class="topbar__left">
|
||||
<button pButton type="button" icon="pi pi-bars" styleClass="topbar__icon-btn p-button-text" (click)="menuClick.emit()"></button>
|
||||
<div>
|
||||
<div class="topbar__caption">{{ 'topbar.caption' | translate }}</div>
|
||||
<div class="topbar__headline">{{ pageTitle | translate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar__right">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
styleClass="topbar__icon-btn p-button-text"
|
||||
[icon]="themeMode === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
(click)="themeClick.emit()"
|
||||
></button>
|
||||
|
||||
<label class="topbar__lang-picker" [attr.aria-label]="'topbar.languageSelector' | translate">
|
||||
<select
|
||||
class="topbar__lang-select"
|
||||
[ngModel]="lang"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onLanguageSelect($event)"
|
||||
>
|
||||
<option *ngFor="let option of languages; trackBy: trackByLanguageCode" [ngValue]="option.code">
|
||||
{{ option.flag }} {{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="topbar__user">
|
||||
<p-avatar [label]="userInitials" shape="circle" styleClass="topbar__avatar"></p-avatar>
|
||||
<div class="topbar__user-meta">
|
||||
<strong>{{ username }}</strong>
|
||||
<small>{{ 'topbar.role' | translate }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button pButton type="button" styleClass="p-button-outlined topbar__logout-btn" icon="pi pi-sign-out" [label]="'nav.logout' | translate" (click)="logoutClick.emit()"></button>
|
||||
</div>
|
||||
</header>
|
||||
42
frontend/src/app/shared/layout/app-topbar.component.ts
Normal file
42
frontend/src/app/shared/layout/app-topbar.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AvatarModule } from 'primeng/avatar';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
|
||||
export interface TopbarLanguageOption {
|
||||
code: string;
|
||||
label: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-topbar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, AvatarModule],
|
||||
templateUrl: './app-topbar.component.html'
|
||||
})
|
||||
export class AppTopbarComponent {
|
||||
@Input() pageTitle = 'dashboard.title';
|
||||
@Input() username = 'admin';
|
||||
@Input() lang = 'pl';
|
||||
@Input() themeMode: 'light' | 'dark' = 'light';
|
||||
@Input() languages: TopbarLanguageOption[] = [];
|
||||
@Output() menuClick = new EventEmitter<void>();
|
||||
@Output() themeClick = new EventEmitter<void>();
|
||||
@Output() languageChange = new EventEmitter<string>();
|
||||
@Output() logoutClick = new EventEmitter<void>();
|
||||
|
||||
get userInitials(): string {
|
||||
return this.username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
trackByLanguageCode(_: number, option: TopbarLanguageOption) {
|
||||
return option.code;
|
||||
}
|
||||
|
||||
onLanguageSelect(value: string) {
|
||||
this.languageChange.emit(value);
|
||||
}
|
||||
}
|
||||
10
frontend/src/app/shared/ui/page-header.component.html
Normal file
10
frontend/src/app/shared/ui/page-header.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-header__eyebrow" *ngIf="eyebrow">{{ eyebrow }}</div>
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p class="page-header__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<ng-content select="[header-actions]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
14
frontend/src/app/shared/ui/page-header.component.ts
Normal file
14
frontend/src/app/shared/ui/page-header.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './page-header.component.html'
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
@Input({ required: true }) title = '';
|
||||
@Input() subtitle = '';
|
||||
@Input() eyebrow = '';
|
||||
}
|
||||
12
frontend/src/app/shared/ui/section-card.component.html
Normal file
12
frontend/src/app/shared/ui/section-card.component.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<p-card styleClass="section-card">
|
||||
<div class="section-card__header" *ngIf="title || subtitle">
|
||||
<div>
|
||||
<h3 class="section-card__title" *ngIf="title">{{ title }}</h3>
|
||||
<p class="section-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="section-card__actions">
|
||||
<ng-content select="[card-actions]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content></ng-content>
|
||||
</p-card>
|
||||
14
frontend/src/app/shared/ui/section-card.component.ts
Normal file
14
frontend/src/app/shared/ui/section-card.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CardModule } from 'primeng/card';
|
||||
|
||||
@Component({
|
||||
selector: 'app-section-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CardModule],
|
||||
templateUrl: './section-card.component.html'
|
||||
})
|
||||
export class SectionCardComponent {
|
||||
@Input() title = '';
|
||||
@Input() subtitle = '';
|
||||
}
|
||||
13
frontend/src/app/shared/ui/stat-card.component.html
Normal file
13
frontend/src/app/shared/ui/stat-card.component.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<p-card styleClass="stat-card">
|
||||
<div class="stat-card__row">
|
||||
<div>
|
||||
<div class="stat-card__label">{{ label }}</div>
|
||||
<div class="stat-card__value">{{ value }}</div>
|
||||
<div class="stat-card__hint" *ngIf="hint">{{ hint }}</div>
|
||||
</div>
|
||||
<div class="stat-card__icon" [ngClass]="iconClass">
|
||||
<i [class]="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p-tag *ngIf="tag" [value]="tag" [severity]="tagSeverity"></p-tag>
|
||||
</p-card>
|
||||
24
frontend/src/app/shared/ui/stat-card.component.ts
Normal file
24
frontend/src/app/shared/ui/stat-card.component.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stat-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CardModule, TagModule],
|
||||
templateUrl: './stat-card.component.html'
|
||||
})
|
||||
export class StatCardComponent {
|
||||
@Input({ required: true }) label = '';
|
||||
@Input({ required: true }) value: string | number = '';
|
||||
@Input() hint = '';
|
||||
@Input() tag = '';
|
||||
@Input() icon = 'pi pi-chart-bar';
|
||||
@Input() iconClass = '';
|
||||
@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;
|
||||
}
|
||||
}
|
||||
541
frontend/src/assets/i18n/en.json
Normal file
541
frontend/src/assets/i18n/en.json
Normal file
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"app": {
|
||||
"menu": "Menu"
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "Mikrotik Backup System",
|
||||
"subtitle": "Device backup platform"
|
||||
},
|
||||
"topbar": {
|
||||
"caption": "mikrotik / control center",
|
||||
"role": "administrator",
|
||||
"languageSelector": "Language selector"
|
||||
},
|
||||
"common": {
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"download": "Download",
|
||||
"email": "Send e-mail",
|
||||
"preview": "Preview",
|
||||
"restore": "Restore",
|
||||
"actions": "Actions",
|
||||
"open": "Open",
|
||||
"edit": "Edit",
|
||||
"diff": "Diff",
|
||||
"ok": "OK",
|
||||
"idle": "Idle",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"routers": "Devices",
|
||||
"files": "Repository",
|
||||
"settings": "Settings",
|
||||
"logs": "Logs",
|
||||
"logout": "Logout",
|
||||
"theme": "Theme",
|
||||
"changePassword": "Change password",
|
||||
"diffConfigs": "Config diff",
|
||||
"switchosBeta": "SwitchOS beta"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"confirmPassword": "Confirm password",
|
||||
"changePassword": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"backToLogin": "Back to login",
|
||||
"backToApp": "Back to app",
|
||||
"loginSubtitle": "Sign in to continue.",
|
||||
"loginFailed": "Login failed",
|
||||
"accountCreated": "Account created",
|
||||
"registrationFailed": "Registration failed",
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"changePasswordFailed": "Password change failed",
|
||||
"securityEyebrow": "account / security",
|
||||
"changePasswordSubtitle": "Update the administrator password without extra visual clutter.",
|
||||
"changePasswordCardSubtitle": "Enter the current password and set new credentials.",
|
||||
"passwordPanelSubtitle": "Quick check for password strength and field consistency before saving.",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordWeak": "Weak",
|
||||
"passwordMedium": "Medium",
|
||||
"passwordStrong": "Strong",
|
||||
"ruleLength": "At least 8 characters",
|
||||
"ruleDigit": "At least one digit",
|
||||
"ruleMatch": "Both fields match",
|
||||
"passwordsMatchHint": "The new password and confirmation match."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"eyebrow": "home / dashboard",
|
||||
"subtitle": "Overview of backups, exports and operational activity in one place.",
|
||||
"exportAll": "Export all",
|
||||
"binaryAll": "Binary backup",
|
||||
"managedRouters": "Devices",
|
||||
"managedRoutersHint": "All managed devices",
|
||||
"inventoryTag": "Fleet",
|
||||
"exportsCard": "Exports",
|
||||
"exportsHint": "Readable configuration snapshots",
|
||||
"textTag": "Text",
|
||||
"binaryCard": "Binary backups",
|
||||
"binaryHint": "Recovery points",
|
||||
"binaryTag": "Binary",
|
||||
"allFilesCard": "All files",
|
||||
"allFilesHint": "Artifacts in the repository",
|
||||
"archiveTag": "Archive",
|
||||
"storageTitle": "Storage utilization",
|
||||
"storageSubtitle": "Current overview of repository usage and free space.",
|
||||
"folderUsage": "Folder usage",
|
||||
"diskUsage": "Disk usage",
|
||||
"totalDisk": "Total disk",
|
||||
"freeSpace": "Free space",
|
||||
"activityTitle": "Recent activity",
|
||||
"activitySubtitle": "Latest operational events from the backend.",
|
||||
"noActivity": "No recent events to display.",
|
||||
"avgBackupsPerRouter": "Avg backups / router",
|
||||
"activitySuccess": "Completed task",
|
||||
"activityFailure": "Needs attention",
|
||||
"activityMaintenance": "Maintenance",
|
||||
"activityDelivery": "Delivery",
|
||||
"operationsTitle": "Operations center",
|
||||
"operationsSubtitle": "Primary actions and live repository indicators in one place.",
|
||||
"latestSnapshot": "Latest snapshot",
|
||||
"coverageLabel": "Fleet coverage",
|
||||
"coverageHint": "Routers with at least one backup",
|
||||
"weeklyActivityLabel": "7-day activity",
|
||||
"weeklyActivityHint": "New backups created this week",
|
||||
"busiestRouterLabel": "Busiest router",
|
||||
"routerSnapshotsHint": "{{count}} snapshots in the repository",
|
||||
"exportShareLabel": "Export share",
|
||||
"activityTodayLabel": "Events today",
|
||||
"noneLabel": "None",
|
||||
"activityTodayHint": "Entries created today",
|
||||
"usedSpace": "Used space",
|
||||
"storageViewCapacity": "Capacity",
|
||||
"storageViewCapacityHint": "Disk, repository usage and free space shown on one scale.",
|
||||
"storageViewMix": "Backup types",
|
||||
"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 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",
|
||||
"storageSnapshotHint": "Quick snapshot of the most important storage and backup indicators."
|
||||
},
|
||||
"routers": {
|
||||
"title": "Devices",
|
||||
"detailTitle": "Device details",
|
||||
"add": "Add device",
|
||||
"eyebrow": "device inventory",
|
||||
"subtitle": "Manage RouterOS and SwitchOS devices plus their backups.",
|
||||
"registeredDevices": "Registered devices",
|
||||
"fleetTag": "Fleet",
|
||||
"sshPassword": "Password",
|
||||
"passwordHint": "Password-based access",
|
||||
"credsTag": "Creds",
|
||||
"sshKey": "SSH key",
|
||||
"keyHint": "Key-based access",
|
||||
"securityTag": "Security",
|
||||
"defaultPort": "Port 22",
|
||||
"defaultPortHint": "Standard SSH endpoints",
|
||||
"portTag": "Port",
|
||||
"listTitle": "Device list",
|
||||
"listSubtitle": "Unified view for RouterOS and SwitchOS devices.",
|
||||
"name": "Name",
|
||||
"endpoint": "Endpoint",
|
||||
"access": "Access",
|
||||
"routerOsTarget": "RouterOS target",
|
||||
"passwordMode": "Password",
|
||||
"noPassword": "No password",
|
||||
"keyMode": "Key",
|
||||
"globalKeyMode": "Global key",
|
||||
"noKey": "No key",
|
||||
"createDialogTitle": "Add device",
|
||||
"editDialogTitle": "Edit device",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"sshUser": "Username",
|
||||
"sshPrivateKey": "SSH private key",
|
||||
"optionalPassword": "Optional password",
|
||||
"optionalPrivateKey": "Optional private key",
|
||||
"saveRouter": "Save device",
|
||||
"profileEyebrow": "router profile",
|
||||
"detailSubtitle": "Device operations and backup history",
|
||||
"exportOne": "Export",
|
||||
"binaryOne": "Binary",
|
||||
"testConnection": "Test connection",
|
||||
"deleteRouter": "Delete router",
|
||||
"exportsLabel": "Exports",
|
||||
"exportsLabelHint": "Text snapshots",
|
||||
"binaryLabel": "Binary backups",
|
||||
"binaryLabelHint": "Recovery images",
|
||||
"connectionLabel": "Connection",
|
||||
"connectionLabelHint": "Status from the latest automatic or manual connection test",
|
||||
"probeTag": "Probe",
|
||||
"accessTag": "Access",
|
||||
"sshUserHint": "Effective device login",
|
||||
"deviceStatusTitle": "Device status",
|
||||
"deviceStatusSubtitle": "Stored metadata from the latest automatic or manual connection test.",
|
||||
"hostname": "Hostname",
|
||||
"model": "Model",
|
||||
"version": "Version",
|
||||
"uptime": "Uptime",
|
||||
"noConnection": "No saved connection test yet. Run a manual test or enable automatic checks in settings.",
|
||||
"previewTitle": "Export preview",
|
||||
"previewSubtitle": "Most recently opened export file.",
|
||||
"noPreview": "Select an export file to preview its contents.",
|
||||
"diffTitle": "Latest diff",
|
||||
"diffSubtitle": "Difference against the newest export.",
|
||||
"exportsTableTitle": "Exports",
|
||||
"exportsTableSubtitle": "Readable RouterOS snapshots.",
|
||||
"binaryTableTitle": "Binary backups",
|
||||
"binaryTableSubtitle": "Binary files and SwitchOS backups.",
|
||||
"summaryKeyAccess": "with key-based access",
|
||||
"summaryPasswordAccess": "with password access",
|
||||
"connectionStateTitle": "Connection state",
|
||||
"lastTestAt": "Last test",
|
||||
"lastError": "Last error",
|
||||
"deviceStatusManualHint": "Automatic checks use the interval from settings. The manual test button is still available.",
|
||||
"previewModalHint": "The last opened export is available in a modal.",
|
||||
"openPreviewModal": "Open preview",
|
||||
"diffModalHint": "The last loaded diff is available in a modal.",
|
||||
"openDiffModal": "Open diff",
|
||||
"noDiff": "Choose an export and run a diff to see the latest comparison.",
|
||||
"routeros": "RouterOS",
|
||||
"switchos": "SwitchOS",
|
||||
"deviceType": "Device type",
|
||||
"defaultCredentials": "Default credentials",
|
||||
"localCredentials": "Local credentials",
|
||||
"noCredentials": "No credentials",
|
||||
"switchUserPlaceholder": "Empty = use settings default",
|
||||
"switchPasswordPlaceholder": "Empty = use settings default",
|
||||
"switchDefaultsHint": "For SwitchOS you can leave username and password empty to use the defaults from settings.",
|
||||
"downloadSwitchBackup": "Download backup",
|
||||
"httpStatus": "HTTP status",
|
||||
"serverHeader": "Server header",
|
||||
"authMode": "Auth mode",
|
||||
"backupEndpoint": "Backup endpoint",
|
||||
"backupAvailable": "Available",
|
||||
"backupUnavailable": "Unavailable",
|
||||
"connectionSectionTitle": "Connection profile",
|
||||
"connectionSectionHint": "Basic device identity and endpoint used to reach it.",
|
||||
"credentialsSectionTitle": "Access and credentials",
|
||||
"routerDialogSubtitle": "Set the device endpoint, SSH access data and your preferred login method.",
|
||||
"switchDialogSubtitle": "Set the SwitchOS endpoint and optional local or shared credentials from settings."
|
||||
},
|
||||
"files": {
|
||||
"title": "Repository",
|
||||
"eyebrow": "artifact repository",
|
||||
"subtitle": "Search, compare and deliver backups from one clear view.",
|
||||
"downloadZip": "Download ZIP",
|
||||
"visibleFiles": "Visible files",
|
||||
"visibleFilesHint": "Current filter result",
|
||||
"liveTag": "Live",
|
||||
"selected": "Selected",
|
||||
"selectedHint": "Ready for bulk actions",
|
||||
"batchTag": "Batch",
|
||||
"exportsCard": "Exports",
|
||||
"exportsHint": "Configuration snapshots",
|
||||
"binaryCard": "Binary backups",
|
||||
"binaryHint": "Recovery images",
|
||||
"filtersTitle": "Filters",
|
||||
"filtersSubtitle": "Refine the list by router, type or keyword.",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Search by file or router",
|
||||
"typeLabel": "Type",
|
||||
"routerLabel": "Device",
|
||||
"dateLabel": "Date",
|
||||
"datePlaceholder": "Pick a date",
|
||||
"sortLabel": "Sort by",
|
||||
"orderLabel": "Order",
|
||||
"allTypes": "All types",
|
||||
"allRouters": "All devices",
|
||||
"sortNewest": "Newest",
|
||||
"sortName": "Name",
|
||||
"sortRouter": "Device",
|
||||
"sortType": "Type",
|
||||
"tableTitle": "Repository table",
|
||||
"tableSubtitle": "Artifacts available for download, e-mail and restore.",
|
||||
"compareHint": "Select exactly two .rsc files to compare them.",
|
||||
"compareSelected": "Compare selected exports",
|
||||
"fileColumn": "File",
|
||||
"typeColumn": "Type",
|
||||
"routerColumn": "Device",
|
||||
"createdColumn": "Created",
|
||||
"actionsColumn": "Actions",
|
||||
"checksum": "Checksum",
|
||||
"exportType": "Export",
|
||||
"binaryType": "Binary backup",
|
||||
"previewDialogTitle": "Export preview",
|
||||
"diffDialogTitle": "Export diff",
|
||||
"openHtmlDiff": "Open HTML diff",
|
||||
"sizeColumn": "Size",
|
||||
"compareColumn": "Compare",
|
||||
"compareOlder": "Older file",
|
||||
"compareNewer": "Newer file",
|
||||
"pickOlder": "Pick older backup",
|
||||
"pickNewer": "Pick newer backup",
|
||||
"compareLatestPair": "Latest pair",
|
||||
"setOlder": "Set as older",
|
||||
"setNewer": "Set as newer",
|
||||
"latestForRouter": "Router diff",
|
||||
"binaryNoCompare": "Diff available for .rsc only",
|
||||
"openPlainDiff": "Show plain diff",
|
||||
"minutesAgo": "{{value}} min ago",
|
||||
"hoursAgo": "{{value}} h ago",
|
||||
"daysAgo": "{{value}} d ago",
|
||||
"compareTitle": "Export comparison",
|
||||
"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 · device {{router}}",
|
||||
"compareReadyMixedRouters": "Pair ready · mixed devices"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"eyebrow": "platform configuration",
|
||||
"subtitle": "Control schedules, retention, notifications, connection tests and shared SSH data.",
|
||||
"testEmail": "Test e-mail",
|
||||
"testPushover": "Test Pushover",
|
||||
"retentionTitle": "Retention",
|
||||
"retentionSubtitle": "Automatic cleanup windows for files and logs.",
|
||||
"backupRetentionDays": "Backup retention days",
|
||||
"logRetentionDays": "Log retention days",
|
||||
"retentionCron": "Retention cron",
|
||||
"automationTitle": "Automation",
|
||||
"automationSubtitle": "Schedules for export, binary jobs, retention and connection checks.",
|
||||
"enableAutoExport": "Enable auto export",
|
||||
"enableAutoExportHint": "Run export jobs using the cron rules below.",
|
||||
"exportCron": "Export cron",
|
||||
"binaryCron": "Binary cron",
|
||||
"notificationsTitle": "Notifications",
|
||||
"notificationsSubtitle": "SMTP and Pushover delivery configuration.",
|
||||
"smtpEnabled": "SMTP enabled",
|
||||
"smtpEnabledHint": "Send notifications through the SMTP gateway.",
|
||||
"failuresOnly": "Failures only",
|
||||
"failuresOnlyHint": "Limit alerts to failed jobs.",
|
||||
"smtpHost": "SMTP host",
|
||||
"smtpPort": "SMTP port",
|
||||
"smtpLogin": "SMTP login",
|
||||
"smtpPassword": "SMTP password",
|
||||
"recipientEmail": "Recipient e-mail",
|
||||
"pushoverToken": "Pushover token",
|
||||
"pushoverUserKey": "Pushover user key",
|
||||
"pushoverTokenPlaceholder": "Application token",
|
||||
"pushoverUserKeyPlaceholder": "User key",
|
||||
"sshDefaultsTitle": "Default Credentials",
|
||||
"sshDefaultsSubtitle": "Shared SSH key and default SwitchOS login used across managed devices.",
|
||||
"globalSshPrivateKey": "Global SSH private key",
|
||||
"globalSshPrivateKeyPlaceholder": "Paste PEM or OpenSSH private key",
|
||||
"save": "Save settings",
|
||||
"scheduleDisabled": "Disabled",
|
||||
"scheduleDaily": "Daily",
|
||||
"scheduleWeekly": "Weekly",
|
||||
"scheduleCustom": "Custom cron",
|
||||
"scheduleMode": "Schedule mode",
|
||||
"scheduleTime": "Time",
|
||||
"scheduleWeekday": "Weekday",
|
||||
"weekdayMonday": "Monday",
|
||||
"weekdayTuesday": "Tuesday",
|
||||
"weekdayWednesday": "Wednesday",
|
||||
"weekdayThursday": "Thursday",
|
||||
"weekdayFriday": "Friday",
|
||||
"weekdaySaturday": "Saturday",
|
||||
"weekdaySunday": "Sunday",
|
||||
"scheduleDisabledHint": "The job will not run automatically.",
|
||||
"scheduleDailySummary": "Every day at {{time}}",
|
||||
"scheduleWeeklySummary": "Every {{weekday}} at {{time}}",
|
||||
"scheduleCustomEmpty": "Enter a custom cron expression",
|
||||
"statusEnabled": "Enabled",
|
||||
"statusDisabled": "Disabled",
|
||||
"noNextRun": "No next run scheduled",
|
||||
"exportScheduleTitle": "Text exports",
|
||||
"binaryScheduleTitle": "Binary backups",
|
||||
"automationPlannerTitle": "Job planner",
|
||||
"automationPlannerSubtitle": "Every job has its own schedule, so export, binary backup and retention can run in separate windows.",
|
||||
"automationPlannerTag": "Flexible windows",
|
||||
"exportPlannerHint": "Decide when readable text exports should be created. Disabled mode stops the automation completely.",
|
||||
"binaryPlannerHint": "Separate window for full binary backups when you need restore points.",
|
||||
"retentionPlannerHint": "Retention cleans old backups and logs on its own schedule.",
|
||||
"connectionTestsTitle": "Automatic connection tests",
|
||||
"connectionTestsHint": "The application can refresh router status automatically. Set 0 to disable automatic tests.",
|
||||
"connectionTestIntervalMinutes": "Check every X minutes",
|
||||
"connectionTestsEverySummary": "Every {{minutes}} minutes",
|
||||
"connectionTestsDisabledHint": "Automatic connection tests are disabled.",
|
||||
"sshKeyHelper": "Keep the shared SSH key on the right side. Reveal it only after confirming your account password.",
|
||||
"sshKeyStoredTag": "Stored key",
|
||||
"sshKeyWillBeRemovedTag": "Will be removed",
|
||||
"sshRevealHint": "The current key stays hidden until you confirm your password. You can still paste a new key below to replace it.",
|
||||
"revealSshPassword": "Current account password",
|
||||
"revealSshPasswordPlaceholder": "Enter password to reveal the key",
|
||||
"revealSshKey": "Reveal key",
|
||||
"hideSshKey": "Hide key",
|
||||
"clearSshKey": "Clear key",
|
||||
"sshKeyClearNotice": "The stored shared SSH key will be removed after saving.",
|
||||
"globalSshPrivateKeyHiddenPlaceholder": "Stored key hidden. Enter the password above to reveal it, or paste a new key here to replace it.",
|
||||
"sshRevealPasswordRequired": "Enter your current password to reveal the SSH key.",
|
||||
"sshRevealPasswordInvalid": "The password used to reveal the SSH key is invalid.",
|
||||
"schedulerAutoExportLabel": "Automatic exports",
|
||||
"schedulerBinaryLabel": "Binary backups",
|
||||
"schedulerRetentionLabel": "Retention cleanup",
|
||||
"schedulerConnectionLabel": "Connection checks",
|
||||
"schedulerLogsLabel": "Log cleanup",
|
||||
"schedulerLogsDescription": "Every 24 hours",
|
||||
"schedulerCronDescription": "{{description}}",
|
||||
"schedulerInvalidCron": "Invalid cron expression",
|
||||
"interfaceTitle": "Interface configuration",
|
||||
"interfaceSubtitle": "Language and typography preferences saved for your account.",
|
||||
"interfacePreferencesTitle": "Workspace appearance",
|
||||
"interfacePreferencesHint": "Choose the default language and font family for the whole application.",
|
||||
"interfacePreferencesTag": "Per-user",
|
||||
"fontFamily": "Font family",
|
||||
"fontDefault": "Default",
|
||||
"switchosDefaultsTitle": "Default SwitchOS credentials",
|
||||
"switchosDefaultsHint": "Used when a SwitchOS device has no local username or password.",
|
||||
"defaultSwitchosUsername": "Default SwitchOS username",
|
||||
"defaultSwitchosPassword": "Default SwitchOS password"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
"eyebrow": "operational history",
|
||||
"subtitle": "Audit the latest export, restore and maintenance events.",
|
||||
"daysPlaceholder": "days",
|
||||
"deleteOlderThan": "Delete older than",
|
||||
"entriesLabel": "Entries",
|
||||
"entriesHint": "Loaded rows",
|
||||
"auditTag": "Audit",
|
||||
"retentionLabel": "Retention",
|
||||
"retentionHint": "Cleanup threshold",
|
||||
"policyTag": "Policy",
|
||||
"daysSuffix": "days",
|
||||
"tableTitle": "Log table",
|
||||
"tableSubtitle": "Chronological list of operations captured by the backend.",
|
||||
"timestampColumn": "Timestamp",
|
||||
"messageColumn": "Message",
|
||||
"retentionInfoLabel": "Configured log retention"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Done",
|
||||
"info": "Info",
|
||||
"error": "Error",
|
||||
"exportPreviewLoaded": "Export preview loaded.",
|
||||
"backupSentEmail": "Backup sent by e-mail.",
|
||||
"binaryUploaded": "Binary backup uploaded to the router.",
|
||||
"backupDeleted": "Backup deleted.",
|
||||
"selectedBackupsDeleted": "Selected backups deleted.",
|
||||
"diffLoaded": "Diff loaded.",
|
||||
"archivePrepared": "Archive prepared.",
|
||||
"exportedRouters": "Export completed for {{count}} devices.",
|
||||
"binaryCompletedRouters": "Binary backup completed for {{count}} devices.",
|
||||
"routerCreated": "Router created.",
|
||||
"routerUpdated": "Router updated.",
|
||||
"routerDeleted": "Router deleted.",
|
||||
"exportCreated": "Export created.",
|
||||
"binaryCreated": "Binary backup created.",
|
||||
"connectionSuccessful": "Connection successful.",
|
||||
"settingsSaved": "Settings saved.",
|
||||
"testEmailSent": "Test e-mail sent.",
|
||||
"testPushoverSent": "Test Pushover notification sent.",
|
||||
"logsDeletedOlderThan": "Logs older than {{days}} days deleted.",
|
||||
"passwordChanged": "Password changed.",
|
||||
"connectionFailed": "Connection test failed.",
|
||||
"sshKeyUnlocked": "SSH key unlocked.",
|
||||
"settingsSaveFailed": "Could not save settings.",
|
||||
"testEmailFailed": "Could not send the test email.",
|
||||
"testPushoverFailed": "Could not send the test Pushover notification.",
|
||||
"swosBetaProbeOk": "SwitchOS connectivity verified.",
|
||||
"swosBetaProbeFailed": "Could not verify SwitchOS access.",
|
||||
"swosBetaDownloadOk": "SwitchOS backup downloaded.",
|
||||
"swosBetaDownloadFailed": "Could not download the SwitchOS backup."
|
||||
},
|
||||
"confirm": {
|
||||
"header": "Confirmation",
|
||||
"deleteBackup": "Delete this backup file?",
|
||||
"deleteSelectedFiles": "Delete {{count}} selected files?",
|
||||
"deleteRouterWithFiles": "Delete the router and all related files?",
|
||||
"deleteLogsOlderThan": "Delete logs older than {{days}} days?"
|
||||
},
|
||||
"footer": {
|
||||
"authorLabel": "Author",
|
||||
"apiLabel": "API",
|
||||
"apiOnline": "online",
|
||||
"apiOffline": "offline",
|
||||
"apiChecking": "checking",
|
||||
"apiLatencyLabel": "API latency",
|
||||
"apiDocs": "API docs",
|
||||
"apiOfflineTitle": "API connection lost",
|
||||
"apiOfflineMessage": "The backend is not responding. Some features may be temporarily unavailable.",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"diffConfigs": {
|
||||
"title": "Config diff",
|
||||
"eyebrow": "export comparison",
|
||||
"subtitle": "Dedicated workspace for convenient RouterOS configuration comparisons.",
|
||||
"exportsCard": "Exports for diff",
|
||||
"exportsCardHint": ".rsc files in the current scope",
|
||||
"scopeCard": "Scope",
|
||||
"scopeCardHint": "Selected router or whole fleet",
|
||||
"scopeTag": "Scope",
|
||||
"readyCard": "Pair",
|
||||
"readyCardHint": "Selection state for comparison",
|
||||
"readyTag": "State",
|
||||
"lastDiffCard": "Last diff",
|
||||
"lastDiffCardHint": "Last opened file pair",
|
||||
"lastDiffTag": "History",
|
||||
"workspaceTitle": "Comparison workspace",
|
||||
"workspaceSubtitle": "Pick a router, set older and newer export, then open the diff in a modal.",
|
||||
"tableTitle": "Exports to pick from",
|
||||
"tableSubtitle": "Quickly assign older and newer files and preview them without leaving the page.",
|
||||
"waitingTag": "Waiting",
|
||||
"noneSelected": "None"
|
||||
},
|
||||
"switchosBeta": {
|
||||
"title": "SwitchOS beta",
|
||||
"eyebrow": "switchos / beta build",
|
||||
"subtitle": "Standalone module for pulling SwitchOS backups without wiring it into the main repository.",
|
||||
"betaTag": "Untested beta",
|
||||
"summaryStandaloneValue": "Standalone",
|
||||
"summaryStandaloneLabel": "Runs outside the main flow",
|
||||
"summaryProtocolLabel": "Target protocol",
|
||||
"summaryArtifactLabel": "Backup format",
|
||||
"warningTitle": "Module status",
|
||||
"warningSubtitle": "Separate working path prepared for SwitchOS web scraping.",
|
||||
"warningHeadline": "This tab is marked as an untested beta build.",
|
||||
"warningBody": "It does not save devices or files into the existing RouterOS inventory. It is meant for manual access checks and direct SwitchOS backup downloads.",
|
||||
"formTitle": "Device details",
|
||||
"formSubtitle": "Enter the switch address and the credentials used in the web UI.",
|
||||
"label": "File label",
|
||||
"labelPlaceholder": "for example css326-warehouse",
|
||||
"host": "Host / URL",
|
||||
"hostPlaceholder": "for example 192.168.88.1 or http://192.168.88.1",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Leave empty when the device has no password",
|
||||
"probeButton": "Check access",
|
||||
"downloadButton": "Download backup .swb",
|
||||
"resultTitle": "Connection result",
|
||||
"resultSubtitle": "Quick preview of the device response before downloading the file.",
|
||||
"resultEmpty": "Check device access first or download the backup right away.",
|
||||
"baseUrl": "Base URL",
|
||||
"httpStatus": "HTTP status",
|
||||
"authMode": "Auth mode",
|
||||
"pageTitle": "Page title",
|
||||
"serverHeader": "Server header",
|
||||
"backupEndpoint": "Backup endpoint",
|
||||
"available": "Available",
|
||||
"unavailable": "Unavailable",
|
||||
"genericError": "The SwitchOS beta operation could not be completed."
|
||||
}
|
||||
}
|
||||
541
frontend/src/assets/i18n/es.json
Normal file
541
frontend/src/assets/i18n/es.json
Normal file
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"app": {
|
||||
"menu": "Menú"
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "copia de MikroTik",
|
||||
"subtitle": "gestor de RouterOS/SwitchOS"
|
||||
},
|
||||
"topbar": {
|
||||
"caption": "mikrotik / centro de control",
|
||||
"role": "administrador",
|
||||
"languageSelector": "Selector de idioma"
|
||||
},
|
||||
"common": {
|
||||
"apply": "Aplicar",
|
||||
"reset": "Restablecer",
|
||||
"delete": "Eliminar",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"download": "Descargar",
|
||||
"email": "Enviar correo",
|
||||
"preview": "Vista previa",
|
||||
"restore": "Restaurar",
|
||||
"actions": "Acciones",
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"diff": "Diff",
|
||||
"ok": "OK",
|
||||
"idle": "Sin datos",
|
||||
"asc": "Ascendente",
|
||||
"desc": "Descendente",
|
||||
"enabled": "Activado",
|
||||
"disabled": "Desactivado",
|
||||
"failed": "Error"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"routers": "Dispositivos",
|
||||
"files": "Repositorio",
|
||||
"settings": "Ajustes",
|
||||
"logs": "Registros",
|
||||
"logout": "Cerrar sesión",
|
||||
"theme": "Tema",
|
||||
"changePassword": "Cambiar contraseña",
|
||||
"diffConfigs": "Diff de configuración",
|
||||
"switchosBeta": "SwitchOS beta"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"changePassword": "Cambiar contraseña",
|
||||
"currentPassword": "Contraseña actual",
|
||||
"newPassword": "Nueva contraseña",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"backToApp": "Volver a la app",
|
||||
"loginSubtitle": "Inicia sesión para continuar.",
|
||||
"loginFailed": "Error de inicio de sesión",
|
||||
"accountCreated": "Cuenta creada",
|
||||
"registrationFailed": "Error de registro",
|
||||
"passwordsMismatch": "Las contraseñas no coinciden",
|
||||
"changePasswordFailed": "No se pudo cambiar la contraseña",
|
||||
"securityEyebrow": "cuenta / seguridad",
|
||||
"changePasswordSubtitle": "Actualiza la contraseña del administrador sin desorden visual.",
|
||||
"changePasswordCardSubtitle": "Introduce la contraseña actual y define las nuevas credenciales.",
|
||||
"passwordPanelSubtitle": "Comprobación rápida de fuerza y coincidencia antes de guardar.",
|
||||
"passwordStrength": "Fuerza de la contraseña",
|
||||
"passwordWeak": "Débil",
|
||||
"passwordMedium": "Media",
|
||||
"passwordStrong": "Fuerte",
|
||||
"ruleLength": "Al menos 8 caracteres",
|
||||
"ruleDigit": "Al menos un número",
|
||||
"ruleMatch": "Ambos campos coinciden",
|
||||
"passwordsMatchHint": "La nueva contraseña y la confirmación coinciden."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"eyebrow": "inicio / panel",
|
||||
"subtitle": "Resumen de copias, exportaciones y actividad operativa en un solo lugar.",
|
||||
"exportAll": "Exportar todo",
|
||||
"binaryAll": "Copia binaria",
|
||||
"managedRouters": "Dispositivos",
|
||||
"managedRoutersHint": "Todos los dispositivos gestionados",
|
||||
"inventoryTag": "Flota",
|
||||
"exportsCard": "Exportaciones",
|
||||
"exportsHint": "Instantáneas legibles de configuración",
|
||||
"textTag": "Texto",
|
||||
"binaryCard": "Copias binarias",
|
||||
"binaryHint": "Puntos de recuperación",
|
||||
"binaryTag": "Binario",
|
||||
"allFilesCard": "Todos los archivos",
|
||||
"allFilesHint": "Artefactos en el repositorio",
|
||||
"archiveTag": "Archivo",
|
||||
"storageTitle": "Uso del almacenamiento",
|
||||
"storageSubtitle": "Resumen actual del uso del repositorio y del espacio libre.",
|
||||
"folderUsage": "Uso de la carpeta",
|
||||
"diskUsage": "Uso del disco",
|
||||
"totalDisk": "Disco total",
|
||||
"freeSpace": "Espacio libre",
|
||||
"activityTitle": "Actividad reciente",
|
||||
"activitySubtitle": "Últimos eventos operativos del backend.",
|
||||
"noActivity": "No hay eventos recientes para mostrar.",
|
||||
"avgBackupsPerRouter": "Prom. copias / router",
|
||||
"activitySuccess": "Tarea completada",
|
||||
"activityFailure": "Requiere atención",
|
||||
"activityMaintenance": "Mantenimiento",
|
||||
"activityDelivery": "Entrega",
|
||||
"operationsTitle": "Centro de operaciones",
|
||||
"operationsSubtitle": "Acciones principales e indicadores en vivo del repositorio en un solo lugar.",
|
||||
"latestSnapshot": "Última instantánea",
|
||||
"coverageLabel": "Cobertura de la flota",
|
||||
"coverageHint": "Routers con al menos una copia",
|
||||
"weeklyActivityLabel": "Actividad de 7 días",
|
||||
"weeklyActivityHint": "Nuevas copias creadas esta semana",
|
||||
"busiestRouterLabel": "Router más activo",
|
||||
"routerSnapshotsHint": "{{count}} instantáneas en el repositorio",
|
||||
"exportShareLabel": "Cuota de exportaciones",
|
||||
"activityTodayLabel": "Eventos hoy",
|
||||
"noneLabel": "Ninguno",
|
||||
"activityTodayHint": "Entradas creadas hoy",
|
||||
"usedSpace": "Espacio usado",
|
||||
"storageViewCapacity": "Capacidad",
|
||||
"storageViewCapacityHint": "Disco, uso del repositorio y espacio libre en una sola escala.",
|
||||
"storageViewMix": "Tipos de copias",
|
||||
"storageViewMixHint": "Distribución de todas las copias entre exportaciones de texto y copias binarias.",
|
||||
"storageViewActivity": "Actividad 7 días",
|
||||
"storageViewActivityHint": "Número de nuevas copias creadas en los últimos siete días.",
|
||||
"storageViewRouters": "Routers principales",
|
||||
"storageViewRoutersHint": "Dispositivos con mayor número de instantáneas en el repositorio.",
|
||||
"storageChartEmpty": "Todavía no hay datos suficientes para dibujar este gráfico.",
|
||||
"storageSnapshotTitle": "Métricas del repositorio",
|
||||
"storageSnapshotHint": "Vista rápida de los indicadores más importantes de almacenamiento y copias."
|
||||
},
|
||||
"routers": {
|
||||
"title": "Dispositivos",
|
||||
"detailTitle": "Detalles del dispositivo",
|
||||
"add": "Agregar dispositivo",
|
||||
"eyebrow": "inventario de dispositivos",
|
||||
"subtitle": "Administra dispositivos RouterOS y SwitchOS y sus copias.",
|
||||
"registeredDevices": "Dispositivos registrados",
|
||||
"fleetTag": "Flota",
|
||||
"sshPassword": "Contraseña",
|
||||
"passwordHint": "Acceso con contraseña",
|
||||
"credsTag": "Credenciales",
|
||||
"sshKey": "Clave SSH",
|
||||
"keyHint": "Acceso con clave",
|
||||
"securityTag": "Seguridad",
|
||||
"defaultPort": "Puerto 22",
|
||||
"defaultPortHint": "Endpoints SSH estándar",
|
||||
"portTag": "Puerto",
|
||||
"listTitle": "Lista de dispositivos",
|
||||
"listSubtitle": "Vista unificada para RouterOS y SwitchOS.",
|
||||
"name": "Nombre",
|
||||
"endpoint": "Endpoint",
|
||||
"access": "Acceso",
|
||||
"routerOsTarget": "Objetivo RouterOS",
|
||||
"passwordMode": "Contraseña",
|
||||
"noPassword": "Sin contraseña",
|
||||
"keyMode": "Clave",
|
||||
"globalKeyMode": "Clave global",
|
||||
"noKey": "Sin clave",
|
||||
"createDialogTitle": "Agregar dispositivo",
|
||||
"editDialogTitle": "Editar dispositivo",
|
||||
"host": "Host",
|
||||
"port": "Puerto",
|
||||
"sshUser": "Usuario",
|
||||
"sshPrivateKey": "Clave privada SSH",
|
||||
"optionalPassword": "Contraseña opcional",
|
||||
"optionalPrivateKey": "Clave privada opcional",
|
||||
"saveRouter": "Guardar dispositivo",
|
||||
"profileEyebrow": "perfil del router",
|
||||
"detailSubtitle": "Operaciones del dispositivo e historial de copias",
|
||||
"exportOne": "Exportar",
|
||||
"binaryOne": "Binario",
|
||||
"testConnection": "Probar conexión",
|
||||
"deleteRouter": "Eliminar router",
|
||||
"exportsLabel": "Exportaciones",
|
||||
"exportsLabelHint": "Instantáneas de texto",
|
||||
"binaryLabel": "Copias binarias",
|
||||
"binaryLabelHint": "Imágenes de recuperación",
|
||||
"connectionLabel": "Conexión",
|
||||
"connectionLabelHint": "Estado de la última prueba automática o manual",
|
||||
"probeTag": "Prueba",
|
||||
"accessTag": "Acceso",
|
||||
"sshUserHint": "Usuario SSH actual",
|
||||
"deviceStatusTitle": "Estado del dispositivo",
|
||||
"deviceStatusSubtitle": "Metadatos guardados de la última prueba automática o manual de conexión.",
|
||||
"connectionStateTitle": "Estado de la conexión",
|
||||
"lastTestAt": "Última prueba",
|
||||
"hostname": "Hostname",
|
||||
"model": "Modelo",
|
||||
"version": "Versión",
|
||||
"uptime": "Uptime",
|
||||
"lastError": "Último error",
|
||||
"deviceStatusManualHint": "Las comprobaciones automáticas usan el intervalo de ajustes. La prueba manual sigue disponible.",
|
||||
"noConnection": "Aún no hay una prueba de conexión guardada. Ejecuta una prueba manual o activa las comprobaciones automáticas en ajustes.",
|
||||
"previewTitle": "Vista previa de exportación",
|
||||
"previewSubtitle": "Último archivo de exportación abierto.",
|
||||
"noPreview": "Selecciona un archivo de exportación para ver su contenido.",
|
||||
"diffTitle": "Último diff",
|
||||
"diffSubtitle": "Diferencia respecto a la exportación más reciente.",
|
||||
"exportsTableTitle": "Exportaciones",
|
||||
"exportsTableSubtitle": "Instantáneas legibles de RouterOS.",
|
||||
"binaryTableTitle": "Copias binarias",
|
||||
"binaryTableSubtitle": "Archivos listos para restaurar el dispositivo.",
|
||||
"summaryKeyAccess": "con acceso por clave",
|
||||
"summaryPasswordAccess": "con acceso por contraseña",
|
||||
"previewModalHint": "La última exportación abierta está disponible en un modal.",
|
||||
"openPreviewModal": "Abrir vista previa",
|
||||
"diffModalHint": "El último diff cargado está disponible en un modal.",
|
||||
"openDiffModal": "Abrir diff",
|
||||
"noDiff": "Elige una exportación y ejecuta un diff para ver la última comparación.",
|
||||
"routeros": "RouterOS",
|
||||
"switchos": "SwitchOS",
|
||||
"deviceType": "Tipo de dispositivo",
|
||||
"defaultCredentials": "Credenciales por defecto",
|
||||
"localCredentials": "Credenciales locales",
|
||||
"noCredentials": "Sin credenciales",
|
||||
"switchUserPlaceholder": "Vacío = usar ajustes",
|
||||
"switchPasswordPlaceholder": "Vacío = usar ajustes",
|
||||
"switchDefaultsHint": "Para SwitchOS puedes dejar usuario y contraseña vacíos para usar los valores por defecto.",
|
||||
"downloadSwitchBackup": "Descargar copia",
|
||||
"httpStatus": "Estado HTTP",
|
||||
"serverHeader": "Cabecera Server",
|
||||
"authMode": "Modo de autenticación",
|
||||
"backupEndpoint": "Endpoint de copia",
|
||||
"backupAvailable": "Disponible",
|
||||
"backupUnavailable": "No disponible",
|
||||
"connectionSectionTitle": "Perfil de conexión",
|
||||
"connectionSectionHint": "Identidad básica del dispositivo y endpoint usado para alcanzarlo.",
|
||||
"credentialsSectionTitle": "Acceso y credenciales",
|
||||
"routerDialogSubtitle": "Configura el endpoint del dispositivo, los datos SSH y el método de acceso preferido.",
|
||||
"switchDialogSubtitle": "Configura el endpoint de SwitchOS y las credenciales locales u opcionales compartidas desde ajustes."
|
||||
},
|
||||
"files": {
|
||||
"title": "Repositorio",
|
||||
"eyebrow": "repositorio de artefactos",
|
||||
"subtitle": "Busca, compara y entrega copias desde una vista clara.",
|
||||
"downloadZip": "Descargar ZIP",
|
||||
"visibleFiles": "Archivos visibles",
|
||||
"visibleFilesHint": "Resultado del filtro actual",
|
||||
"liveTag": "En vivo",
|
||||
"selected": "Seleccionados",
|
||||
"selectedHint": "Listos para acciones masivas",
|
||||
"batchTag": "Lote",
|
||||
"exportsCard": "Exportaciones",
|
||||
"exportsHint": "Instantáneas de configuración",
|
||||
"binaryCard": "Copias binarias",
|
||||
"binaryHint": "Imágenes de recuperación",
|
||||
"filtersTitle": "Filtros",
|
||||
"filtersSubtitle": "Refina la lista por router, tipo o palabra clave.",
|
||||
"searchLabel": "Buscar",
|
||||
"searchPlaceholder": "Buscar por archivo o router",
|
||||
"typeLabel": "Tipo",
|
||||
"routerLabel": "Dispositivo",
|
||||
"dateLabel": "Fecha",
|
||||
"datePlaceholder": "Selecciona una fecha",
|
||||
"sortLabel": "Ordenar por",
|
||||
"orderLabel": "Orden",
|
||||
"allTypes": "Todos los tipos",
|
||||
"allRouters": "Todos los dispositivos",
|
||||
"sortNewest": "Más nuevo",
|
||||
"sortName": "Nombre",
|
||||
"sortRouter": "Dispositivo",
|
||||
"sortType": "Tipo",
|
||||
"tableTitle": "Tabla del repositorio",
|
||||
"tableSubtitle": "Artefactos disponibles para descarga, correo y restauración.",
|
||||
"compareHint": "Selecciona exactamente dos archivos .rsc para compararlos.",
|
||||
"compareSelected": "Comparar exportaciones seleccionadas",
|
||||
"fileColumn": "Archivo",
|
||||
"typeColumn": "Tipo",
|
||||
"routerColumn": "Dispositivo",
|
||||
"createdColumn": "Creado",
|
||||
"actionsColumn": "Acciones",
|
||||
"checksum": "Checksum",
|
||||
"exportType": "Exportación",
|
||||
"binaryType": "Copia binaria",
|
||||
"previewDialogTitle": "Vista previa de exportación",
|
||||
"diffDialogTitle": "Diff de exportación",
|
||||
"openHtmlDiff": "Abrir diff HTML",
|
||||
"sizeColumn": "Tamaño",
|
||||
"compareColumn": "Comparar",
|
||||
"compareOlder": "Archivo anterior",
|
||||
"compareNewer": "Archivo más nuevo",
|
||||
"pickOlder": "Seleccionar copia anterior",
|
||||
"pickNewer": "Seleccionar copia más nueva",
|
||||
"compareLatestPair": "Último par",
|
||||
"setOlder": "Marcar como anterior",
|
||||
"setNewer": "Marcar como más nuevo",
|
||||
"latestForRouter": "Diff del router",
|
||||
"binaryNoCompare": "Diff disponible solo para .rsc",
|
||||
"openPlainDiff": "Mostrar diff plano",
|
||||
"minutesAgo": "hace {{value}} min",
|
||||
"hoursAgo": "hace {{value}} h",
|
||||
"daysAgo": "hace {{value}} d",
|
||||
"compareTitle": "Comparación de exportaciones",
|
||||
"compareSubtitle": "Selecciona dos archivos .rsc y ejecuta el diff sin revisar toda la tabla.",
|
||||
"exportPoolLabel": "exportaciones listas para comparar",
|
||||
"compareSelectionHint": "Selecciona un archivo anterior y uno más nuevo",
|
||||
"compareReadySameRouter": "Par listo · router {{router}}",
|
||||
"compareReadyMixedRouters": "Par listo · routers mezclados"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"eyebrow": "configuración de la plataforma",
|
||||
"subtitle": "Controla horarios, retención, notificaciones, pruebas de conexión y datos SSH compartidos.",
|
||||
"testEmail": "Probar correo",
|
||||
"testPushover": "Probar Pushover",
|
||||
"retentionTitle": "Retención",
|
||||
"retentionSubtitle": "Ventanas automáticas de limpieza para archivos y registros.",
|
||||
"backupRetentionDays": "Días de retención de copias",
|
||||
"logRetentionDays": "Días de retención de registros",
|
||||
"retentionCron": "Cron de retención",
|
||||
"automationTitle": "Automatización",
|
||||
"automationSubtitle": "Horarios para exportaciones, copias binarias, retención y comprobaciones de conexión.",
|
||||
"enableAutoExport": "Activar exportación automática",
|
||||
"enableAutoExportHint": "Ejecuta exportaciones con las reglas cron de abajo.",
|
||||
"exportCron": "Cron de exportación",
|
||||
"binaryCron": "Cron binario",
|
||||
"notificationsTitle": "Notificaciones",
|
||||
"notificationsSubtitle": "Configuración de entrega SMTP y Pushover.",
|
||||
"smtpEnabled": "SMTP activado",
|
||||
"smtpEnabledHint": "Envía notificaciones a través de la pasarela SMTP.",
|
||||
"failuresOnly": "Solo fallos",
|
||||
"failuresOnlyHint": "Limita las alertas a trabajos fallidos.",
|
||||
"smtpHost": "Host SMTP",
|
||||
"smtpPort": "Puerto SMTP",
|
||||
"smtpLogin": "Login SMTP",
|
||||
"smtpPassword": "Contraseña SMTP",
|
||||
"recipientEmail": "Correo del destinatario",
|
||||
"pushoverToken": "Token de Pushover",
|
||||
"pushoverUserKey": "Clave de usuario de Pushover",
|
||||
"pushoverTokenPlaceholder": "Token de la aplicación",
|
||||
"pushoverUserKeyPlaceholder": "Clave de usuario",
|
||||
"sshDefaultsTitle": "Credenciales predeterminadas",
|
||||
"sshDefaultsSubtitle": "Clave SSH compartida y acceso por defecto de SwitchOS usados por los dispositivos gestionados.",
|
||||
"globalSshPrivateKey": "Clave privada SSH global",
|
||||
"globalSshPrivateKeyPlaceholder": "Pega la clave privada PEM u OpenSSH",
|
||||
"globalSshPrivateKeyHiddenPlaceholder": "La clave guardada está oculta. Introduce la contraseña arriba para verla o pega aquí una nueva clave para reemplazarla.",
|
||||
"save": "Guardar ajustes",
|
||||
"scheduleDisabled": "Desactivado",
|
||||
"scheduleDaily": "Diario",
|
||||
"scheduleWeekly": "Semanal",
|
||||
"scheduleCustom": "Cron personalizado",
|
||||
"scheduleMode": "Modo de horario",
|
||||
"scheduleTime": "Hora",
|
||||
"scheduleWeekday": "Día de la semana",
|
||||
"weekdayMonday": "Lunes",
|
||||
"weekdayTuesday": "Martes",
|
||||
"weekdayWednesday": "Miércoles",
|
||||
"weekdayThursday": "Jueves",
|
||||
"weekdayFriday": "Viernes",
|
||||
"weekdaySaturday": "Sábado",
|
||||
"weekdaySunday": "Domingo",
|
||||
"scheduleDisabledHint": "La tarea no se ejecutará automáticamente.",
|
||||
"scheduleDailySummary": "Cada día a las {{time}}",
|
||||
"scheduleWeeklySummary": "Cada {{weekday}} a las {{time}}",
|
||||
"scheduleCustomEmpty": "Introduce una expresión cron personalizada",
|
||||
"statusEnabled": "Activado",
|
||||
"statusDisabled": "Desactivado",
|
||||
"noNextRun": "No hay próxima ejecución programada",
|
||||
"exportScheduleTitle": "Exportaciones de texto",
|
||||
"binaryScheduleTitle": "Copias binarias",
|
||||
"automationPlannerTitle": "Planificador de tareas",
|
||||
"automationPlannerSubtitle": "Cada tarea tiene su propio horario, así que exportación, copia binaria y retención pueden ejecutarse en ventanas separadas.",
|
||||
"automationPlannerTag": "Ventanas flexibles",
|
||||
"exportPlannerHint": "Decide cuándo crear exportaciones de texto legibles. El modo desactivado detiene la automatización.",
|
||||
"binaryPlannerHint": "Ventana separada para copias binarias completas cuando necesitas puntos de restauración.",
|
||||
"retentionPlannerHint": "La retención limpia copias y registros antiguos según su propio horario.",
|
||||
"connectionTestsTitle": "Pruebas automáticas de conexión",
|
||||
"connectionTestsHint": "La aplicación puede actualizar el estado del router automáticamente. Pon 0 para desactivar las pruebas automáticas.",
|
||||
"connectionTestIntervalMinutes": "Comprobar cada X minutos",
|
||||
"connectionTestsEverySummary": "Cada {{minutes}} minutos",
|
||||
"connectionTestsDisabledHint": "Las pruebas automáticas de conexión están desactivadas.",
|
||||
"sshKeyHelper": "Mantén la clave SSH compartida en la columna derecha. Solo se revela tras confirmar la contraseña de tu cuenta.",
|
||||
"sshKeyStoredTag": "Clave guardada",
|
||||
"sshKeyWillBeRemovedTag": "Se eliminará",
|
||||
"sshRevealHint": "La clave actual permanece oculta hasta que confirmes tu contraseña. También puedes pegar una clave nueva abajo para reemplazarla.",
|
||||
"revealSshPassword": "Contraseña actual de la cuenta",
|
||||
"revealSshPasswordPlaceholder": "Introduce la contraseña para revelar la clave",
|
||||
"revealSshKey": "Revelar clave",
|
||||
"hideSshKey": "Ocultar clave",
|
||||
"clearSshKey": "Borrar clave",
|
||||
"sshKeyClearNotice": "La clave SSH compartida guardada se eliminará al guardar.",
|
||||
"sshRevealPasswordRequired": "Introduce tu contraseña actual para revelar la clave SSH.",
|
||||
"sshRevealPasswordInvalid": "La contraseña usada para revelar la clave SSH no es válida.",
|
||||
"schedulerAutoExportLabel": "Exportaciones automáticas",
|
||||
"schedulerBinaryLabel": "Copias binarias",
|
||||
"schedulerRetentionLabel": "Limpieza por retención",
|
||||
"schedulerConnectionLabel": "Comprobaciones de conexión",
|
||||
"schedulerLogsLabel": "Limpieza de registros",
|
||||
"schedulerLogsDescription": "Cada 24 horas",
|
||||
"schedulerCronDescription": "{{description}}",
|
||||
"schedulerInvalidCron": "Expresión cron no válida",
|
||||
"interfaceTitle": "Configuración de la interfaz",
|
||||
"interfaceSubtitle": "Preferencias de idioma y tipografía guardadas para tu cuenta.",
|
||||
"interfacePreferencesTitle": "Apariencia del espacio de trabajo",
|
||||
"interfacePreferencesHint": "Elige el idioma predeterminado y la familia tipográfica para toda la aplicación.",
|
||||
"interfacePreferencesTag": "Por usuario",
|
||||
"fontFamily": "Familia tipográfica",
|
||||
"fontDefault": "Predeterminada",
|
||||
"switchosDefaultsTitle": "Credenciales por defecto de SwitchOS",
|
||||
"switchosDefaultsHint": "Se usan cuando un dispositivo SwitchOS no tiene usuario o contraseña local.",
|
||||
"defaultSwitchosUsername": "Usuario SwitchOS por defecto",
|
||||
"defaultSwitchosPassword": "Contraseña SwitchOS por defecto"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Registros",
|
||||
"eyebrow": "historial operativo",
|
||||
"subtitle": "Audita los últimos eventos de exportación, restauración y mantenimiento.",
|
||||
"daysPlaceholder": "días",
|
||||
"deleteOlderThan": "Eliminar anteriores a",
|
||||
"entriesLabel": "Entradas",
|
||||
"entriesHint": "Filas cargadas",
|
||||
"auditTag": "Auditoría",
|
||||
"retentionLabel": "Retención",
|
||||
"retentionHint": "Umbral de limpieza",
|
||||
"policyTag": "Política",
|
||||
"daysSuffix": "días",
|
||||
"tableTitle": "Tabla de registros",
|
||||
"tableSubtitle": "Lista cronológica de operaciones capturadas por el backend.",
|
||||
"timestampColumn": "Marca de tiempo",
|
||||
"messageColumn": "Mensaje",
|
||||
"retentionInfoLabel": "Retención de registros configurada"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Hecho",
|
||||
"info": "Info",
|
||||
"error": "Error",
|
||||
"exportPreviewLoaded": "Vista previa de exportación cargada.",
|
||||
"backupSentEmail": "Copia enviada por correo.",
|
||||
"binaryUploaded": "Copia binaria subida al router.",
|
||||
"backupDeleted": "Copia eliminada.",
|
||||
"selectedBackupsDeleted": "Copias seleccionadas eliminadas.",
|
||||
"diffLoaded": "Diff cargado.",
|
||||
"archivePrepared": "Archivo preparado.",
|
||||
"exportedRouters": "Exportación completada para {{count}} routers.",
|
||||
"binaryCompletedRouters": "Copia binaria completada para {{count}} routers.",
|
||||
"routerCreated": "Router creado.",
|
||||
"routerUpdated": "Router actualizado.",
|
||||
"routerDeleted": "Router eliminado.",
|
||||
"exportCreated": "Exportación creada.",
|
||||
"binaryCreated": "Copia binaria creada.",
|
||||
"connectionSuccessful": "Conexión correcta.",
|
||||
"connectionFailed": "La prueba de conexión falló.",
|
||||
"settingsSaved": "Ajustes guardados.",
|
||||
"testEmailSent": "Correo de prueba enviado.",
|
||||
"testPushoverSent": "Notificación de prueba de Pushover enviada.",
|
||||
"logsDeletedOlderThan": "Se eliminaron los registros anteriores a {{days}} días.",
|
||||
"passwordChanged": "Contraseña cambiada.",
|
||||
"sshKeyUnlocked": "Clave SSH desbloqueada.",
|
||||
"settingsSaveFailed": "No se pudieron guardar los ajustes.",
|
||||
"testEmailFailed": "No se pudo enviar el correo de prueba.",
|
||||
"testPushoverFailed": "No se pudo enviar la notificación de prueba de Pushover.",
|
||||
"swosBetaProbeOk": "Conectividad de SwitchOS verificada.",
|
||||
"swosBetaProbeFailed": "No se pudo verificar el acceso a SwitchOS.",
|
||||
"swosBetaDownloadOk": "Backup de SwitchOS descargado.",
|
||||
"swosBetaDownloadFailed": "No se pudo descargar el backup de SwitchOS."
|
||||
},
|
||||
"confirm": {
|
||||
"header": "Confirmación",
|
||||
"deleteBackup": "¿Eliminar este archivo de copia?",
|
||||
"deleteSelectedFiles": "¿Eliminar {{count}} archivos seleccionados?",
|
||||
"deleteRouterWithFiles": "¿Eliminar el router y todos los archivos relacionados?",
|
||||
"deleteLogsOlderThan": "¿Eliminar registros anteriores a {{days}} días?"
|
||||
},
|
||||
"footer": {
|
||||
"authorLabel": "Autor",
|
||||
"apiLabel": "API",
|
||||
"apiOnline": "en línea",
|
||||
"apiOffline": "sin conexión",
|
||||
"apiChecking": "comprobando",
|
||||
"apiLatencyLabel": "Latencia API",
|
||||
"apiDocs": "Docs API",
|
||||
"apiOfflineTitle": "Conexión API perdida",
|
||||
"apiOfflineMessage": "El backend no responde. Algunas funciones pueden no estar disponibles temporalmente.",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"diffConfigs": {
|
||||
"title": "Diff de configuración",
|
||||
"eyebrow": "comparación de exportaciones",
|
||||
"subtitle": "Vista dedicada para comparar configuraciones de RouterOS con mejor UX.",
|
||||
"exportsCard": "Exportaciones para diff",
|
||||
"exportsCardHint": "Archivos .rsc en el alcance actual",
|
||||
"scopeCard": "Alcance",
|
||||
"scopeCardHint": "Router seleccionado o toda la flota",
|
||||
"scopeTag": "Alcance",
|
||||
"readyCard": "Par",
|
||||
"readyCardHint": "Estado de selección para comparar",
|
||||
"readyTag": "Estado",
|
||||
"lastDiffCard": "Último diff",
|
||||
"lastDiffCardHint": "Último par abierto",
|
||||
"lastDiffTag": "Historial",
|
||||
"workspaceTitle": "Espacio de comparación",
|
||||
"workspaceSubtitle": "Elige un router, define exportación antigua y nueva y abre el diff en un modal.",
|
||||
"tableTitle": "Exportaciones para elegir",
|
||||
"tableSubtitle": "Asignación rápida de archivos y vista previa sin salir de la página.",
|
||||
"waitingTag": "Esperando",
|
||||
"noneSelected": "Ninguno"
|
||||
},
|
||||
"switchosBeta": {
|
||||
"title": "SwitchOS beta",
|
||||
"eyebrow": "switchos / beta",
|
||||
"subtitle": "Módulo independiente para descargar copias de SwitchOS sin integrarlo con el repositorio principal.",
|
||||
"betaTag": "Beta sin probar",
|
||||
"summaryStandaloneValue": "Aislado",
|
||||
"summaryStandaloneLabel": "Funciona fuera del flujo principal",
|
||||
"summaryProtocolLabel": "Protocolo objetivo",
|
||||
"summaryArtifactLabel": "Formato de copia",
|
||||
"warningTitle": "Estado del módulo",
|
||||
"warningSubtitle": "Ruta de trabajo separada preparada para scraping web de SwitchOS.",
|
||||
"warningHeadline": "Esta pestaña está marcada como una beta sin probar.",
|
||||
"warningBody": "No guarda dispositivos ni archivos en el inventario RouterOS existente. Sirve para comprobar acceso manualmente y descargar el backup de SwitchOS.",
|
||||
"formTitle": "Datos del dispositivo",
|
||||
"formSubtitle": "Introduce la dirección del switch y las credenciales usadas en la interfaz web.",
|
||||
"label": "Etiqueta del archivo",
|
||||
"labelPlaceholder": "por ejemplo css326-almacen",
|
||||
"host": "Host / URL",
|
||||
"hostPlaceholder": "por ejemplo 192.168.88.1 o http://192.168.88.1",
|
||||
"port": "Puerto",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"passwordPlaceholder": "Déjalo vacío si el equipo no tiene contraseña",
|
||||
"probeButton": "Comprobar acceso",
|
||||
"downloadButton": "Descargar backup .swb",
|
||||
"resultTitle": "Resultado de conexión",
|
||||
"resultSubtitle": "Vista rápida de la respuesta del equipo antes de descargar el archivo.",
|
||||
"resultEmpty": "Primero comprueba el acceso al equipo o descarga el backup directamente.",
|
||||
"baseUrl": "URL base",
|
||||
"httpStatus": "Código HTTP",
|
||||
"authMode": "Modo de autenticación",
|
||||
"pageTitle": "Título de la página",
|
||||
"serverHeader": "Cabecera del servidor",
|
||||
"backupEndpoint": "Endpoint del backup",
|
||||
"available": "Disponible",
|
||||
"unavailable": "No disponible",
|
||||
"genericError": "No se pudo completar la operación beta de SwitchOS."
|
||||
}
|
||||
}
|
||||
541
frontend/src/assets/i18n/no.json
Normal file
541
frontend/src/assets/i18n/no.json
Normal file
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"app": {
|
||||
"menu": "Meny"
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "MikroTik-backup",
|
||||
"subtitle": "RouterOS/SwitchOS-behandler"
|
||||
},
|
||||
"topbar": {
|
||||
"caption": "mikrotik / kontrollsenter",
|
||||
"role": "administrator",
|
||||
"languageSelector": "Språkvalg"
|
||||
},
|
||||
"common": {
|
||||
"apply": "Bruk",
|
||||
"reset": "Tilbakestill",
|
||||
"delete": "Slett",
|
||||
"confirm": "Bekreft",
|
||||
"cancel": "Avbryt",
|
||||
"download": "Last ned",
|
||||
"email": "Send e-post",
|
||||
"preview": "Forhåndsvisning",
|
||||
"restore": "Gjenopprett",
|
||||
"actions": "Handlinger",
|
||||
"open": "Åpne",
|
||||
"edit": "Rediger",
|
||||
"diff": "Diff",
|
||||
"ok": "OK",
|
||||
"idle": "Ingen data",
|
||||
"asc": "Stigende",
|
||||
"desc": "Synkende",
|
||||
"enabled": "På",
|
||||
"disabled": "Av",
|
||||
"failed": "Feilet"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashbord",
|
||||
"routers": "Enheter",
|
||||
"files": "Repository",
|
||||
"settings": "Innstillinger",
|
||||
"logs": "Logger",
|
||||
"logout": "Logg ut",
|
||||
"theme": "Tema",
|
||||
"changePassword": "Bytt passord",
|
||||
"diffConfigs": "Konfig-diff",
|
||||
"switchosBeta": "SwitchOS beta"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"login": "Logg inn",
|
||||
"register": "Registrer",
|
||||
"confirmPassword": "Bekreft passord",
|
||||
"changePassword": "Bytt passord",
|
||||
"currentPassword": "Nåværende passord",
|
||||
"newPassword": "Nytt passord",
|
||||
"backToLogin": "Tilbake til innlogging",
|
||||
"backToApp": "Tilbake til appen",
|
||||
"loginSubtitle": "Logg inn for å fortsette.",
|
||||
"loginFailed": "Innlogging mislyktes",
|
||||
"accountCreated": "Konto opprettet",
|
||||
"registrationFailed": "Registrering mislyktes",
|
||||
"passwordsMismatch": "Passordene samsvarer ikke",
|
||||
"changePasswordFailed": "Passordbytte mislyktes",
|
||||
"securityEyebrow": "konto / sikkerhet",
|
||||
"changePasswordSubtitle": "Oppdater administratorpassordet uten unødvendig visuell støy.",
|
||||
"changePasswordCardSubtitle": "Skriv inn nåværende passord og sett nye legitimasjonsdata.",
|
||||
"passwordPanelSubtitle": "Rask kontroll av styrke og samsvar før lagring.",
|
||||
"passwordStrength": "Passordstyrke",
|
||||
"passwordWeak": "Svak",
|
||||
"passwordMedium": "Middels",
|
||||
"passwordStrong": "Sterk",
|
||||
"ruleLength": "Minst 8 tegn",
|
||||
"ruleDigit": "Minst ett tall",
|
||||
"ruleMatch": "Begge feltene samsvarer",
|
||||
"passwordsMatchHint": "Det nye passordet og bekreftelsen samsvarer."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashbord",
|
||||
"eyebrow": "hjem / dashbord",
|
||||
"subtitle": "Oversikt over backuper, eksportfiler og operativ aktivitet på ett sted.",
|
||||
"exportAll": "Eksporter alle",
|
||||
"binaryAll": "Binær backup",
|
||||
"managedRouters": "Enheter",
|
||||
"managedRoutersHint": "Alle administrerte enheter",
|
||||
"inventoryTag": "Flåte",
|
||||
"exportsCard": "Eksporter",
|
||||
"exportsHint": "Lesbare konfigurasjonsøyeblikksbilder",
|
||||
"textTag": "Tekst",
|
||||
"binaryCard": "Binære backuper",
|
||||
"binaryHint": "Gjenopprettingspunkter",
|
||||
"binaryTag": "Binær",
|
||||
"allFilesCard": "Alle filer",
|
||||
"allFilesHint": "Artefakter i repositoryet",
|
||||
"archiveTag": "Arkiv",
|
||||
"storageTitle": "Lagringsbruk",
|
||||
"storageSubtitle": "Nåværende oversikt over bruk og ledig plass i repositoryet.",
|
||||
"folderUsage": "Mappebruk",
|
||||
"diskUsage": "Diskbruk",
|
||||
"totalDisk": "Total disk",
|
||||
"freeSpace": "Ledig plass",
|
||||
"activityTitle": "Nylig aktivitet",
|
||||
"activitySubtitle": "Siste operative hendelser fra backend.",
|
||||
"noActivity": "Ingen nylige hendelser å vise.",
|
||||
"avgBackupsPerRouter": "Snitt backuper / ruter",
|
||||
"activitySuccess": "Oppgave fullført",
|
||||
"activityFailure": "Trenger oppmerksomhet",
|
||||
"activityMaintenance": "Vedlikehold",
|
||||
"activityDelivery": "Levering",
|
||||
"operationsTitle": "Driftssenter",
|
||||
"operationsSubtitle": "Viktigste handlinger og levende repositoryindikatorer på ett sted.",
|
||||
"latestSnapshot": "Siste øyeblikksbilde",
|
||||
"coverageLabel": "Flåtedekning",
|
||||
"coverageHint": "Rutere med minst én backup",
|
||||
"weeklyActivityLabel": "7-dagers aktivitet",
|
||||
"weeklyActivityHint": "Nye backuper opprettet denne uken",
|
||||
"busiestRouterLabel": "Mest aktive ruter",
|
||||
"routerSnapshotsHint": "{{count}} øyeblikksbilder i repositoryet",
|
||||
"exportShareLabel": "Eksportandel",
|
||||
"activityTodayLabel": "Hendelser i dag",
|
||||
"noneLabel": "Ingen",
|
||||
"activityTodayHint": "Oppføringer opprettet i dag",
|
||||
"usedSpace": "Brukt plass",
|
||||
"storageViewCapacity": "Kapasitet",
|
||||
"storageViewCapacityHint": "Disk, repositorybruk og ledig plass vist på samme skala.",
|
||||
"storageViewMix": "Backuptyper",
|
||||
"storageViewMixHint": "Fordeling av alle kopier mellom teksteksporter og binære backuper.",
|
||||
"storageViewActivity": "7-dagers aktivitet",
|
||||
"storageViewActivityHint": "Antall nye backuper opprettet de siste sju dagene.",
|
||||
"storageViewRouters": "Topp-rutere",
|
||||
"storageViewRoutersHint": "Enheter med flest øyeblikksbilder i repositoryet.",
|
||||
"storageChartEmpty": "Det er ikke nok data til å tegne denne grafen ennå.",
|
||||
"storageSnapshotTitle": "Repository-metrikker",
|
||||
"storageSnapshotHint": "Rask oversikt over de viktigste lagrings- og backupindikatorene."
|
||||
},
|
||||
"routers": {
|
||||
"title": "Enheter",
|
||||
"detailTitle": "Enhetsdetaljer",
|
||||
"add": "Legg til enhet",
|
||||
"eyebrow": "enhetsinventar",
|
||||
"subtitle": "Administrer RouterOS- og SwitchOS-enheter og sikkerhetskopier.",
|
||||
"registeredDevices": "Registrerte enheter",
|
||||
"fleetTag": "Flåte",
|
||||
"sshPassword": "Passord",
|
||||
"passwordHint": "Passordbasert tilgang",
|
||||
"credsTag": "Tilgang",
|
||||
"sshKey": "SSH-nøkkel",
|
||||
"keyHint": "Nøkkelbasert tilgang",
|
||||
"securityTag": "Sikkerhet",
|
||||
"defaultPort": "Port 22",
|
||||
"defaultPortHint": "Standard SSH-endepunkter",
|
||||
"portTag": "Port",
|
||||
"listTitle": "Enhetsliste",
|
||||
"listSubtitle": "Felles visning for RouterOS og SwitchOS.",
|
||||
"name": "Navn",
|
||||
"endpoint": "Endepunkt",
|
||||
"access": "Tilgang",
|
||||
"routerOsTarget": "RouterOS-mål",
|
||||
"passwordMode": "Passord",
|
||||
"noPassword": "Ingen passord",
|
||||
"keyMode": "Nøkkel",
|
||||
"globalKeyMode": "Global nøkkel",
|
||||
"noKey": "Ingen nøkkel",
|
||||
"createDialogTitle": "Legg til enhet",
|
||||
"editDialogTitle": "Rediger enhet",
|
||||
"host": "Vert",
|
||||
"port": "Port",
|
||||
"sshUser": "Bruker",
|
||||
"sshPrivateKey": "SSH privat nøkkel",
|
||||
"optionalPassword": "Valgfritt passord",
|
||||
"optionalPrivateKey": "Valgfri privat nøkkel",
|
||||
"saveRouter": "Lagre enhet",
|
||||
"profileEyebrow": "ruterprofil",
|
||||
"detailSubtitle": "Enhetsoperasjoner og backuphistorikk",
|
||||
"exportOne": "Eksport",
|
||||
"binaryOne": "Binær",
|
||||
"testConnection": "Test tilkobling",
|
||||
"deleteRouter": "Slett ruter",
|
||||
"exportsLabel": "Eksporter",
|
||||
"exportsLabelHint": "Tekstbaserte øyeblikksbilder",
|
||||
"binaryLabel": "Binære backuper",
|
||||
"binaryLabelHint": "Gjenopprettingsbilder",
|
||||
"connectionLabel": "Tilkobling",
|
||||
"connectionLabelHint": "Status fra siste automatiske eller manuelle test",
|
||||
"probeTag": "Test",
|
||||
"accessTag": "Tilgang",
|
||||
"sshUserHint": "Gjeldende SSH-bruker",
|
||||
"deviceStatusTitle": "Enhetsstatus",
|
||||
"deviceStatusSubtitle": "Lagrede metadata fra siste automatiske eller manuelle tilkoblingstest.",
|
||||
"connectionStateTitle": "Tilkoblingsstatus",
|
||||
"lastTestAt": "Siste test",
|
||||
"hostname": "Vertsnavn",
|
||||
"model": "Modell",
|
||||
"version": "Versjon",
|
||||
"uptime": "Oppetid",
|
||||
"lastError": "Siste feil",
|
||||
"deviceStatusManualHint": "Automatiske kontroller bruker intervallet fra innstillingene. Manuell test er fortsatt tilgjengelig.",
|
||||
"noConnection": "Ingen lagret tilkoblingstest ennå. Kjør en manuell test eller aktiver automatiske kontroller i innstillingene.",
|
||||
"previewTitle": "Forhåndsvisning av eksport",
|
||||
"previewSubtitle": "Sist åpnet eksportfil.",
|
||||
"noPreview": "Velg en eksportfil for å se innholdet.",
|
||||
"diffTitle": "Siste diff",
|
||||
"diffSubtitle": "Forskjell mot nyeste eksport.",
|
||||
"exportsTableTitle": "Eksporter",
|
||||
"exportsTableSubtitle": "Lesbare RouterOS-øyeblikksbilder.",
|
||||
"binaryTableTitle": "Binære backuper",
|
||||
"binaryTableSubtitle": "Filer klare for gjenoppretting av enheten.",
|
||||
"summaryKeyAccess": "med nøkkelbasert tilgang",
|
||||
"summaryPasswordAccess": "med passordtilgang",
|
||||
"previewModalHint": "Sist åpnet eksport er tilgjengelig i en modal.",
|
||||
"openPreviewModal": "Åpne forhåndsvisning",
|
||||
"diffModalHint": "Sist lastede diff er tilgjengelig i en modal.",
|
||||
"openDiffModal": "Åpne diff",
|
||||
"noDiff": "Velg en eksport og kjør diff for å se siste sammenligning.",
|
||||
"routeros": "RouterOS",
|
||||
"switchos": "SwitchOS",
|
||||
"deviceType": "Enhetstype",
|
||||
"defaultCredentials": "Standard legitimasjon",
|
||||
"localCredentials": "Lokal legitimasjon",
|
||||
"noCredentials": "Ingen legitimasjon",
|
||||
"switchUserPlaceholder": "Tom = bruk innstillinger",
|
||||
"switchPasswordPlaceholder": "Tom = bruk innstillinger",
|
||||
"switchDefaultsHint": "For SwitchOS kan du la bruker og passord være tomme for å bruke standardverdier fra innstillinger.",
|
||||
"downloadSwitchBackup": "Last ned backup",
|
||||
"httpStatus": "HTTP-status",
|
||||
"serverHeader": "Server-header",
|
||||
"authMode": "Autentiseringsmodus",
|
||||
"backupEndpoint": "Backup-endepunkt",
|
||||
"backupAvailable": "Tilgjengelig",
|
||||
"backupUnavailable": "Utilgjengelig",
|
||||
"connectionSectionTitle": "Tilkoblingsprofil",
|
||||
"connectionSectionHint": "Grunnleggende enhetsidentitet og endpoint som brukes for å nå den.",
|
||||
"credentialsSectionTitle": "Tilgang og legitimasjon",
|
||||
"routerDialogSubtitle": "Sett enhetens endpoint, SSH-data og foretrukket innloggingsmetode.",
|
||||
"switchDialogSubtitle": "Sett SwitchOS-endpoint og valgfrie lokale eller delte standarddata fra innstillinger."
|
||||
},
|
||||
"files": {
|
||||
"title": "Repository",
|
||||
"eyebrow": "artefaktrepository",
|
||||
"subtitle": "Søk, sammenlign og lever backuper fra én tydelig visning.",
|
||||
"downloadZip": "Last ned ZIP",
|
||||
"visibleFiles": "Synlige filer",
|
||||
"visibleFilesHint": "Resultat av gjeldende filter",
|
||||
"liveTag": "Live",
|
||||
"selected": "Valgte",
|
||||
"selectedHint": "Klare for massehandlinger",
|
||||
"batchTag": "Batch",
|
||||
"exportsCard": "Eksporter",
|
||||
"exportsHint": "Konfigurasjonsøyeblikksbilder",
|
||||
"binaryCard": "Binære backuper",
|
||||
"binaryHint": "Gjenopprettingsbilder",
|
||||
"filtersTitle": "Filtre",
|
||||
"filtersSubtitle": "Begrens listen etter ruter, type eller nøkkelord.",
|
||||
"searchLabel": "Søk",
|
||||
"searchPlaceholder": "Søk etter fil eller ruter",
|
||||
"typeLabel": "Type",
|
||||
"routerLabel": "Enhet",
|
||||
"dateLabel": "Dato",
|
||||
"datePlaceholder": "Velg dato",
|
||||
"sortLabel": "Sorter etter",
|
||||
"orderLabel": "Rekkefølge",
|
||||
"allTypes": "Alle typer",
|
||||
"allRouters": "Alle enheter",
|
||||
"sortNewest": "Nyeste",
|
||||
"sortName": "Navn",
|
||||
"sortRouter": "Enhet",
|
||||
"sortType": "Type",
|
||||
"tableTitle": "Repositorytabell",
|
||||
"tableSubtitle": "Artefakter tilgjengelige for nedlasting, e-post og gjenoppretting.",
|
||||
"compareHint": "Velg nøyaktig to .rsc-filer for å sammenligne dem.",
|
||||
"compareSelected": "Sammenlign valgte eksporter",
|
||||
"fileColumn": "Fil",
|
||||
"typeColumn": "Type",
|
||||
"routerColumn": "Enhet",
|
||||
"createdColumn": "Opprettet",
|
||||
"actionsColumn": "Handlinger",
|
||||
"checksum": "Checksum",
|
||||
"exportType": "Eksport",
|
||||
"binaryType": "Binær backup",
|
||||
"previewDialogTitle": "Forhåndsvisning av eksport",
|
||||
"diffDialogTitle": "Eksportdiff",
|
||||
"openHtmlDiff": "Åpne HTML-diff",
|
||||
"sizeColumn": "Størrelse",
|
||||
"compareColumn": "Sammenlign",
|
||||
"compareOlder": "Eldre fil",
|
||||
"compareNewer": "Nyere fil",
|
||||
"pickOlder": "Velg eldre backup",
|
||||
"pickNewer": "Velg nyere backup",
|
||||
"compareLatestPair": "Siste par",
|
||||
"setOlder": "Sett som eldre",
|
||||
"setNewer": "Sett som nyere",
|
||||
"latestForRouter": "Ruterdiff",
|
||||
"binaryNoCompare": "Diff tilgjengelig kun for .rsc",
|
||||
"openPlainDiff": "Vis ren diff",
|
||||
"minutesAgo": "{{value}} min siden",
|
||||
"hoursAgo": "{{value}} t siden",
|
||||
"daysAgo": "{{value}} d siden",
|
||||
"compareTitle": "Sammenligning av eksporter",
|
||||
"compareSubtitle": "Velg to .rsc-filer og start diff uten å grave gjennom hele tabellen.",
|
||||
"exportPoolLabel": "eksporter klare for sammenligning",
|
||||
"compareSelectionHint": "Velg en eldre og en nyere fil",
|
||||
"compareReadySameRouter": "Par klart · ruter {{router}}",
|
||||
"compareReadyMixedRouters": "Par klart · blandede rutere"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Innstillinger",
|
||||
"eyebrow": "plattformkonfigurasjon",
|
||||
"subtitle": "Styr tidsplaner, retensjon, varsler, tilkoblingstester og delte SSH-data.",
|
||||
"testEmail": "Test e-post",
|
||||
"testPushover": "Test Pushover",
|
||||
"retentionTitle": "Retensjon",
|
||||
"retentionSubtitle": "Automatiske ryddevinduer for filer og logger.",
|
||||
"backupRetentionDays": "Dager for backupretensjon",
|
||||
"logRetentionDays": "Dager for loggretensjon",
|
||||
"retentionCron": "Retensjons-cron",
|
||||
"automationTitle": "Automatisering",
|
||||
"automationSubtitle": "Tidsplaner for eksport, binære jobber, retensjon og tilkoblingskontroller.",
|
||||
"enableAutoExport": "Aktiver automatisk eksport",
|
||||
"enableAutoExportHint": "Kjør eksportjobber med cron-reglene nedenfor.",
|
||||
"exportCron": "Eksport-cron",
|
||||
"binaryCron": "Binær-cron",
|
||||
"notificationsTitle": "Varsler",
|
||||
"notificationsSubtitle": "SMTP- og Pushover-konfigurasjon.",
|
||||
"smtpEnabled": "SMTP aktivert",
|
||||
"smtpEnabledHint": "Send varsler via SMTP-gatewayen.",
|
||||
"failuresOnly": "Kun feil",
|
||||
"failuresOnlyHint": "Begrens varsler til mislykkede jobber.",
|
||||
"smtpHost": "SMTP-vert",
|
||||
"smtpPort": "SMTP-port",
|
||||
"smtpLogin": "SMTP-innlogging",
|
||||
"smtpPassword": "SMTP-passord",
|
||||
"recipientEmail": "Mottaker e-post",
|
||||
"pushoverToken": "Pushover-token",
|
||||
"pushoverUserKey": "Pushover-brukernøkkel",
|
||||
"pushoverTokenPlaceholder": "Applikasjonstoken",
|
||||
"pushoverUserKeyPlaceholder": "Brukernøkkel",
|
||||
"sshDefaultsTitle": "Standard legitimasjon",
|
||||
"sshDefaultsSubtitle": "Delt SSH-nøkkel og standard innlogging for SwitchOS brukt på administrerte enheter.",
|
||||
"globalSshPrivateKey": "Global SSH privat nøkkel",
|
||||
"globalSshPrivateKeyPlaceholder": "Lim inn PEM- eller OpenSSH-privat nøkkel",
|
||||
"globalSshPrivateKeyHiddenPlaceholder": "Den lagrede nøkkelen er skjult. Skriv inn passordet over for å se den, eller lim inn en ny nøkkel her for å erstatte den.",
|
||||
"save": "Lagre innstillinger",
|
||||
"scheduleDisabled": "Av",
|
||||
"scheduleDaily": "Daglig",
|
||||
"scheduleWeekly": "Ukentlig",
|
||||
"scheduleCustom": "Egendefinert cron",
|
||||
"scheduleMode": "Planmodus",
|
||||
"scheduleTime": "Tid",
|
||||
"scheduleWeekday": "Ukedag",
|
||||
"weekdayMonday": "Mandag",
|
||||
"weekdayTuesday": "Tirsdag",
|
||||
"weekdayWednesday": "Onsdag",
|
||||
"weekdayThursday": "Torsdag",
|
||||
"weekdayFriday": "Fredag",
|
||||
"weekdaySaturday": "Lørdag",
|
||||
"weekdaySunday": "Søndag",
|
||||
"scheduleDisabledHint": "Jobben vil ikke kjøre automatisk.",
|
||||
"scheduleDailySummary": "Hver dag kl. {{time}}",
|
||||
"scheduleWeeklySummary": "Hver {{weekday}} kl. {{time}}",
|
||||
"scheduleCustomEmpty": "Skriv inn et egendefinert cron-uttrykk",
|
||||
"statusEnabled": "På",
|
||||
"statusDisabled": "Av",
|
||||
"noNextRun": "Ingen neste kjøring planlagt",
|
||||
"exportScheduleTitle": "Teksteksporter",
|
||||
"binaryScheduleTitle": "Binære backuper",
|
||||
"automationPlannerTitle": "Jobbplanlegger",
|
||||
"automationPlannerSubtitle": "Hver jobb har sin egen plan, så eksport, binær backup og retensjon kan kjøre i separate vinduer.",
|
||||
"automationPlannerTag": "Fleksible vinduer",
|
||||
"exportPlannerHint": "Bestem når lesbare teksteksporter skal opprettes. Av-modus stopper automatiseringen helt.",
|
||||
"binaryPlannerHint": "Separat vindu for fulle binære backuper når du trenger gjenopprettingspunkter.",
|
||||
"retentionPlannerHint": "Retensjon rydder gamle backuper og logger etter sin egen plan.",
|
||||
"connectionTestsTitle": "Automatiske tilkoblingstester",
|
||||
"connectionTestsHint": "Appen kan oppdatere ruterstatus automatisk. Sett 0 for å deaktivere automatiske tester.",
|
||||
"connectionTestIntervalMinutes": "Kontroller hver X minutt",
|
||||
"connectionTestsEverySummary": "Hvert {{minutes}}. minutt",
|
||||
"connectionTestsDisabledHint": "Automatiske tilkoblingstester er deaktivert.",
|
||||
"sshKeyHelper": "Hold den delte SSH-nøkkelen i høyre kolonne. Vis den først etter at du har bekreftet passordet til kontoen din.",
|
||||
"sshKeyStoredTag": "Lagret nøkkel",
|
||||
"sshKeyWillBeRemovedTag": "Vil bli fjernet",
|
||||
"sshRevealHint": "Gjeldende nøkkel forblir skjult til du bekrefter passordet ditt. Du kan også lime inn en ny nøkkel nedenfor for å erstatte den.",
|
||||
"revealSshPassword": "Gjeldende kontopassord",
|
||||
"revealSshPasswordPlaceholder": "Skriv inn passord for å vise nøkkelen",
|
||||
"revealSshKey": "Vis nøkkel",
|
||||
"hideSshKey": "Skjul nøkkel",
|
||||
"clearSshKey": "Tøm nøkkel",
|
||||
"sshKeyClearNotice": "Den lagrede delte SSH-nøkkelen blir fjernet når du lagrer.",
|
||||
"sshRevealPasswordRequired": "Skriv inn gjeldende passord for å vise SSH-nøkkelen.",
|
||||
"sshRevealPasswordInvalid": "Passordet som ble brukt for å vise SSH-nøkkelen er ugyldig.",
|
||||
"schedulerAutoExportLabel": "Automatiske eksporter",
|
||||
"schedulerBinaryLabel": "Binære backuper",
|
||||
"schedulerRetentionLabel": "Retensjonsrydding",
|
||||
"schedulerConnectionLabel": "Tilkoblingskontroller",
|
||||
"schedulerLogsLabel": "Loggrydding",
|
||||
"schedulerLogsDescription": "Hver 24. time",
|
||||
"schedulerCronDescription": "{{description}}",
|
||||
"schedulerInvalidCron": "Ugyldig cron-uttrykk",
|
||||
"interfaceTitle": "Grensesnittkonfigurasjon",
|
||||
"interfaceSubtitle": "Språk- og typografivalg som lagres for kontoen din.",
|
||||
"interfacePreferencesTitle": "Utseende for arbeidsområdet",
|
||||
"interfacePreferencesHint": "Velg standardspråk og skriftfamilie for hele applikasjonen.",
|
||||
"interfacePreferencesTag": "Per bruker",
|
||||
"fontFamily": "Skriftfamilie",
|
||||
"fontDefault": "Standard",
|
||||
"switchosDefaultsTitle": "Standard SwitchOS-legitimasjon",
|
||||
"switchosDefaultsHint": "Brukes når en SwitchOS-enhet ikke har lokalt brukernavn eller passord.",
|
||||
"defaultSwitchosUsername": "Standard SwitchOS-bruker",
|
||||
"defaultSwitchosPassword": "Standard SwitchOS-passord"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logger",
|
||||
"eyebrow": "driftshistorikk",
|
||||
"subtitle": "Revider de siste eksport-, gjenopprettings- og vedlikeholdshendelsene.",
|
||||
"daysPlaceholder": "dager",
|
||||
"deleteOlderThan": "Slett eldre enn",
|
||||
"entriesLabel": "Oppføringer",
|
||||
"entriesHint": "Lastede rader",
|
||||
"auditTag": "Revisjon",
|
||||
"retentionLabel": "Retensjon",
|
||||
"retentionHint": "Ryddeterskel",
|
||||
"policyTag": "Policy",
|
||||
"daysSuffix": "dager",
|
||||
"tableTitle": "Loggtabell",
|
||||
"tableSubtitle": "Kronologisk liste over operasjoner fanget av backend.",
|
||||
"timestampColumn": "Tidsstempel",
|
||||
"messageColumn": "Melding",
|
||||
"retentionInfoLabel": "Konfigurert loggretensjon"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Ferdig",
|
||||
"info": "Info",
|
||||
"error": "Feil",
|
||||
"exportPreviewLoaded": "Forhåndsvisning av eksport lastet.",
|
||||
"backupSentEmail": "Backup sendt på e-post.",
|
||||
"binaryUploaded": "Binær backup lastet opp til ruteren.",
|
||||
"backupDeleted": "Backup slettet.",
|
||||
"selectedBackupsDeleted": "Valgte backuper slettet.",
|
||||
"diffLoaded": "Diff lastet.",
|
||||
"archivePrepared": "Arkiv klargjort.",
|
||||
"exportedRouters": "Eksport fullført for {{count}} rutere.",
|
||||
"binaryCompletedRouters": "Binær backup fullført for {{count}} rutere.",
|
||||
"routerCreated": "Ruter opprettet.",
|
||||
"routerUpdated": "Ruter oppdatert.",
|
||||
"routerDeleted": "Ruter slettet.",
|
||||
"exportCreated": "Eksport opprettet.",
|
||||
"binaryCreated": "Binær backup opprettet.",
|
||||
"connectionSuccessful": "Tilkobling vellykket.",
|
||||
"connectionFailed": "Tilkoblingstesten mislyktes.",
|
||||
"settingsSaved": "Innstillinger lagret.",
|
||||
"testEmailSent": "Test-e-post sendt.",
|
||||
"testPushoverSent": "Testvarsling for Pushover sendt.",
|
||||
"logsDeletedOlderThan": "Logger eldre enn {{days}} dager slettet.",
|
||||
"passwordChanged": "Passord endret.",
|
||||
"sshKeyUnlocked": "SSH-nøkkel låst opp.",
|
||||
"settingsSaveFailed": "Kunne ikke lagre innstillingene.",
|
||||
"testEmailFailed": "Kunne ikke sende test-e-post.",
|
||||
"testPushoverFailed": "Kunne ikke sende testvarsel via Pushover.",
|
||||
"swosBetaProbeOk": "SwitchOS-tilkobling verifisert.",
|
||||
"swosBetaProbeFailed": "Kunne ikke verifisere tilgang til SwitchOS.",
|
||||
"swosBetaDownloadOk": "SwitchOS-backup lastet ned.",
|
||||
"swosBetaDownloadFailed": "Kunne ikke laste ned SwitchOS-backup."
|
||||
},
|
||||
"confirm": {
|
||||
"header": "Bekreftelse",
|
||||
"deleteBackup": "Slette denne backupfilen?",
|
||||
"deleteSelectedFiles": "Slette {{count}} valgte filer?",
|
||||
"deleteRouterWithFiles": "Slette ruteren og alle relaterte filer?",
|
||||
"deleteLogsOlderThan": "Slette logger eldre enn {{days}} dager?"
|
||||
},
|
||||
"footer": {
|
||||
"authorLabel": "Forfatter",
|
||||
"apiLabel": "API",
|
||||
"apiOnline": "online",
|
||||
"apiOffline": "offline",
|
||||
"apiChecking": "sjekker",
|
||||
"apiLatencyLabel": "API-forsinkelse",
|
||||
"apiDocs": "API-dokumentasjon",
|
||||
"apiOfflineTitle": "API-tilkoblingen er borte",
|
||||
"apiOfflineMessage": "Backend svarer ikke. Noen funksjoner kan være midlertidig utilgjengelige.",
|
||||
"retry": "Prøv igjen"
|
||||
},
|
||||
"diffConfigs": {
|
||||
"title": "Konfig-diff",
|
||||
"eyebrow": "eksportsammenligning",
|
||||
"subtitle": "Egen side for enklere sammenligning av RouterOS-konfigurasjoner.",
|
||||
"exportsCard": "Eksporter for diff",
|
||||
"exportsCardHint": ".rsc-filer i valgt område",
|
||||
"scopeCard": "Område",
|
||||
"scopeCardHint": "Valgt ruter eller hele flåten",
|
||||
"scopeTag": "Område",
|
||||
"readyCard": "Par",
|
||||
"readyCardHint": "Valgstatus for sammenligning",
|
||||
"readyTag": "Status",
|
||||
"lastDiffCard": "Siste diff",
|
||||
"lastDiffCardHint": "Sist åpnet filpar",
|
||||
"lastDiffTag": "Historikk",
|
||||
"workspaceTitle": "Sammenligningsflate",
|
||||
"workspaceSubtitle": "Velg ruter, sett eldre og nyere eksport, og åpne diff i modal.",
|
||||
"tableTitle": "Eksporter å velge fra",
|
||||
"tableSubtitle": "Rask tildeling av eldre og nyere filer med forhåndsvisning på samme side.",
|
||||
"waitingTag": "Venter",
|
||||
"noneSelected": "Ingen"
|
||||
},
|
||||
"switchosBeta": {
|
||||
"title": "SwitchOS beta",
|
||||
"eyebrow": "switchos / beta",
|
||||
"subtitle": "Egen modul for å hente SwitchOS-kopier uten å koble den til hovedlageret.",
|
||||
"betaTag": "Utestet beta",
|
||||
"summaryStandaloneValue": "Separat",
|
||||
"summaryStandaloneLabel": "Kjører utenfor hovedflyten",
|
||||
"summaryProtocolLabel": "Målprotokoll",
|
||||
"summaryArtifactLabel": "Backup-format",
|
||||
"warningTitle": "Modulstatus",
|
||||
"warningSubtitle": "Separat arbeidsløype forberedt for SwitchOS web scraping.",
|
||||
"warningHeadline": "Denne fanen er merket som en utestet beta.",
|
||||
"warningBody": "Den lagrer ikke enheter eller filer i den eksisterende RouterOS-listen. Den er ment for manuell tilgangssjekk og direkte nedlasting av SwitchOS-backup.",
|
||||
"formTitle": "Enhetsdata",
|
||||
"formSubtitle": "Oppgi switch-adresse og legitimasjonen som brukes i webgrensesnittet.",
|
||||
"label": "Filnavn-etikett",
|
||||
"labelPlaceholder": "for eksempel css326-lager",
|
||||
"host": "Vert / URL",
|
||||
"hostPlaceholder": "for eksempel 192.168.88.1 eller http://192.168.88.1",
|
||||
"port": "Port",
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"passwordPlaceholder": "La stå tomt hvis enheten ikke har passord",
|
||||
"probeButton": "Sjekk tilgang",
|
||||
"downloadButton": "Last ned backup .swb",
|
||||
"resultTitle": "Tilkoblingsresultat",
|
||||
"resultSubtitle": "Rask forhåndsvisning av enhetens svar før filen lastes ned.",
|
||||
"resultEmpty": "Sjekk tilgang først eller last ned backupen med en gang.",
|
||||
"baseUrl": "Basis-URL",
|
||||
"httpStatus": "HTTP-status",
|
||||
"authMode": "Autentiseringsmodus",
|
||||
"pageTitle": "Sidetittel",
|
||||
"serverHeader": "Server-header",
|
||||
"backupEndpoint": "Backup-endepunkt",
|
||||
"available": "Tilgjengelig",
|
||||
"unavailable": "Utilgjengelig",
|
||||
"genericError": "SwitchOS beta-operasjonen kunne ikke fullføres."
|
||||
}
|
||||
}
|
||||
541
frontend/src/assets/i18n/pl.json
Normal file
541
frontend/src/assets/i18n/pl.json
Normal file
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"app": {
|
||||
"menu": "Menu"
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "Mikrotik Backup System",
|
||||
"subtitle": "Device backup platform"
|
||||
},
|
||||
"topbar": {
|
||||
"caption": "mikrotik / control center",
|
||||
"role": "administrator",
|
||||
"languageSelector": "Wybór języka"
|
||||
},
|
||||
"common": {
|
||||
"apply": "Zastosuj",
|
||||
"reset": "Resetuj",
|
||||
"delete": "Usuń",
|
||||
"confirm": "Potwierdź",
|
||||
"cancel": "Anuluj",
|
||||
"download": "Pobierz",
|
||||
"email": "Wyślij e-mail",
|
||||
"preview": "Podgląd",
|
||||
"restore": "Przywróć",
|
||||
"actions": "Akcje",
|
||||
"open": "Otwórz",
|
||||
"edit": "Edytuj",
|
||||
"diff": "Diff",
|
||||
"ok": "OK",
|
||||
"idle": "Brak",
|
||||
"asc": "Rosnąco",
|
||||
"desc": "Malejąco",
|
||||
"enabled": "Włączone",
|
||||
"disabled": "Wyłączone",
|
||||
"failed": "Błąd"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"routers": "Urządzenia",
|
||||
"files": "Repozytorium",
|
||||
"settings": "Ustawienia",
|
||||
"logs": "Logi",
|
||||
"logout": "Wyloguj",
|
||||
"theme": "Motyw",
|
||||
"changePassword": "Zmień hasło",
|
||||
"diffConfigs": "Diff konfiguracji",
|
||||
"switchosBeta": "SwitchOS beta"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Użytkownik",
|
||||
"password": "Hasło",
|
||||
"login": "Zaloguj",
|
||||
"register": "Rejestracja",
|
||||
"confirmPassword": "Potwierdź hasło",
|
||||
"changePassword": "Zmień hasło",
|
||||
"currentPassword": "Obecne hasło",
|
||||
"newPassword": "Nowe hasło",
|
||||
"backToLogin": "Powrót do logowania",
|
||||
"backToApp": "Powrót do aplikacji",
|
||||
"loginSubtitle": "Zaloguj się, aby kontynuować.",
|
||||
"loginFailed": "Logowanie nie powiodło się",
|
||||
"accountCreated": "Konto zostało utworzone",
|
||||
"registrationFailed": "Rejestracja nie powiodła się",
|
||||
"passwordsMismatch": "Hasła nie są takie same",
|
||||
"changePasswordFailed": "Zmiana hasła nie powiodła się",
|
||||
"securityEyebrow": "konto / bezpieczeństwo",
|
||||
"changePasswordSubtitle": "Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.",
|
||||
"changePasswordCardSubtitle": "Podaj obecne hasło i ustaw nowe dane logowania.",
|
||||
"passwordPanelSubtitle": "Szybki podgląd siły hasła i zgodności pól przed zapisem.",
|
||||
"passwordStrength": "Siła hasła",
|
||||
"passwordWeak": "Słabe",
|
||||
"passwordMedium": "Średnie",
|
||||
"passwordStrong": "Mocne",
|
||||
"ruleLength": "Minimum 8 znaków",
|
||||
"ruleDigit": "Przynajmniej jedna cyfra",
|
||||
"ruleMatch": "Oba pola są zgodne",
|
||||
"passwordsMatchHint": "Nowe hasło i potwierdzenie są zgodne."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"eyebrow": "strona główna / dashboard",
|
||||
"subtitle": "Przegląd backupów, eksportów i aktywności operacyjnej w jednym miejscu.",
|
||||
"exportAll": "Eksportuj wszystko",
|
||||
"binaryAll": "Backup binarny",
|
||||
"managedRouters": "Urządzenia",
|
||||
"managedRoutersHint": "Wszystkie zarządzane urządzenia",
|
||||
"inventoryTag": "Flota",
|
||||
"exportsCard": "Eksporty",
|
||||
"exportsHint": "Czytelne snapshoty konfiguracji",
|
||||
"textTag": "Tekst",
|
||||
"binaryCard": "Backupy binarne",
|
||||
"binaryHint": "Punkty odtworzenia",
|
||||
"binaryTag": "Binary",
|
||||
"allFilesCard": "Wszystkie pliki",
|
||||
"allFilesHint": "Artefakty w repozytorium",
|
||||
"archiveTag": "Archiwum",
|
||||
"storageTitle": "Wykorzystanie przestrzeni",
|
||||
"storageSubtitle": "Bieżący podgląd zajętości repozytorium i wolnego miejsca.",
|
||||
"folderUsage": "Zajętość katalogu",
|
||||
"diskUsage": "Użycie dysku",
|
||||
"totalDisk": "Cały dysk",
|
||||
"freeSpace": "Wolne miejsce",
|
||||
"activityTitle": "Ostatnia aktywność",
|
||||
"activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.",
|
||||
"noActivity": "Brak ostatnich zdarzeń do wyświetlenia.",
|
||||
"avgBackupsPerRouter": "Śr. backupów / router",
|
||||
"activitySuccess": "Zadanie zakończone",
|
||||
"activityFailure": "Wymaga uwagi",
|
||||
"activityMaintenance": "Utrzymanie",
|
||||
"activityDelivery": "Dystrybucja",
|
||||
"operationsTitle": "Centrum operacji",
|
||||
"operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.",
|
||||
"latestSnapshot": "Najnowszy snapshot",
|
||||
"coverageLabel": "Pokrycie floty",
|
||||
"coverageHint": "Routery z co najmniej jednym backupem",
|
||||
"weeklyActivityLabel": "Aktywność 7 dni",
|
||||
"weeklyActivityHint": "Nowe backupy z ostatniego tygodnia",
|
||||
"busiestRouterLabel": "Najaktywniejszy router",
|
||||
"routerSnapshotsHint": "{{count}} snapshotów w repozytorium",
|
||||
"exportShareLabel": "Udział eksportów",
|
||||
"activityTodayLabel": "Zdarzenia dzisiaj",
|
||||
"noneLabel": "Brak",
|
||||
"activityTodayHint": "Wpisy z bieżącego dnia",
|
||||
"usedSpace": "Zajęte miejsce",
|
||||
"storageViewCapacity": "Pojemność",
|
||||
"storageViewCapacityHint": "Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.",
|
||||
"storageViewMix": "Typy backupów",
|
||||
"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",
|
||||
"storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.",
|
||||
"storageChartEmpty": "Brak danych do narysowania wykresu.",
|
||||
"storageSnapshotTitle": "Metryki repozytorium",
|
||||
"storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."
|
||||
},
|
||||
"routers": {
|
||||
"title": "Urządzenia",
|
||||
"detailTitle": "Szczegóły urządzenia",
|
||||
"add": "Dodaj urządzenie",
|
||||
"eyebrow": "inwentaryzacja urządzeń",
|
||||
"subtitle": "Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.",
|
||||
"registeredDevices": "Zarejestrowane urządzenia",
|
||||
"fleetTag": "Flota",
|
||||
"sshPassword": "Hasło",
|
||||
"passwordHint": "Dostęp hasłem",
|
||||
"credsTag": "Dostęp",
|
||||
"sshKey": "Klucz SSH",
|
||||
"keyHint": "Dostęp kluczem",
|
||||
"securityTag": "Bezpieczeństwo",
|
||||
"defaultPort": "Port 22",
|
||||
"defaultPortHint": "Standardowe endpointy SSH",
|
||||
"portTag": "Port",
|
||||
"listTitle": "Lista urządzeń",
|
||||
"listSubtitle": "Wspólny widok RouterOS i SwitchOS.",
|
||||
"name": "Nazwa",
|
||||
"endpoint": "Endpoint",
|
||||
"access": "Dostęp",
|
||||
"routerOsTarget": "Cel RouterOS",
|
||||
"passwordMode": "Hasło",
|
||||
"noPassword": "Bez hasła",
|
||||
"keyMode": "Klucz",
|
||||
"globalKeyMode": "Klucz globalny",
|
||||
"noKey": "Bez klucza",
|
||||
"createDialogTitle": "Dodaj urządzenie",
|
||||
"editDialogTitle": "Edytuj urządzenie",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"sshUser": "Użytkownik",
|
||||
"sshPrivateKey": "Klucz prywatny SSH",
|
||||
"optionalPassword": "Opcjonalne hasło",
|
||||
"optionalPrivateKey": "Opcjonalny klucz prywatny",
|
||||
"saveRouter": "Zapisz urządzenie",
|
||||
"profileEyebrow": "profil routera",
|
||||
"detailSubtitle": "Operacje urządzenia i historia backupów",
|
||||
"exportOne": "Eksport",
|
||||
"binaryOne": "Backup",
|
||||
"testConnection": "Test połączenia",
|
||||
"deleteRouter": "Usuń router",
|
||||
"exportsLabel": "Eksporty",
|
||||
"exportsLabelHint": "Tekstowe snapshoty",
|
||||
"binaryLabel": "Backupy binarne",
|
||||
"binaryLabelHint": "Obrazy odzyskiwania",
|
||||
"connectionLabel": "Połączenie",
|
||||
"connectionLabelHint": "Status z ostatniego automatycznego lub ręcznego testu połączenia",
|
||||
"probeTag": "Test",
|
||||
"accessTag": "Dostęp",
|
||||
"sshUserHint": "Efektywny login urządzenia",
|
||||
"deviceStatusTitle": "Status urządzenia",
|
||||
"deviceStatusSubtitle": "Zapisane metadane z ostatniego automatycznego lub ręcznego testu połączenia.",
|
||||
"hostname": "Hostname",
|
||||
"model": "Model",
|
||||
"version": "Wersja",
|
||||
"uptime": "Uptime",
|
||||
"noConnection": "Brak zapisanego testu połączenia. Uruchom test ręczny albo włącz testy automatyczne w ustawieniach.",
|
||||
"previewTitle": "Podgląd eksportu",
|
||||
"previewSubtitle": "Ostatnio otwarty plik eksportu.",
|
||||
"noPreview": "Wybierz plik eksportu, aby zobaczyć jego zawartość.",
|
||||
"diffTitle": "Ostatni diff",
|
||||
"diffSubtitle": "Różnice względem najnowszego eksportu.",
|
||||
"exportsTableTitle": "Eksporty",
|
||||
"exportsTableSubtitle": "Czytelne snapshoty RouterOS.",
|
||||
"binaryTableTitle": "Backupy binarne",
|
||||
"binaryTableSubtitle": "Pliki binarne i kopie SwitchOS.",
|
||||
"summaryKeyAccess": "z dostępem kluczem",
|
||||
"summaryPasswordAccess": "z dostępem hasłem",
|
||||
"connectionStateTitle": "Stan połączenia",
|
||||
"lastTestAt": "Ostatni test",
|
||||
"lastError": "Ostatni błąd",
|
||||
"deviceStatusManualHint": "Automatyczne testy używają interwału z ustawień. Ręczny test nadal jest dostępny.",
|
||||
"previewModalHint": "Ostatnio otwarty eksport jest dostępny w modalu.",
|
||||
"openPreviewModal": "Otwórz podgląd",
|
||||
"diffModalHint": "Ostatnio załadowany diff jest dostępny w modalu.",
|
||||
"openDiffModal": "Otwórz diff",
|
||||
"noDiff": "Wybierz eksport i uruchom diff, aby zobaczyć ostatnie porównanie.",
|
||||
"routeros": "RouterOS",
|
||||
"switchos": "SwitchOS",
|
||||
"deviceType": "Typ urządzenia",
|
||||
"defaultCredentials": "Domyślne dane",
|
||||
"localCredentials": "Lokalne dane",
|
||||
"noCredentials": "Brak danych",
|
||||
"switchUserPlaceholder": "Puste = z ustawień",
|
||||
"switchPasswordPlaceholder": "Puste = z ustawień",
|
||||
"switchDefaultsHint": "Dla SwitchOS możesz zostawić użytkownika i hasło puste, aby użyć wartości domyślnych z ustawień.",
|
||||
"downloadSwitchBackup": "Pobierz backup",
|
||||
"httpStatus": "Status HTTP",
|
||||
"serverHeader": "Nagłówek Server",
|
||||
"authMode": "Tryb autoryzacji",
|
||||
"backupEndpoint": "Endpoint backupu",
|
||||
"backupAvailable": "Dostępny",
|
||||
"backupUnavailable": "Niedostępny",
|
||||
"connectionSectionTitle": "Profil połączenia",
|
||||
"connectionSectionHint": "Podstawowa tożsamość urządzenia i endpoint używany do połączenia.",
|
||||
"credentialsSectionTitle": "Dostęp i poświadczenia",
|
||||
"routerDialogSubtitle": "Ustaw adres urządzenia, dane dostępu SSH i preferowaną metodę logowania.",
|
||||
"switchDialogSubtitle": "Ustaw endpoint SwitchOS i opcjonalne poświadczenia lokalne lub domyślne z ustawień."
|
||||
},
|
||||
"files": {
|
||||
"title": "Repozytorium",
|
||||
"eyebrow": "repozytorium artefaktów",
|
||||
"subtitle": "Szukaj, porównuj i udostępniaj backupy z jednego czytelnego widoku.",
|
||||
"downloadZip": "Pobierz ZIP",
|
||||
"visibleFiles": "Widoczne pliki",
|
||||
"visibleFilesHint": "Wynik bieżącego filtra",
|
||||
"liveTag": "Live",
|
||||
"selected": "Zaznaczone",
|
||||
"selectedHint": "Gotowe do akcji zbiorczych",
|
||||
"batchTag": "Batch",
|
||||
"exportsCard": "Eksporty",
|
||||
"exportsHint": "Snapshoty konfiguracji",
|
||||
"binaryCard": "Backupy binarne",
|
||||
"binaryHint": "Obrazy odzyskiwania",
|
||||
"filtersTitle": "Filtry",
|
||||
"filtersSubtitle": "Zawęź listę plików po routerze, typie lub słowie kluczowym.",
|
||||
"searchLabel": "Szukaj",
|
||||
"searchPlaceholder": "Szukaj po pliku lub routerze",
|
||||
"typeLabel": "Typ",
|
||||
"routerLabel": "Urządzenie",
|
||||
"dateLabel": "Data",
|
||||
"datePlaceholder": "Wybierz datę",
|
||||
"sortLabel": "Sortowanie",
|
||||
"orderLabel": "Kolejność",
|
||||
"allTypes": "Wszystkie typy",
|
||||
"allRouters": "Wszystkie urządzenia",
|
||||
"sortNewest": "Najnowsze",
|
||||
"sortName": "Nazwa",
|
||||
"sortRouter": "Urządzenie",
|
||||
"sortType": "Typ",
|
||||
"tableTitle": "Tabela repozytorium",
|
||||
"tableSubtitle": "Artefakty dostępne do pobrania, wysyłki e-mail i przywracania.",
|
||||
"compareHint": "Zaznacz dokładnie dwa pliki .rsc, aby je porównać.",
|
||||
"compareSelected": "Porównaj zaznaczone eksporty",
|
||||
"fileColumn": "Plik",
|
||||
"typeColumn": "Typ",
|
||||
"routerColumn": "Urządzenie",
|
||||
"createdColumn": "Utworzono",
|
||||
"actionsColumn": "Akcje",
|
||||
"checksum": "Checksum",
|
||||
"exportType": "Eksport",
|
||||
"binaryType": "Backup binarny",
|
||||
"previewDialogTitle": "Podgląd eksportu",
|
||||
"diffDialogTitle": "Diff eksportów",
|
||||
"openHtmlDiff": "Otwórz HTML diff",
|
||||
"sizeColumn": "Rozmiar",
|
||||
"compareColumn": "Porównanie",
|
||||
"compareOlder": "Starszy plik",
|
||||
"compareNewer": "Nowszy plik",
|
||||
"pickOlder": "Wybierz starszy backup",
|
||||
"pickNewer": "Wybierz nowszy backup",
|
||||
"compareLatestPair": "Najnowsza para",
|
||||
"setOlder": "Ustaw jako starszy",
|
||||
"setNewer": "Ustaw jako nowszy",
|
||||
"latestForRouter": "Diff dla routera",
|
||||
"binaryNoCompare": "Diff tylko dla .rsc",
|
||||
"openPlainDiff": "Pokaż diff tekstowy",
|
||||
"minutesAgo": "{{value}} min temu",
|
||||
"hoursAgo": "{{value}} godz. temu",
|
||||
"daysAgo": "{{value}} dni temu",
|
||||
"compareTitle": "Porównanie eksportów",
|
||||
"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"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
"eyebrow": "konfiguracja platformy",
|
||||
"subtitle": "Skonfiguruj harmonogramy, retencję, powiadomienia, testy połączeń i współdzielone dane SSH.",
|
||||
"testEmail": "Test e-mail",
|
||||
"testPushover": "Test Pushover",
|
||||
"retentionTitle": "Retencja",
|
||||
"retentionSubtitle": "Okna automatycznego czyszczenia plików i logów.",
|
||||
"backupRetentionDays": "Dni retencji backupów",
|
||||
"logRetentionDays": "Dni retencji logów",
|
||||
"retentionCron": "Cron retencji",
|
||||
"automationTitle": "Automatyzacja",
|
||||
"automationSubtitle": "Harmonogramy eksportów, backupów binarnych, retencji i testów połączeń.",
|
||||
"enableAutoExport": "Włącz auto eksport",
|
||||
"enableAutoExportHint": "Uruchamiaj zadania eksportu według reguł cron poniżej.",
|
||||
"exportCron": "Cron eksportu",
|
||||
"binaryCron": "Cron backupu binarnego",
|
||||
"notificationsTitle": "Powiadomienia",
|
||||
"notificationsSubtitle": "Konfiguracja dostarczania SMTP i Pushover.",
|
||||
"smtpEnabled": "Włącz SMTP",
|
||||
"smtpEnabledHint": "Wysyłaj powiadomienia przez bramkę SMTP.",
|
||||
"failuresOnly": "Tylko błędy",
|
||||
"failuresOnlyHint": "Ogranicz alerty do nieudanych zadań.",
|
||||
"smtpHost": "Host SMTP",
|
||||
"smtpPort": "Port SMTP",
|
||||
"smtpLogin": "Login SMTP",
|
||||
"smtpPassword": "Hasło SMTP",
|
||||
"recipientEmail": "E-mail odbiorcy",
|
||||
"pushoverToken": "Token Pushover",
|
||||
"pushoverUserKey": "Klucz użytkownika Pushover",
|
||||
"pushoverTokenPlaceholder": "Token aplikacji",
|
||||
"pushoverUserKeyPlaceholder": "Klucz użytkownika",
|
||||
"sshDefaultsTitle": "Domyślne Poświadczenia",
|
||||
"sshDefaultsSubtitle": "Wspólny klucz SSH oraz domyślne logowanie SwitchOS używane przez urządzenia.",
|
||||
"globalSshPrivateKey": "Globalny klucz prywatny SSH",
|
||||
"globalSshPrivateKeyPlaceholder": "Wklej klucz prywatny PEM lub OpenSSH",
|
||||
"save": "Zapisz ustawienia",
|
||||
"scheduleDisabled": "Wyłączony",
|
||||
"scheduleDaily": "Codziennie",
|
||||
"scheduleWeekly": "Co tydzień",
|
||||
"scheduleCustom": "Własny cron",
|
||||
"scheduleMode": "Tryb harmonogramu",
|
||||
"scheduleTime": "Godzina",
|
||||
"scheduleWeekday": "Dzień tygodnia",
|
||||
"weekdayMonday": "Poniedziałek",
|
||||
"weekdayTuesday": "Wtorek",
|
||||
"weekdayWednesday": "Środa",
|
||||
"weekdayThursday": "Czwartek",
|
||||
"weekdayFriday": "Piątek",
|
||||
"weekdaySaturday": "Sobota",
|
||||
"weekdaySunday": "Niedziela",
|
||||
"scheduleDisabledHint": "Zadanie nie będzie uruchamiane automatycznie.",
|
||||
"scheduleDailySummary": "Codziennie o {{time}}",
|
||||
"scheduleWeeklySummary": "Co {{weekday}} o {{time}}",
|
||||
"scheduleCustomEmpty": "Wpisz własny cron",
|
||||
"statusEnabled": "Aktywny",
|
||||
"statusDisabled": "Wyłączony",
|
||||
"noNextRun": "Brak zaplanowanego uruchomienia",
|
||||
"exportScheduleTitle": "Eksporty tekstowe",
|
||||
"binaryScheduleTitle": "Backupy binarne",
|
||||
"automationPlannerTitle": "Planer zadań",
|
||||
"automationPlannerSubtitle": "Każde zadanie ma osobny harmonogram, więc możesz osobno ustawić eksport, backup binarny i retencję.",
|
||||
"automationPlannerTag": "Elastyczne okna",
|
||||
"exportPlannerHint": "Ustaw kiedy mają powstawać czytelne eksporty tekstowe. Tryb Wyłączony całkowicie zatrzymuje automat.",
|
||||
"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 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.",
|
||||
"sshKeyHelper": "Wspólny klucz SSH jest po prawej stronie. Podejrzenie wymaga potwierdzenia hasłem do konta.",
|
||||
"sshKeyStoredTag": "Klucz zapisany",
|
||||
"sshKeyWillBeRemovedTag": "Do usunięcia",
|
||||
"sshRevealHint": "Bieżący klucz pozostaje ukryty, dopóki nie potwierdzisz hasła. Możesz też wkleić nowy klucz poniżej, aby go podmienić.",
|
||||
"revealSshPassword": "Aktualne hasło do konta",
|
||||
"revealSshPasswordPlaceholder": "Wpisz hasło, aby podejrzeć klucz",
|
||||
"revealSshKey": "Pokaż klucz",
|
||||
"hideSshKey": "Ukryj klucz",
|
||||
"clearSshKey": "Wyczyść klucz",
|
||||
"sshKeyClearNotice": "Zapisany wspólny klucz SSH zostanie usunięty po zapisaniu zmian.",
|
||||
"globalSshPrivateKeyHiddenPlaceholder": "Zapisany klucz jest ukryty. Wpisz hasło powyżej, aby go zobaczyć, albo wklej tutaj nowy klucz, aby go podmienić.",
|
||||
"sshRevealPasswordRequired": "Wpisz aktualne hasło, aby podejrzeć klucz SSH.",
|
||||
"sshRevealPasswordInvalid": "Hasło użyte do podejrzenia klucza SSH jest nieprawidłowe.",
|
||||
"schedulerAutoExportLabel": "Automatyczne eksporty",
|
||||
"schedulerBinaryLabel": "Backupy binarne",
|
||||
"schedulerRetentionLabel": "Czyszczenie retencji",
|
||||
"schedulerConnectionLabel": "Testy połączeń",
|
||||
"schedulerLogsLabel": "Czyszczenie logów",
|
||||
"schedulerLogsDescription": "Co 24 godziny",
|
||||
"schedulerCronDescription": "{{description}}",
|
||||
"schedulerInvalidCron": "Nieprawidłowe wyrażenie cron",
|
||||
"interfaceTitle": "Konfiguracja interfejsu",
|
||||
"interfaceSubtitle": "Preferencje języka i typografii zapisywane dla Twojego konta.",
|
||||
"interfacePreferencesTitle": "Wygląd przestrzeni roboczej",
|
||||
"interfacePreferencesHint": "Wybierz domyślny język i rodzinę fontów dla całej aplikacji.",
|
||||
"interfacePreferencesTag": "Per-user",
|
||||
"fontFamily": "Rodzina fontów",
|
||||
"fontDefault": "Domyślna",
|
||||
"switchosDefaultsTitle": "Domyślne dane SwitchOS",
|
||||
"switchosDefaultsHint": "Używane, gdy urządzenie SwitchOS nie ma własnego loginu lub hasła.",
|
||||
"defaultSwitchosUsername": "Domyślny użytkownik SwitchOS",
|
||||
"defaultSwitchosPassword": "Domyślne hasło SwitchOS"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logi",
|
||||
"eyebrow": "historia operacyjna",
|
||||
"subtitle": "Przeglądaj ostatnie zdarzenia eksportu, przywracania i utrzymania.",
|
||||
"daysPlaceholder": "dni",
|
||||
"deleteOlderThan": "Usuń starsze niż",
|
||||
"entriesLabel": "Wpisy",
|
||||
"entriesHint": "Załadowane rekordy",
|
||||
"auditTag": "Audyt",
|
||||
"retentionLabel": "Retencja",
|
||||
"retentionHint": "Próg czyszczenia",
|
||||
"policyTag": "Polityka",
|
||||
"daysSuffix": "dni",
|
||||
"tableTitle": "Tabela logów",
|
||||
"tableSubtitle": "Chronologiczna lista operacji zapisanych przez backend.",
|
||||
"timestampColumn": "Czas",
|
||||
"messageColumn": "Komunikat",
|
||||
"retentionInfoLabel": "Ustawiona retencja logów"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Gotowe",
|
||||
"info": "Informacja",
|
||||
"error": "Błąd",
|
||||
"exportPreviewLoaded": "Załadowano podgląd eksportu.",
|
||||
"backupSentEmail": "Backup został wysłany e-mailem.",
|
||||
"binaryUploaded": "Backup binarny został wysłany na router.",
|
||||
"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.",
|
||||
"exportCreated": "Eksport został utworzony.",
|
||||
"binaryCreated": "Backup binarny został utworzony.",
|
||||
"connectionSuccessful": "Połączenie zakończone powodzeniem.",
|
||||
"settingsSaved": "Ustawienia zostały zapisane.",
|
||||
"testEmailSent": "Wysłano testowy e-mail.",
|
||||
"testPushoverSent": "Wysłano testowe powiadomienie Pushover.",
|
||||
"logsDeletedOlderThan": "Usunięto logi starsze niż {{days}} dni.",
|
||||
"passwordChanged": "Hasło zostało zmienione.",
|
||||
"connectionFailed": "Test połączenia nie powiódł się.",
|
||||
"sshKeyUnlocked": "Klucz SSH został odblokowany.",
|
||||
"settingsSaveFailed": "Nie udało się zapisać ustawień.",
|
||||
"testEmailFailed": "Nie udało się wysłać testowego e-maila.",
|
||||
"testPushoverFailed": "Nie udało się wysłać testowego powiadomienia Pushover.",
|
||||
"swosBetaProbeOk": "Połączenie ze SwitchOS zostało sprawdzone.",
|
||||
"swosBetaProbeFailed": "Nie udało się sprawdzić dostępu do SwitchOS.",
|
||||
"swosBetaDownloadOk": "Backup SwitchOS został pobrany.",
|
||||
"swosBetaDownloadFailed": "Nie udało się pobrać backupu SwitchOS."
|
||||
},
|
||||
"confirm": {
|
||||
"header": "Potwierdzenie",
|
||||
"deleteBackup": "Usunąć ten plik backupu?",
|
||||
"deleteSelectedFiles": "Usunąć {{count}} zaznaczonych plików?",
|
||||
"deleteRouterWithFiles": "Usunąć router i wszystkie powiązane pliki?",
|
||||
"deleteLogsOlderThan": "Usunąć logi starsze niż {{days}} dni?"
|
||||
},
|
||||
"footer": {
|
||||
"authorLabel": "Autor",
|
||||
"apiLabel": "API",
|
||||
"apiOnline": "online",
|
||||
"apiOffline": "offline",
|
||||
"apiChecking": "sprawdzanie",
|
||||
"apiLatencyLabel": "Odpowiedź API",
|
||||
"apiDocs": "Docs API",
|
||||
"apiOfflineTitle": "Brak połączenia z API",
|
||||
"apiOfflineMessage": "Backend nie odpowiada. Część funkcji może być chwilowo niedostępna.",
|
||||
"retry": "Ponów"
|
||||
},
|
||||
"diffConfigs": {
|
||||
"title": "Diff konfiguracji",
|
||||
"eyebrow": "porównanie eksportów",
|
||||
"subtitle": "Dedykowany widok do wygodnego porównywania konfiguracji RouterOS.",
|
||||
"exportsCard": "Eksporty do diffu",
|
||||
"exportsCardHint": "Pliki .rsc w bieżącym zakresie",
|
||||
"scopeCard": "Zakres",
|
||||
"scopeCardHint": "Wybrany router lub cała flota",
|
||||
"scopeTag": "Zakres",
|
||||
"readyCard": "Para",
|
||||
"readyCardHint": "Stan wyboru do porównania",
|
||||
"readyTag": "Stan",
|
||||
"lastDiffCard": "Ostatni diff",
|
||||
"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.",
|
||||
"tableTitle": "Eksporty do wyboru",
|
||||
"tableSubtitle": "Szybkie przypisanie starszego i nowszego pliku oraz podgląd bez opuszczania strony.",
|
||||
"waitingTag": "Czeka",
|
||||
"noneSelected": "Brak"
|
||||
},
|
||||
"switchosBeta": {
|
||||
"title": "SwitchOS beta",
|
||||
"eyebrow": "switchos / wersja beta",
|
||||
"subtitle": "Osobny moduł do pobierania kopii urządzeń SwitchOS bez integracji z głównym repozytorium.",
|
||||
"betaTag": "Nietestowane beta",
|
||||
"summaryStandaloneValue": "Osobno",
|
||||
"summaryStandaloneLabel": "Działa poza głównym obiegiem",
|
||||
"summaryProtocolLabel": "Protokół docelowy",
|
||||
"summaryArtifactLabel": "Format kopii",
|
||||
"warningTitle": "Status modułu",
|
||||
"warningSubtitle": "To osobna ścieżka robocza przygotowana pod scraping WWW dla SwitchOS.",
|
||||
"warningHeadline": "Ta zakładka jest oznaczona jako nietestowana wersja beta.",
|
||||
"warningBody": "Nie zapisuje urządzeń ani plików do istniejącej listy RouterOS. Służy do ręcznego sprawdzenia dostępu i pobrania pliku backupu SwitchOS.",
|
||||
"formTitle": "Dane urządzenia",
|
||||
"formSubtitle": "Wprowadź adres przełącznika i dane logowania do panelu WWW.",
|
||||
"label": "Etykieta pliku",
|
||||
"labelPlaceholder": "np. css326-magazyn",
|
||||
"host": "Host / URL",
|
||||
"hostPlaceholder": "np. 192.168.88.1 albo http://192.168.88.1",
|
||||
"port": "Port",
|
||||
"username": "Użytkownik",
|
||||
"password": "Hasło",
|
||||
"passwordPlaceholder": "Puste, jeśli urządzenie nie ma hasła",
|
||||
"probeButton": "Sprawdź dostęp",
|
||||
"downloadButton": "Pobierz backup .swb",
|
||||
"resultTitle": "Wynik połączenia",
|
||||
"resultSubtitle": "Podgląd odpowiedzi urządzenia przed pobraniem pliku.",
|
||||
"resultEmpty": "Najpierw sprawdź dostęp do urządzenia albo od razu pobierz backup.",
|
||||
"baseUrl": "Adres bazowy",
|
||||
"httpStatus": "Kod HTTP",
|
||||
"authMode": "Tryb autoryzacji",
|
||||
"pageTitle": "Tytuł strony",
|
||||
"serverHeader": "Nagłówek serwera",
|
||||
"backupEndpoint": "Endpoint backupu",
|
||||
"available": "Dostępny",
|
||||
"unavailable": "Niedostępny",
|
||||
"genericError": "Nie udało się wykonać operacji SwitchOS beta."
|
||||
}
|
||||
}
|
||||
0
frontend/src/favicon.ico
Normal file
0
frontend/src/favicon.ico
Normal file
22
frontend/src/index.html
Normal file
22
frontend/src/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Mikrotik Backup System</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var mode = localStorage.getItem('routeros_theme') || 'dark';
|
||||
if (mode === 'dark') {
|
||||
document.documentElement.classList.add('dark-theme');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/src/main.ts
Normal file
40
frontend/src/main.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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 { 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';
|
||||
|
||||
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
|
||||
]
|
||||
}).catch((err) => console.error(err));
|
||||
4
frontend/src/styles.css
Normal file
4
frontend/src/styles.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './styles/pages.css';
|
||||
@import './styles/layout.css';
|
||||
@import './styles/auth.css';
|
||||
@import './styles/dashboard.css';
|
||||
165
frontend/src/styles/auth.css
Normal file
165
frontend/src/styles/auth.css
Normal file
@@ -0,0 +1,165 @@
|
||||
.auth-toolbar {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
z-index: 130;
|
||||
}
|
||||
|
||||
.auth-toolbar__btn.p-button {
|
||||
min-width: 2.6rem;
|
||||
height: 2.4rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-color: var(--border-color);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, transparent);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.app-auth-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.app-auth-view__content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.app-auth-view__content > * {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.auth-shell,
|
||||
.auth-shell--login,
|
||||
.auth-shell--compact {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
width: 100%;
|
||||
min-height: min(100%, 720px);
|
||||
padding: clamp(4.5rem, 8vh, 5.5rem) 0 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-card,
|
||||
.app-auth-view__content .auth-card--wide {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.2rem;
|
||||
padding: 2rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, var(--surface-1) 0%, var(--surface-0) 100%);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.auth-card__header {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.auth-card__header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-card__header p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form--grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auth-form--grid > .form-field,
|
||||
.auth-form--grid > small,
|
||||
.auth-form--grid > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-card__actions > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-soft);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: var(--text-main);
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
.p-button.auth-primary-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.layout-footer--auth {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.app-auth-view__content {
|
||||
align-items: start;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-shell,
|
||||
.auth-shell--login,
|
||||
.auth-shell--compact {
|
||||
min-height: 0;
|
||||
padding: 4.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.auth-form--grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-card__actions--split {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.auth-toolbar {
|
||||
top: 0.9rem;
|
||||
right: 0.9rem;
|
||||
}
|
||||
}
|
||||
187
frontend/src/styles/dashboard.css
Normal file
187
frontend/src/styles/dashboard.css
Normal file
@@ -0,0 +1,187 @@
|
||||
.dashboard-focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-block {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-block--pie {
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-focus-pie {
|
||||
width: min(100%, 240px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-focus-pie__inner {
|
||||
width: 68%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-focus-pie__inner strong {
|
||||
font-family: var(--font-title);
|
||||
font-size: clamp(1.4rem, 2.6vw, 2rem);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-focus-pie__inner span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-focus-summary {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-summary__lead {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dashboard-focus-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-metric {
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-metric span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-focus-metric strong {
|
||||
font-family: var(--font-title);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-focus-activity {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-activity__summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-focus-activity__summary p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.dashboard-focus-activity__summary strong {
|
||||
font-family: var(--font-title);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dashboard-focus-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
align-items: end;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.dashboard-focus-column {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.dashboard-focus-column small,
|
||||
.dashboard-focus-column span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-column strong {
|
||||
font-family: var(--font-title);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-column__track {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
padding: 0.6rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-1) 90%, transparent), color-mix(in srgb, var(--surface-0) 94%, transparent));
|
||||
}
|
||||
|
||||
.dashboard-focus-column__track span {
|
||||
width: min(36px, 100%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 84%, white 8%), color-mix(in srgb, var(--blue) 82%, white 6%));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-focus-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-focus-grid,
|
||||
.dashboard-focus-block--pie {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-focus-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-focus-columns {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.dashboard-focus-column__track {
|
||||
min-height: 120px;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
195
frontend/src/styles/layout.css
Normal file
195
frontend/src/styles/layout.css
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
.layout-shell {
|
||||
min-height: 100vh;
|
||||
padding-left: var(--sidebar-width);
|
||||
transition: padding-left 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-shell--collapsed {
|
||||
padding-left: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
.layout-shell--collapsed .layout-sidebar {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--sidebar-width);
|
||||
z-index: 100;
|
||||
transition: width 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-sidebar app-sidebar {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem 1.2rem;
|
||||
padding: 0 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.layout-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
z-index: 95;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-overlay.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 90;
|
||||
min-height: 68px;
|
||||
padding: 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(17, 20, 23, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark-theme .topbar {
|
||||
border-bottom-color: rgba(146, 170, 194, 0.12);
|
||||
background: rgba(23, 33, 43, 0.74);
|
||||
}
|
||||
|
||||
.topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar__lang-picker,
|
||||
.auth-toolbar__select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar__lang-picker::after,
|
||||
.auth-toolbar__select-wrap::after {
|
||||
content: '\e902';
|
||||
font-family: 'primeicons';
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--text-soft);
|
||||
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: 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.03);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
body.dark-theme .topbar__lang-select option,
|
||||
body.dark-theme .auth-toolbar__select option {
|
||||
color: #d6e0e8;
|
||||
background: #1c2631;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.layout-shell,
|
||||
.layout-shell--collapsed {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: min(88vw, var(--sidebar-width));
|
||||
}
|
||||
|
||||
.layout-sidebar.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.layout-content,
|
||||
.layout-footer {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar__right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
3817
frontend/src/styles/pages.css
Normal file
3817
frontend/src/styles/pages.css
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/tsconfig.app.json
Normal file
9
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user