This repository has been archived on 2026-04-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
routeros_backup_next/patch_routeros.py
Mateusz Gruszczyński ff7dbcb4e4 first commit
2026-04-12 21:26:12 +02:00

1208 lines
43 KiB
Python

from pathlib import Path
import json
root = Path('/mnt/data/work_routeros3/frontend/src')
(root / 'app/features/dashboard/dashboard-page.component.html').write_text('''<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="warning" 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-grid dashboard-grid--feature" *ngIf="data">
<app-section-card [title]="'dashboard.storageTitle' | translate" [subtitle]="'dashboard.storageSubtitle' | translate">
<div class="storage-panel storage-panel--enhanced">
<div class="storage-panel__visual storage-panel__visual--hero">
<div class="storage-panel__toolbar">
<div class="storage-view-switch" role="tablist" [attr.aria-label]="'dashboard.storageTitle' | translate">
<button
type="button"
class="storage-view-switch__btn"
*ngFor="let option of storageViewOptions"
[class.is-active]="storageView === option.value"
(click)="setStorageView(option.value)"
>
<i [class]="option.icon"></i>
<span>{{ option.label | translate }}</span>
</button>
</div>
<div class="storage-panel__eyebrow">{{ storageViewDescriptionKey | translate }}</div>
</div>
<div class="storage-stage" [ngSwitch]="storageView">
<div *ngSwitchCase="'overview'" class="storage-stage storage-stage--overview">
<div class="storage-ring-panel">
<div class="storage-ring" [style.background]="storageRingBackground">
<div class="storage-ring__inner">
<strong>{{ formatPercent(usedPercent) }}</strong>
<span>{{ 'dashboard.diskUsage' | translate }}</span>
</div>
</div>
<div class="storage-legend">
<div class="storage-legend__item" *ngFor="let item of storageOverviewLegend" [attr.data-tone]="item.tone">
<span>{{ item.label | translate }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="storage-overview-side">
<div class="storage-callout storage-callout--accent">
<span>{{ 'dashboard.diskUsed' | translate }}</span>
<strong>{{ formatBytes(storageUsedBytes) }}</strong>
<small>{{ 'dashboard.storageInsightUsage' | translate }}</small>
</div>
<div class="storage-bars storage-bars--enhanced storage-bars--stacked">
<div class="storage-bars__item">
<div class="storage-bars__meta">
<span>{{ 'dashboard.folderUsage' | translate }}</span>
<strong>{{ formatPercent(repositorySharePercent) }}</strong>
</div>
<div class="storage-bars__track"><span [style.width.%]="repositorySharePercent"></span></div>
</div>
<div class="storage-bars__item">
<div class="storage-bars__meta">
<span>{{ 'dashboard.freeSpace' | translate }}</span>
<strong>{{ formatPercent(freePercent) }}</strong>
</div>
<div class="storage-bars__track storage-bars__track--success"><span [style.width.%]="freePercent"></span></div>
</div>
</div>
<div class="storage-mini-grid">
<div class="storage-mini-card">
<span>{{ 'dashboard.exportShareLabel' | translate }}</span>
<strong>{{ exportsSharePercent }}%</strong>
</div>
<div class="storage-mini-card">
<span>{{ 'dashboard.activityTodayLabel' | translate }}</span>
<strong>{{ activityToday }}</strong>
</div>
</div>
</div>
</div>
<div *ngSwitchCase="'composition'" class="storage-stage storage-stage--composition">
<div class="storage-composition-card">
<div class="storage-composition-list">
<div class="storage-composition-row" *ngFor="let row of storageCompositionRows" [attr.data-tone]="row.tone">
<div class="storage-composition-row__meta">
<span>{{ row.label | translate }}</span>
<strong>{{ row.value }}</strong>
</div>
<div class="storage-composition-row__track">
<span [style.width.%]="row.percent"></span>
</div>
<small>{{ formatPercent(row.percent) }}</small>
</div>
</div>
</div>
<div class="storage-mini-grid storage-mini-grid--triple">
<div class="storage-mini-card">
<span>{{ 'dashboard.exportShareLabel' | translate }}</span>
<strong>{{ exportsSharePercent }}%</strong>
</div>
<div class="storage-mini-card">
<span>{{ 'dashboard.binaryCard' | translate }}</span>
<strong>{{ binarySharePercent }}%</strong>
</div>
<div class="storage-mini-card">
<span>{{ 'dashboard.avgBackupsPerRouter' | translate }}</span>
<strong>{{ averageBackupsPerRouter }}</strong>
</div>
</div>
</div>
<div *ngSwitchDefault class="storage-stage storage-stage--activity">
<div class="storage-trend-card">
<div class="storage-trend-card__header">
<div>
<strong>{{ 'dashboard.storageViewActivity' | translate }}</strong>
<small>{{ 'dashboard.storageActivityHint' | translate }}</small>
</div>
<span>{{ backupsLast7Days }}</span>
</div>
<div class="storage-trend-bars">
<div class="storage-trend-bars__item" *ngFor="let bar of recentBackupBars">
<div class="storage-trend-bars__column">
<span [style.height.%]="bar.percent" [attr.title]="bar.tooltip"></span>
</div>
<strong>{{ bar.value }}</strong>
<small>{{ bar.label }}</small>
</div>
</div>
</div>
<div class="storage-mini-grid storage-mini-grid--triple">
<div class="storage-mini-card">
<span>{{ 'dashboard.latestSnapshot' | translate }}</span>
<strong>{{ latestBackupLabel }}</strong>
</div>
<div class="storage-mini-card">
<span>{{ 'dashboard.busiestRouterLabel' | translate }}</span>
<strong>{{ busiestRouterLabel }}</strong>
</div>
<div class="storage-mini-card">
<span>{{ 'dashboard.activityTodayLabel' | translate }}</span>
<strong>{{ activityToday }}</strong>
</div>
</div>
</div>
</div>
</div>
<div class="storage-panel__stats storage-panel__stats--enhanced">
<div class="metric-grid-2 metric-grid-2--dense storage-snapshot-grid">
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.totalDisk' | translate }}</span>
<strong>{{ formatBytes(data.storage.total) }}</strong>
</div>
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.diskUsed' | translate }}</span>
<strong>{{ formatBytes(storageUsedBytes) }}</strong>
</div>
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.freeSpace' | translate }}</span>
<strong>{{ formatBytes(data.storage.free) }}</strong>
</div>
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.folderUsage' | translate }}</span>
<strong>{{ formatBytes(data.storage.folder_used) }}</strong>
</div>
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.avgBackupsPerRouter' | translate }}</span>
<strong>{{ averageBackupsPerRouter }}</strong>
</div>
<div class="metric-tile metric-tile--feature storage-snapshot-card">
<span>{{ 'dashboard.activityTodayLabel' | translate }}</span>
<strong>{{ activityToday }}</strong>
</div>
</div>
</div>
</div>
</app-section-card>
<app-section-card [title]="'dashboard.activityTitle' | translate" [subtitle]="'dashboard.activitySubtitle' | translate">
<div class="activity-feed" *ngIf="data.recent_logs.length; else noActivity">
<div class="activity-feed__item" *ngFor="let log of pagedRecentLogs" [attr.data-tone]="activityTone(log.message)">
<div class="activity-feed__dot"><i [class]="activityIcon(log.message)"></i></div>
<div class="activity-feed__body">
<div class="activity-feed__header">
<strong>{{ activityLabel(log.message) }}</strong>
<small>{{ log.timestamp | date: 'dd.MM.yyyy HH:mm:ss' }}</small>
</div>
<div class="activity-feed__message">{{ log.message }}</div>
</div>
</div>
<div class="activity-feed__footer" *ngIf="activityPageCount > 1">
<small>{{ activityRangeLabel }}</small>
<div class="dialog-actions">
<button pButton type="button" severity="secondary" icon="pi pi-angle-left" [disabled]="activityPage === 0" (click)="previousActivityPage()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-angle-right" [disabled]="activityPage >= activityPageCount - 1" (click)="nextActivityPage()"></button>
</div>
</div>
</div>
<ng-template #noActivity>
<div class="empty-state compact-empty">
<i class="pi pi-history"></i>
<p>{{ 'dashboard.noActivity' | translate }}</p>
</div>
</ng-template>
</app-section-card>
</div>
''')
(root / 'app/features/dashboard/dashboard-page.component.ts').write_text('''import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { forkJoin } from 'rxjs';
import { ApiService } from '../../core/services/api.service';
import { LanguageService } from '../../core/services/language.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 StorageView = 'overview' | 'composition' | 'activity';
@Component({
standalone: true,
imports: [CommonModule, RouterLink, 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);
private readonly language = inject(LanguageService);
data?: DashboardData;
backups: BackupInventoryItem[] = [];
routers: RouterInventoryItem[] = [];
exporting = false;
runningBinary = false;
readonly activityPageSize = 6;
activityPage = 0;
storageView: StorageView = 'overview';
readonly storageViewOptions: { value: StorageView; label: string; icon: string }[] = [
{ value: 'overview', label: 'dashboard.storageViewOverview', icon: 'pi pi-chart-pie' },
{ value: 'composition', label: 'dashboard.storageViewComposition', icon: 'pi pi-sliders-h' },
{ value: 'activity', label: 'dashboard.storageViewActivity', icon: 'pi pi-chart-bar' }
];
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;
}
});
}
setStorageView(view: StorageView) {
this.storageView = view;
}
get storageViewDescriptionKey(): string {
switch (this.storageView) {
case 'composition':
return 'dashboard.storageViewCompositionHint';
case 'activity':
return 'dashboard.storageViewActivityHint';
default:
return 'dashboard.storageViewOverviewHint';
}
}
get storageUsedBytes(): number {
const storage = this.data?.storage;
if (!storage) {
return 0;
}
if (storage.total > 0 && storage.free >= 0 && storage.free <= storage.total) {
return Math.max(0, storage.total - storage.free);
}
return Math.max(0, storage.used || 0);
}
get usedPercent(): number {
const storage = this.data?.storage;
if (!storage?.total) {
return Number(storage?.usage_percent || 0);
}
return Number(((this.storageUsedBytes / storage.total) * 100).toFixed(1));
}
get freePercent(): number {
const storage = this.data?.storage;
if (!storage?.total) {
return Math.max(0, 100 - this.usedPercent);
}
return Number(((storage.free / storage.total) * 100).toFixed(1));
}
get repositorySharePercent(): number {
const storage = this.data?.storage;
if (!storage?.total) {
return 0;
}
return Number(Math.min(100, (storage.folder_used / storage.total) * 100).toFixed(1));
}
get repositoryVsUsedPercent(): number {
if (!this.storageUsedBytes) {
return 0;
}
return Number(Math.min(100, ((this.data?.storage.folder_used || 0) / this.storageUsedBytes) * 100).toFixed(1));
}
get binarySharePercent(): number {
return Math.max(0, 100 - this.exportsSharePercent);
}
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}`;
}
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)`;
}
get storageOverviewLegend(): { label: string; value: string; tone: 'accent' | 'success' | 'neutral' }[] {
return [
{ label: 'dashboard.totalDisk', value: this.formatBytes(this.data?.storage.total || 0), tone: 'accent' },
{ label: 'dashboard.freeSpace', value: this.formatBytes(this.data?.storage.free || 0), tone: 'success' },
{ label: 'dashboard.folderUsage', value: this.formatBytes(this.data?.storage.folder_used || 0), tone: 'neutral' }
];
}
get storageCompositionRows(): { label: string; value: string; percent: number; tone: 'accent' | 'success' | 'neutral' }[] {
return [
{ label: 'dashboard.diskUsed', value: this.formatBytes(this.storageUsedBytes), percent: this.usedPercent, tone: 'accent' },
{ label: 'dashboard.freeSpace', value: this.formatBytes(this.data?.storage.free || 0), percent: this.freePercent, tone: 'success' },
{ label: 'dashboard.folderUsage', value: this.formatBytes(this.data?.storage.folder_used || 0), percent: this.repositoryVsUsedPercent, tone: 'neutral' }
];
}
get recentBackupBars(): { label: string; value: number; percent: number; tooltip: string }[] {
const locale = this.currentLocale();
const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' });
const today = new Date();
const buckets: { date: Date; label: string; value: number }[] = [];
for (let offset = 6; offset >= 0; offset -= 1) {
const date = new Date(today);
date.setHours(0, 0, 0, 0);
date.setDate(today.getDate() - offset);
buckets.push({ date, label: formatter.format(date), value: 0 });
}
for (const backup of this.backups) {
const created = new Date(backup.created_at);
created.setHours(0, 0, 0, 0);
const bucket = buckets.find((item) => item.date.getTime() === created.getTime());
if (bucket) {
bucket.value += 1;
}
}
const maxValue = Math.max(...buckets.map((item) => item.value), 0);
return buckets.map((item) => ({
label: item.label.replace('.', ''),
value: item.value,
percent: maxValue ? Math.max(14, (item.value / maxValue) * 100) : 14,
tooltip: `${item.label}: ${item.value}`
}));
}
previousActivityPage() {
this.activityPage = Math.max(0, this.activityPage - 1);
}
nextActivityPage() {
this.activityPage = Math.min(this.activityPageCount - 1, this.activityPage + 1);
}
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 currentLocale(): string {
switch (this.language.current()) {
case 'no':
return 'nb-NO';
case 'es':
return 'es-ES';
case 'en':
return 'en-GB';
default:
return 'pl-PL';
}
}
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;
}
}
''')
settings_html = root / 'app/features/settings/settings-page.component.html'
text = settings_html.read_text()
text = text.replace('<div class="form-grid-2">\n <span class="form-field">', '<div class="form-grid-2 settings-interface-grid">\n <span class="form-field">', 1)
text = text.replace('<p-dropdown formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value"></p-dropdown>', '<p-dropdown formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value" appendTo="body" panelStyleClass="settings-floating-dropdown"></p-dropdown>')
text = text.replace('<p-dropdown formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value"></p-dropdown>', '<p-dropdown formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" appendTo="body" panelStyleClass="settings-floating-dropdown"></p-dropdown>')
settings_html.write_text(text)
app_html = root / 'app/app.component.html'
app_html.write_text('''<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">
<span class="layout-footer__author">
<span class="layout-footer__label">{{ 'footer.authorLabel' | translate }}</span>
<strong>{{ authorName }}</strong>
<a href="https://linuxiarz.pl" target="_blank" rel="noreferrer noopener">{{ authorHandle }}</a>
</span>
<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">
<span class="layout-footer__author">
<span class="layout-footer__label">{{ 'footer.authorLabel' | translate }}</span>
<strong>{{ authorName }}</strong>
<a href="https://linuxiarz.pl" target="_blank" rel="noreferrer noopener">{{ authorHandle }}</a>
</span>
<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>
''')
app_ts = root / 'app/app.component.ts'
text = app_ts.read_text()
text = text.replace(" readonly author = 'MateuszG';\n", " readonly authorName = 'Mateusz Gruszczyński';\n readonly authorHandle = '@linuxiarz.pl';\n")
app_ts.write_text(text)
styles_path = root / 'styles.css'
styles = styles_path.read_text()
styles += '''
/* --- 2026 patch: dashboard storage switcher, settings dropdown, footer author --- */
.storage-panel--enhanced {
grid-template-columns: minmax(0, 1.32fr) minmax(280px, 0.9fr);
align-items: stretch;
}
.storage-panel__toolbar {
display: grid;
gap: 0.9rem;
margin-bottom: 1rem;
}
.storage-panel__eyebrow {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-soft);
}
.storage-view-switch {
display: inline-flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.35rem;
border-radius: 999px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
}
.storage-view-switch__btn {
border: 0;
background: transparent;
color: var(--text-soft);
min-height: 2.4rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font: inherit;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.storage-view-switch__btn:hover {
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
color: var(--text-main);
}
.storage-view-switch__btn.is-active {
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 88%, #fff 0%), color-mix(in srgb, var(--blue) 84%, #fff 0%));
color: #eef6ff;
box-shadow: 0 12px 24px color-mix(in srgb, var(--accent) 24%, transparent);
}
.storage-stage {
min-width: 0;
}
.storage-stage--overview,
.storage-stage--composition,
.storage-stage--activity {
display: grid;
gap: 1rem;
}
.storage-stage--overview {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: stretch;
}
.storage-ring-panel,
.storage-overview-side,
.storage-composition-card,
.storage-trend-card {
min-width: 0;
}
.storage-ring-panel {
padding: 1rem;
border-radius: 22px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
}
.storage-ring {
margin-inline: auto;
}
.storage-legend {
display: grid;
gap: 0.65rem;
}
.storage-legend__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.85rem;
border-radius: 16px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-0) 96%, transparent);
}
.storage-legend__item strong {
font-family: var(--font-title);
font-size: 0.88rem;
}
.storage-legend__item[data-tone='accent'] {
border-color: color-mix(in srgb, var(--accent) 26%, var(--border-color));
}
.storage-legend__item[data-tone='success'] {
border-color: color-mix(in srgb, var(--success) 28%, var(--border-color));
}
.storage-overview-side {
display: grid;
gap: 0.95rem;
}
.storage-callout {
display: grid;
gap: 0.32rem;
padding: 1rem 1.05rem;
border-radius: 20px;
border: 1px solid var(--border-color);
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-1) 96%, transparent), color-mix(in srgb, var(--surface-0) 95%, transparent));
}
.storage-callout--accent {
border-color: color-mix(in srgb, var(--accent) 26%, var(--border-color));
box-shadow: 0 16px 28px color-mix(in srgb, var(--accent) 12%, transparent);
}
.storage-callout strong,
.storage-mini-card strong,
.storage-trend-card__header span {
font-family: var(--font-title);
font-size: 1.08rem;
letter-spacing: 0.05em;
color: var(--text-main);
}
.storage-callout small,
.storage-trend-card__header small {
color: var(--text-soft);
line-height: 1.55;
}
.storage-bars--stacked {
gap: 0.85rem;
}
.storage-mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.storage-mini-grid--triple {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.storage-mini-card {
display: grid;
gap: 0.35rem;
padding: 0.9rem 1rem;
border-radius: 18px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 94%, transparent);
}
.storage-composition-card {
padding: 1rem;
border-radius: 22px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
}
.storage-composition-list {
display: grid;
gap: 0.85rem;
}
.storage-composition-row {
display: grid;
gap: 0.45rem;
}
.storage-composition-row__meta {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.storage-composition-row__meta strong {
font-family: var(--font-title);
font-size: 0.9rem;
}
.storage-composition-row__track {
height: 12px;
border-radius: 999px;
background: rgba(128, 145, 164, 0.14);
overflow: hidden;
}
.storage-composition-row__track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--blue));
}
.storage-composition-row[data-tone='success'] .storage-composition-row__track span,
.storage-bars__track--success span {
background: linear-gradient(90deg, var(--success), #2d8d74);
}
.storage-composition-row[data-tone='neutral'] .storage-composition-row__track span {
background: linear-gradient(90deg, #8e9cab, #b0bcc8);
}
.storage-composition-row small {
color: var(--text-faint);
}
.storage-trend-card {
padding: 1rem;
border-radius: 22px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
}
.storage-trend-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.storage-trend-card__header strong {
display: block;
margin-bottom: 0.2rem;
}
.storage-trend-bars {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.7rem;
align-items: end;
min-height: 220px;
}
.storage-trend-bars__item {
display: grid;
gap: 0.45rem;
justify-items: center;
}
.storage-trend-bars__column {
width: 100%;
min-height: 150px;
border-radius: 18px;
padding: 0.45rem;
display: flex;
align-items: flex-end;
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-0) 98%, transparent), color-mix(in srgb, var(--surface-2) 96%, transparent));
border: 1px solid var(--border-color);
}
.storage-trend-bars__column span {
display: block;
width: 100%;
min-height: 14%;
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff 0%), color-mix(in srgb, var(--blue) 86%, #fff 0%));
box-shadow: 0 14px 22px color-mix(in srgb, var(--accent) 18%, transparent);
}
.storage-trend-bars__item strong {
font-family: var(--font-title);
font-size: 0.84rem;
}
.storage-trend-bars__item small {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-soft);
}
.storage-snapshot-grid {
gap: 0.75rem;
align-content: start;
}
.storage-snapshot-card {
min-height: 88px;
padding: 0.9rem;
}
.settings-interface-grid {
align-items: start;
row-gap: 1.1rem;
}
.settings-interface-grid .p-dropdown {
width: 100%;
}
.settings-floating-dropdown {
z-index: 1300 !important;
}
.layout-footer__author {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--surface-1) 94%, transparent);
text-transform: none;
letter-spacing: normal;
color: var(--text-main);
}
.layout-footer__label {
color: var(--text-faint);
}
.layout-footer__author strong {
font-family: var(--font-title);
font-size: 0.78rem;
letter-spacing: 0.04em;
}
.layout-footer__author a {
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: none;
color: var(--accent);
}
@media (max-width: 1180px) {
.storage-panel--enhanced,
.storage-stage--overview {
grid-template-columns: 1fr;
}
}
@media (max-width: 780px) {
.storage-mini-grid,
.storage-mini-grid--triple,
.storage-trend-bars,
.storage-snapshot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.storage-trend-bars {
min-height: unset;
}
.storage-trend-bars__column {
min-height: 120px;
}
}
@media (max-width: 560px) {
.storage-view-switch {
display: grid;
grid-template-columns: 1fr;
}
.storage-mini-grid,
.storage-mini-grid--triple,
.storage-snapshot-grid,
.storage-trend-bars {
grid-template-columns: 1fr;
}
.storage-trend-card__header,
.storage-composition-row__meta,
.layout-footer__author {
align-items: flex-start;
flex-direction: column;
}
}
'''
styles_path.write_text(styles)
for name, lang_data in {
'pl.json': {
('dashboard', 'diskUsed'): 'Zajętość dysku',
('dashboard', 'storageViewOverview'): 'Podgląd',
('dashboard', 'storageViewComposition'): 'Proporcje',
('dashboard', 'storageViewActivity'): 'Aktywność 7 dni',
('dashboard', 'storageViewOverviewHint'): 'Pierścień użycia i szybkie wskaźniki miejsca.',
('dashboard', 'storageViewCompositionHint'): 'Udziały zajętości, wolnego miejsca i repozytorium.',
('dashboard', 'storageViewActivityHint'): 'Nowe backupy z ostatnich 7 dni i tempo zmian.',
('dashboard', 'storageActivityHint'): 'Liczba nowych backupów w ostatnim tygodniu.',
('dashboard', 'storageInsightUsage'): 'Rzeczywiście zajęta przestrzeń na dysku według danych systemowych.'
},
'en.json': {
('dashboard', 'diskUsed'): 'Disk used',
('dashboard', 'storageViewOverview'): 'Overview',
('dashboard', 'storageViewComposition'): 'Breakdown',
('dashboard', 'storageViewActivity'): '7-day activity',
('dashboard', 'storageViewOverviewHint'): 'Usage ring and quick space indicators.',
('dashboard', 'storageViewCompositionHint'): 'Relative split of used, free and repository space.',
('dashboard', 'storageViewActivityHint'): 'New backups over the last 7 days and their pace.',
('dashboard', 'storageActivityHint'): 'Number of new backups created during the last week.',
('dashboard', 'storageInsightUsage'): 'Actual disk occupancy calculated from current system values.'
},
'es.json': {
('dashboard', 'diskUsed'): 'Disco usado',
('dashboard', 'storageViewOverview'): 'Vista general',
('dashboard', 'storageViewComposition'): 'Proporciones',
('dashboard', 'storageViewActivity'): 'Actividad 7 días',
('dashboard', 'storageViewOverviewHint'): 'Anillo de uso e indicadores rápidos de espacio.',
('dashboard', 'storageViewCompositionHint'): 'Reparto del espacio usado, libre y del repositorio.',
('dashboard', 'storageViewActivityHint'): 'Backups nuevos de los últimos 7 días y su ritmo.',
('dashboard', 'storageActivityHint'): 'Número de backups nuevos creados durante la última semana.',
('dashboard', 'storageInsightUsage'): 'Ocupación real del disco calculada con los valores actuales del sistema.'
},
'no.json': {
('dashboard', 'diskUsed'): 'Brukt disk',
('dashboard', 'storageViewOverview'): 'Oversikt',
('dashboard', 'storageViewComposition'): 'Fordeling',
('dashboard', 'storageViewActivity'): '7 dagers aktivitet',
('dashboard', 'storageViewOverviewHint'): 'Bruksring og raske plassindikatorer.',
('dashboard', 'storageViewCompositionHint'): 'Fordeling av brukt, ledig og lagringsplass for repoet.',
('dashboard', 'storageViewActivityHint'): 'Nye sikkerhetskopier de siste 7 dagene og tempoet.',
('dashboard', 'storageActivityHint'): 'Antall nye sikkerhetskopier opprettet den siste uken.',
('dashboard', 'storageInsightUsage'): 'Faktisk diskbruk beregnet fra systemets nåværende verdier.'
}
}.items():
path = root / 'assets/i18n' / name
data = json.loads(path.read_text())
for (section, key), value in lang_data.items():
data.setdefault(section, {})[key] = value
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
print('[OK] patched')