mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-13 05:47:33 -04:00
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
|
|
import { rxResource } from '@angular/core/rxjs-interop';
|
|
import type { Observable } from 'rxjs';
|
|
import { Router, RouterLink } from '@angular/router';
|
|
import { DatePipe, JsonPipe } from '@angular/common';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle, moveItemInArray } from '@angular/cdk/drag-drop';
|
|
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
|
import { CardComponent, ButtonComponent, BadgeComponent, SpinnerComponent } from '@ui';
|
|
import { AppHubService } from '@core/realtime/app-hub.service';
|
|
import { EventsApi } from '@core/api/events.api';
|
|
import { JobsApi } from '@core/api/jobs.api';
|
|
import { GeneralConfigApi } from '@core/api/general-config.api';
|
|
import { CfScoreApi, CfScoreStats, CfScoreUpgradesResponse } from '@core/api/cf-score.api';
|
|
import { ToastService } from '@core/services/toast.service';
|
|
import { ManualEvent } from '@core/models/event.models';
|
|
import { JobType } from '@shared/models/enums';
|
|
|
|
const DASHBOARD_ROW_ORDER_KEY = 'dashboard-row-order';
|
|
const DEFAULT_ROW_ORDER = ['strikes', 'logs-events', 'cf-scores', 'jobs'] as const;
|
|
type DashboardRowId = typeof DEFAULT_ROW_ORDER[number];
|
|
|
|
@Component({
|
|
selector: 'app-dashboard',
|
|
standalone: true,
|
|
imports: [
|
|
RouterLink,
|
|
DatePipe,
|
|
JsonPipe,
|
|
NgIcon,
|
|
PageHeaderComponent,
|
|
CardComponent,
|
|
ButtonComponent,
|
|
BadgeComponent,
|
|
SpinnerComponent,
|
|
CdkDropList,
|
|
CdkDrag,
|
|
CdkDragHandle,
|
|
],
|
|
templateUrl: './dashboard.component.html',
|
|
styleUrl: './dashboard.component.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class DashboardComponent {
|
|
readonly JobType = JobType;
|
|
|
|
private readonly hub = inject(AppHubService);
|
|
private readonly eventsApi = inject(EventsApi);
|
|
private readonly jobsApi = inject(JobsApi);
|
|
private readonly generalConfigApi = inject(GeneralConfigApi);
|
|
private readonly cfScoreApi = inject(CfScoreApi);
|
|
private readonly toast = inject(ToastService);
|
|
private readonly router = inject(Router);
|
|
|
|
readonly connected = this.hub.isConnected;
|
|
readonly jobs = this.hub.jobs;
|
|
|
|
private readonly generalConfigResource = rxResource({
|
|
stream: () => this.generalConfigApi.get(),
|
|
});
|
|
private readonly cfScoreStatsResource = rxResource({
|
|
stream: (): Observable<CfScoreStats | null> => this.cfScoreApi.getStats(),
|
|
defaultValue: null,
|
|
});
|
|
private readonly cfScoreUpgradesResource = rxResource({
|
|
stream: () => this.cfScoreApi.getRecentUpgrades({ page: 1, pageSize: 5 }),
|
|
defaultValue: { items: [], page: 1, pageSize: 5, totalCount: 0, totalPages: 0 } as CfScoreUpgradesResponse,
|
|
});
|
|
|
|
readonly showSupportSection = computed(() =>
|
|
this.generalConfigResource.hasValue() ? this.generalConfigResource.value().displaySupportBanner : false,
|
|
);
|
|
readonly cfScoreStats = computed(() => this.cfScoreStatsResource.value());
|
|
readonly cfScoreUpgrades = computed(() => this.cfScoreUpgradesResource.value().items);
|
|
|
|
readonly rowOrder = signal<DashboardRowId[]>(this.loadOrder());
|
|
readonly visibleRowOrder = computed(() => {
|
|
const order = this.rowOrder();
|
|
return this.cfScoreStats() ? order : order.filter((id) => id !== 'cf-scores');
|
|
});
|
|
|
|
readonly recentStrikes = computed(() => this.hub.strikes().slice(0, 5));
|
|
readonly recentLogs = computed(() => this.hub.logs().slice(0, 5));
|
|
readonly recentEvents = computed(() => this.hub.events().slice(0, 5));
|
|
|
|
readonly unresolvedManualEvents = computed(() =>
|
|
this.hub.manualEvents().filter((e) => !e.isResolved)
|
|
);
|
|
|
|
readonly manualEventIndex = signal(0);
|
|
|
|
readonly currentManualEvent = computed(() => {
|
|
const events = this.unresolvedManualEvents();
|
|
const idx = this.manualEventIndex();
|
|
return events[idx] ?? null;
|
|
});
|
|
|
|
readonly canNavigatePrev = computed(() => this.manualEventIndex() > 0);
|
|
readonly canNavigateNext = computed(() =>
|
|
this.manualEventIndex() < this.unresolvedManualEvents().length - 1
|
|
);
|
|
|
|
// Manual event navigation
|
|
prevManualEvent(): void {
|
|
if (this.canNavigatePrev()) {
|
|
this.manualEventIndex.update((i) => i - 1);
|
|
}
|
|
}
|
|
|
|
nextManualEvent(): void {
|
|
if (this.canNavigateNext()) {
|
|
this.manualEventIndex.update((i) => i + 1);
|
|
}
|
|
}
|
|
|
|
dismissManualEvent(event: ManualEvent): void {
|
|
this.eventsApi.resolveManualEvent(event.id).subscribe({
|
|
next: () => {
|
|
this.hub.removeManualEvent(event.id);
|
|
const maxIdx = this.unresolvedManualEvents().length - 1;
|
|
if (this.manualEventIndex() > maxIdx) {
|
|
this.manualEventIndex.set(Math.max(0, maxIdx));
|
|
}
|
|
this.toast.success('Event dismissed');
|
|
},
|
|
error: () => this.toast.error('Failed to dismiss event'),
|
|
});
|
|
}
|
|
|
|
triggerJob(jobType: string): void {
|
|
this.jobsApi.trigger(jobType as JobType).subscribe({
|
|
next: () => this.toast.success(`${this.jobDisplayName(jobType)} triggered`),
|
|
error: () => this.toast.error(`Failed to trigger ${this.jobDisplayName(jobType)}`),
|
|
});
|
|
}
|
|
|
|
// Log helpers
|
|
logSeverity(level: string): 'error' | 'warning' | 'info' | 'success' | 'default' {
|
|
const l = level.toLowerCase();
|
|
if (l === 'error' || l === 'fatal' || l === 'critical') return 'error';
|
|
if (l === 'warning') return 'warning';
|
|
if (l === 'information' || l === 'info') return 'info';
|
|
if (l === 'debug' || l === 'trace' || l === 'verbose') return 'success';
|
|
return 'default';
|
|
}
|
|
|
|
logBadgeSeverity(level: string): 'error' | 'warning' | 'info' | 'success' | 'default' {
|
|
return this.logSeverity(level);
|
|
}
|
|
|
|
logLevelLabel(level: string): string {
|
|
const l = level.toLowerCase();
|
|
if (l === 'information') return 'Info';
|
|
return level.charAt(0).toUpperCase() + level.slice(1).toLowerCase();
|
|
}
|
|
|
|
// Event helpers
|
|
eventMarkerClass(eventType: string, severity: string): string {
|
|
const t = eventType.toLowerCase();
|
|
if (t.includes('strike')) {
|
|
const s = severity.toLowerCase();
|
|
if (s === 'error') return 'error';
|
|
if (s === 'warning') return 'warning';
|
|
return 'warning'; // strikes default to yellow/amber
|
|
}
|
|
return this.eventSeverity(severity);
|
|
}
|
|
|
|
eventTypeSeverity(eventType: string): 'error' | 'warning' | 'info' | 'success' | 'default' {
|
|
const t = eventType.toLowerCase();
|
|
if (t === 'failedimportstrike' || t === 'queueitemdeleted') return 'error';
|
|
if (t === 'stalledstrike' || t === 'downloadmarkedfordeletion') return 'warning';
|
|
if (t === 'downloadcleaned') return 'success';
|
|
if (t.includes('strike') || t === 'categorychanged') return 'info';
|
|
return 'default';
|
|
}
|
|
|
|
eventSeverity(severity: string): 'error' | 'warning' | 'info' | 'default' {
|
|
const s = severity.toLowerCase();
|
|
if (s === 'error') return 'error';
|
|
if (s === 'warning' || s === 'important') return 'warning';
|
|
if (s === 'information' || s === 'info') return 'info';
|
|
return 'default';
|
|
}
|
|
|
|
formatEventType(eventType: string): string {
|
|
return eventType.replace(/([A-Z])/g, ' $1').trim();
|
|
}
|
|
|
|
getDownloadName(event: { data?: string }): string | null {
|
|
if (!event.data) return null;
|
|
try {
|
|
return JSON.parse(event.data)?.itemName || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
truncate(text: string, max = 80): string {
|
|
return text.length > max ? text.substring(0, max) + '...' : text;
|
|
}
|
|
|
|
// Timeline icon helpers
|
|
logIcon(level: string): string {
|
|
const l = level.toLowerCase();
|
|
if (l === 'error' || l === 'fatal' || l === 'critical') return 'tablerCircleX';
|
|
if (l === 'warning') return 'tablerAlertTriangle';
|
|
if (l === 'information' || l === 'info') return 'tablerInfoCircle';
|
|
if (l === 'debug' || l === 'trace' || l === 'verbose') return 'tablerCode';
|
|
return 'tablerCircle';
|
|
}
|
|
|
|
eventIcon(eventType: string): string {
|
|
const t = eventType.toLowerCase();
|
|
if (t.includes('strike')) return 'tablerBolt';
|
|
if (t === 'downloadcleaned') return 'tablerDownload';
|
|
if (t === 'queueitemdeleted') return 'tablerTrash';
|
|
if (t === 'categorychanged') return 'tablerTag';
|
|
return 'tablerCircle';
|
|
}
|
|
|
|
// Job helpers
|
|
jobDisplayName(jobType: string): string {
|
|
switch (jobType) {
|
|
case 'QueueCleaner': return 'Queue Cleaner';
|
|
case 'MalwareBlocker': return 'Malware Blocker';
|
|
case 'DownloadCleaner': return 'Download Cleaner';
|
|
case 'BlacklistSynchronizer': return 'Blacklist Sync';
|
|
default: return jobType;
|
|
}
|
|
}
|
|
|
|
jobStatusSeverity(status: string): 'success' | 'warning' | 'error' | 'info' | 'default' {
|
|
const s = status.toLowerCase();
|
|
if (s === 'running') return 'info';
|
|
if (s === 'complete' || s === 'scheduled') return 'success';
|
|
if (s === 'error') return 'error';
|
|
if (s === 'paused') return 'warning';
|
|
return 'default';
|
|
}
|
|
|
|
manualEventSeverityClass(severity: string): string {
|
|
const s = severity.toLowerCase();
|
|
if (s === 'error') return 'manual-event--error';
|
|
if (s === 'warning') return 'manual-event--warning';
|
|
if (s === 'important') return 'manual-event--important';
|
|
return 'manual-event--info';
|
|
}
|
|
|
|
processManualEventMessage(message: string): string {
|
|
if (!message) return '';
|
|
// Escape HTML to prevent XSS
|
|
let processed = message
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
// Convert newlines to <br> tags
|
|
processed = processed.replace(/\\n/g, '<br>').replace(/\n/g, '<br>');
|
|
// Convert URLs to clickable links
|
|
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
|
processed = processed.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="manual-event-link">$1</a>');
|
|
return processed;
|
|
}
|
|
|
|
parseEventData(data: string | undefined): unknown {
|
|
if (!data) return null;
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
navigateTo(path: string): void {
|
|
this.router.navigate([path]);
|
|
}
|
|
|
|
private loadOrder(): DashboardRowId[] {
|
|
try {
|
|
const saved = localStorage.getItem(DASHBOARD_ROW_ORDER_KEY);
|
|
if (saved) {
|
|
const parsed: unknown[] = JSON.parse(saved);
|
|
const valid = parsed.filter((id): id is DashboardRowId =>
|
|
(DEFAULT_ROW_ORDER as readonly unknown[]).includes(id)
|
|
);
|
|
// Ensure any newly added rows (future-proofing) are appended
|
|
for (const id of DEFAULT_ROW_ORDER) {
|
|
if (!valid.includes(id)) valid.push(id);
|
|
}
|
|
return valid;
|
|
}
|
|
} catch { /* ignore */ }
|
|
return [...DEFAULT_ROW_ORDER];
|
|
}
|
|
|
|
onDrop(event: CdkDragDrop<DashboardRowId[]>): void {
|
|
const visible = [...this.visibleRowOrder()];
|
|
moveItemInArray(visible, event.previousIndex, event.currentIndex);
|
|
const hidden = this.rowOrder().filter((id) => !visible.includes(id));
|
|
const newOrder = [...visible, ...hidden];
|
|
this.rowOrder.set(newOrder);
|
|
localStorage.setItem(DASHBOARD_ROW_ORDER_KEY, JSON.stringify(newOrder));
|
|
}
|
|
|
|
// Strike helpers
|
|
strikeTypeSeverity(type: string): 'error' | 'warning' | 'info' | 'default' {
|
|
const t = type.toLowerCase();
|
|
if (t === 'failedimport') return 'error';
|
|
if (t === 'stalled') return 'warning';
|
|
if (t === 'slowspeed' || t === 'slowtime') return 'info';
|
|
return 'default';
|
|
}
|
|
|
|
formatStrikeType(type: string): string {
|
|
return type.replace(/([A-Z])/g, ' $1').trim();
|
|
}
|
|
|
|
instanceTypeSeverity(type: string): 'info' | 'warning' | 'default' {
|
|
if (type === 'Radarr') return 'warning';
|
|
if (type === 'Sonarr') return 'info';
|
|
return 'default';
|
|
}
|
|
}
|