1208 lines
43 KiB
Python
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')
|