first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 11:39:46 +02:00
commit 3da6c2832c
125 changed files with 30111 additions and 0 deletions

View 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>

View 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';
}
}

View 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: '' }
];

View 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;
};

View 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;
};

View 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}` } }));
};

View 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;
});
}
}

View 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) {}
}

View 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');
}
}

View 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"
};
}
}
}

View 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;
}
}

View 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);
}
}

View 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'));
});
}
}

View 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);
}
}

View File

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

View File

@@ -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>

View File

@@ -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;
}
});
}
}

View 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>

View 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;
}
});
}
}

View 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>

View 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;
}
});
}
}

View File

@@ -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>

View 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;
}
}

View File

@@ -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>

View File

@@ -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];
}
}

View 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>

View 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);
}
}

View 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>

View 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;
}
});
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View 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>

View 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 });
}
}

View 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>

View 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;
});
}
}

View File

@@ -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>

View 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');
}
}

View 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>

View 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);
}
}

View 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>

View 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>();
}

View 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>

View 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);
}
}

View 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>

View 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 = '';
}

View 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>

View 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 = '';
}

View 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>

View 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;
}
}