mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-13 08:16:24 -04:00
This commit is contained in:
@@ -41,8 +41,6 @@ public static class LoggingDI
|
||||
List<string> instanceNames = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()];
|
||||
int arrPadding = instanceNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Log level is controlled by the LoggingConfigManager's level switch
|
||||
|
||||
// Apply padding values to templates
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
.Replace("CAT_PAD", catPadding.ToString())
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'logs', pathMatch: 'full' },
|
||||
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) }
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
|
||||
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
|
||||
{ path: 'settings', loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent) }
|
||||
];
|
||||
|
||||
32
code/UI/src/app/core/models/signalr.models.ts
Normal file
32
code/UI/src/app/core/models/signalr.models.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Models for SignalR connections and messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration options for a SignalR hub connection
|
||||
*/
|
||||
export interface SignalRHubConfig {
|
||||
/** URL to the SignalR hub endpoint */
|
||||
hubUrl: string;
|
||||
/** Maximum number of reconnection attempts (0 for infinite) */
|
||||
maxReconnectAttempts: number;
|
||||
/** Initial delay between reconnection attempts in milliseconds (will be subject to backoff) */
|
||||
reconnectDelayMs: number;
|
||||
/** Maximum size of the message buffer */
|
||||
bufferSize: number;
|
||||
/** Interval in milliseconds to check connection health (0 to disable) */
|
||||
healthCheckIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard log entry message format received from the server
|
||||
*/
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
message: string;
|
||||
exception?: string;
|
||||
category?: string;
|
||||
jobName?: string;
|
||||
instanceName?: string;
|
||||
}
|
||||
269
code/UI/src/app/core/services/base-signalr.service.ts
Normal file
269
code/UI/src/app/core/services/base-signalr.service.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { SignalRHubConfig } from '../models/signalr.models';
|
||||
|
||||
/**
|
||||
* Base service for SignalR hub connections.
|
||||
* Provides common functionality for connecting to, monitoring, and managing SignalR hubs.
|
||||
*
|
||||
* @typeParam T - The type of messages that will be received from this hub
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class BaseSignalRService<T> implements OnDestroy {
|
||||
protected hubConnection!: signalR.HubConnection;
|
||||
protected connectionStatusSubject = new BehaviorSubject<boolean>(false);
|
||||
protected messageSubject = new BehaviorSubject<T[]>([]);
|
||||
protected destroy$ = new Subject<void>();
|
||||
protected reconnectAttempts = 0;
|
||||
protected connectionHealthCheckInterval: any;
|
||||
protected messageBuffer: T[] = [];
|
||||
|
||||
/**
|
||||
* Initialize a base SignalR hub connection service
|
||||
*
|
||||
* @param config - Configuration for the hub connection
|
||||
* @param messageEventName - The name of the event that delivers messages from the server
|
||||
*/
|
||||
constructor(
|
||||
protected config: SignalRHubConfig,
|
||||
protected messageEventName: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start the SignalR connection to the hub
|
||||
* @returns Promise that resolves when the connection is established
|
||||
*/
|
||||
public startConnection(): Promise<void> {
|
||||
if (this.hubConnection &&
|
||||
this.hubConnection.state !== signalR.HubConnectionState.Disconnected) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Build a new connection if needed
|
||||
if (!this.hubConnection) {
|
||||
this.hubConnection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(this.config.hubUrl)
|
||||
.withAutomaticReconnect({
|
||||
nextRetryDelayInMilliseconds: (retryContext) => {
|
||||
if (this.config.maxReconnectAttempts > 0 &&
|
||||
retryContext.previousRetryCount >= this.config.maxReconnectAttempts) {
|
||||
return null; // Stop trying after max attempts
|
||||
}
|
||||
|
||||
// Implement exponential backoff with a maximum delay of 30 seconds
|
||||
return Math.min(
|
||||
this.config.reconnectDelayMs * Math.pow(2, retryContext.previousRetryCount),
|
||||
30000
|
||||
);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
this.registerSignalREvents();
|
||||
}
|
||||
|
||||
// Start health check interval if configured
|
||||
this.startHealthCheck();
|
||||
|
||||
return this.hubConnection.start()
|
||||
.then(() => {
|
||||
console.log(`SignalR connection started to ${this.config.hubUrl}`);
|
||||
this.connectionStatusSubject.next(true);
|
||||
this.reconnectAttempts = 0;
|
||||
this.onConnectionEstablished();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error connecting to ${this.config.hubUrl}:`, err);
|
||||
this.connectionStatusSubject.next(false);
|
||||
|
||||
// Always try to reconnect, optionally limited by maxReconnectAttempts
|
||||
const infiniteReconnect = this.config.maxReconnectAttempts === 0;
|
||||
if (infiniteReconnect || this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(
|
||||
this.config.reconnectDelayMs * Math.pow(1.5, this.reconnectAttempts),
|
||||
30000
|
||||
);
|
||||
|
||||
console.log(`Attempting to reconnect (${this.reconnectAttempts}) in ${delay}ms...`);
|
||||
setTimeout(() => this.startConnection(), delay);
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the SignalR connection
|
||||
* @returns Promise that resolves when the connection is stopped
|
||||
*/
|
||||
public stopConnection(): Promise<void> {
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (!this.hubConnection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.hubConnection.stop()
|
||||
.then(() => {
|
||||
console.log(`SignalR connection to ${this.config.hubUrl} stopped`);
|
||||
this.connectionStatusSubject.next(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error stopping connection to ${this.config.hubUrl}:`, err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event handlers for SignalR events
|
||||
*/
|
||||
protected registerSignalREvents(): void {
|
||||
// Handle incoming messages
|
||||
this.hubConnection.on(this.messageEventName, (message: T) => {
|
||||
this.processMessage(message);
|
||||
});
|
||||
|
||||
// Handle reconnection events
|
||||
this.hubConnection.onreconnected(() => {
|
||||
console.log(`SignalR connection reconnected to ${this.config.hubUrl}`);
|
||||
this.connectionStatusSubject.next(true);
|
||||
this.reconnectAttempts = 0;
|
||||
this.onConnectionEstablished();
|
||||
});
|
||||
|
||||
this.hubConnection.onreconnecting(() => {
|
||||
console.log(`SignalR connection reconnecting to ${this.config.hubUrl}...`);
|
||||
this.connectionStatusSubject.next(false);
|
||||
});
|
||||
|
||||
this.hubConnection.onclose(() => {
|
||||
console.log(`SignalR connection to ${this.config.hubUrl} closed`);
|
||||
this.connectionStatusSubject.next(false);
|
||||
|
||||
// Try to reconnect if the connection was closed unexpectedly
|
||||
if (this.shouldAttemptReconnect()) {
|
||||
this.startConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the health check timer to periodically verify connection status
|
||||
*/
|
||||
protected startHealthCheck(): void {
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (this.config.healthCheckIntervalMs > 0) {
|
||||
this.connectionHealthCheckInterval = setInterval(() => {
|
||||
this.checkConnectionHealth();
|
||||
}, this.config.healthCheckIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the health check timer
|
||||
*/
|
||||
protected stopHealthCheck(): void {
|
||||
if (this.connectionHealthCheckInterval) {
|
||||
clearInterval(this.connectionHealthCheckInterval);
|
||||
this.connectionHealthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the health of the connection and attempt to reconnect if needed
|
||||
*/
|
||||
protected checkConnectionHealth(): void {
|
||||
if (!this.hubConnection ||
|
||||
this.hubConnection.state === signalR.HubConnectionState.Disconnected) {
|
||||
console.log('Health check detected disconnected state, attempting to reconnect...');
|
||||
this.startConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a message received from the hub
|
||||
* @param message - The message received from the hub
|
||||
*/
|
||||
protected processMessage(message: T): void {
|
||||
// Add to buffer
|
||||
this.addToBuffer(message);
|
||||
|
||||
// Update the subject with current messages
|
||||
const currentMessages = this.messageSubject.value;
|
||||
this.messageSubject.next([...currentMessages, message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the buffer
|
||||
* @param message - The message to add to the buffer
|
||||
*/
|
||||
protected addToBuffer(message: T): void {
|
||||
this.messageBuffer.push(message);
|
||||
|
||||
// Trim buffer if it exceeds the limit
|
||||
if (this.messageBuffer.length > this.config.bufferSize) {
|
||||
this.messageBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a connection is established or re-established
|
||||
* Override in derived classes to perform hub-specific initialization
|
||||
*/
|
||||
protected onConnectionEstablished(): void {
|
||||
// Override in derived classes
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a reconnection attempt should be made
|
||||
* @returns True if a reconnection attempt should be made
|
||||
*/
|
||||
protected shouldAttemptReconnect(): boolean {
|
||||
// By default, always try to reconnect unless max attempts reached
|
||||
return this.config.maxReconnectAttempts === 0 ||
|
||||
this.reconnectAttempts < this.config.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a reconnection attempt, even if max attempts have been reached
|
||||
*/
|
||||
public forceReconnect(): void {
|
||||
this.reconnectAttempts = 0;
|
||||
this.startConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the buffered messages
|
||||
* @returns A copy of the message buffer
|
||||
*/
|
||||
public getBufferedMessages(): T[] {
|
||||
return [...this.messageBuffer];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages as an observable
|
||||
* @returns An observable that emits the current messages and all future messages
|
||||
*/
|
||||
public getMessages(): Observable<T[]> {
|
||||
return this.messageSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status as an observable
|
||||
* @returns An observable that emits the current connection status and all future status changes
|
||||
*/
|
||||
public getConnectionStatus(): Observable<boolean> {
|
||||
return this.connectionStatusSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.stopHealthCheck();
|
||||
this.stopConnection();
|
||||
}
|
||||
}
|
||||
61
code/UI/src/app/core/services/log-hub.service.ts
Normal file
61
code/UI/src/app/core/services/log-hub.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BaseSignalRService } from './base-signalr.service';
|
||||
import { LogEntry, SignalRHubConfig } from '../models/signalr.models';
|
||||
// Note: We'll define a fallback API URL if environment is not available yet
|
||||
const API_URL = 'http://localhost:5000';
|
||||
|
||||
/**
|
||||
* Service for connecting to the logs SignalR hub
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LogHubService extends BaseSignalRService<LogEntry> {
|
||||
constructor() {
|
||||
// Default configuration for the logs hub
|
||||
const config: SignalRHubConfig = {
|
||||
hubUrl: `${API_URL}/api/hubs/logs`,
|
||||
maxReconnectAttempts: 0, // Infinite reconnection attempts
|
||||
reconnectDelayMs: 2000,
|
||||
bufferSize: 100,
|
||||
healthCheckIntervalMs: 30000 // Check connection every 30 seconds
|
||||
};
|
||||
|
||||
super(config, 'ReceiveLog');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request recent logs from the server
|
||||
*/
|
||||
public requestRecentLogs(): void {
|
||||
if (this.hubConnection &&
|
||||
this.hubConnection.state === signalR.HubConnectionState.Connected) {
|
||||
this.hubConnection.invoke('RequestRecentLogs')
|
||||
.catch(err => console.error('Error while requesting recent logs:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to request recent logs when connection is established
|
||||
*/
|
||||
protected override onConnectionEstablished(): void {
|
||||
this.requestRecentLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the buffered logs
|
||||
*/
|
||||
public getBufferedLogs(): LogEntry[] {
|
||||
return this.getBufferedMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs as an observable
|
||||
*/
|
||||
public getLogs() {
|
||||
return this.getMessages();
|
||||
}
|
||||
}
|
||||
|
||||
// Fix missing signalR import
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
66
code/UI/src/app/core/services/system-hub.service.ts
Normal file
66
code/UI/src/app/core/services/system-hub.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BaseSignalRService } from './base-signalr.service';
|
||||
import { SignalRHubConfig } from '../models/signalr.models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
|
||||
/**
|
||||
* System status message from the system hub
|
||||
*/
|
||||
export interface SystemStatus {
|
||||
timestamp: Date;
|
||||
status: 'online' | 'offline' | 'degraded';
|
||||
message: string;
|
||||
serverVersion?: string;
|
||||
components?: {
|
||||
name: string;
|
||||
status: 'online' | 'offline' | 'degraded';
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for connecting to the system monitoring SignalR hub
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SystemHubService extends BaseSignalRService<SystemStatus> {
|
||||
constructor() {
|
||||
// Default configuration for the system hub
|
||||
const config: SignalRHubConfig = {
|
||||
hubUrl: environment.apiUrl ? `${environment.apiUrl}/api/hubs/system` : 'http://localhost:5000/api/hubs/system',
|
||||
maxReconnectAttempts: 0, // Infinite reconnection attempts
|
||||
reconnectDelayMs: 2000,
|
||||
bufferSize: 10, // Only need a few status updates
|
||||
healthCheckIntervalMs: 30000 // Check connection every 30 seconds
|
||||
};
|
||||
|
||||
super(config, 'ReceiveSystemStatus');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current system status
|
||||
*/
|
||||
public requestSystemStatus(): void {
|
||||
if (this.hubConnection &&
|
||||
this.hubConnection.state === signalR.HubConnectionState.Connected) {
|
||||
this.hubConnection.invoke('RequestSystemStatus')
|
||||
.catch(err => console.error('Error while requesting system status:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to request system status when connection is established
|
||||
*/
|
||||
protected override onConnectionEstablished(): void {
|
||||
this.requestSystemStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest system status
|
||||
*/
|
||||
public getLatestStatus(): SystemStatus | undefined {
|
||||
const messages = this.getBufferedMessages();
|
||||
return messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="dashboard-container">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-6 lg:col-4">
|
||||
<p-card header="Logs Summary" subheader="Recent logging activity">
|
||||
<div class="card-content">
|
||||
<p>Monitor your application logs and error statuses.</p>
|
||||
<div class="card-footer">
|
||||
<button pButton label="View Logs" icon="pi pi-list" routerLink="/logs"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 lg:col-4">
|
||||
<p-card header="System Status" subheader="Current health status">
|
||||
<div class="card-content">
|
||||
<p>All systems operational</p>
|
||||
<div class="card-footer">
|
||||
<button pButton label="Details" icon="pi pi-server" class="p-button-outlined"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 lg:col-4">
|
||||
<p-card header="Available Updates" subheader="System updates">
|
||||
<div class="card-content">
|
||||
<p>Your application is up to date.</p>
|
||||
<div class="card-footer">
|
||||
<button pButton label="Check for Updates" icon="pi pi-refresh" class="p-button-secondary"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
.dashboard-container {
|
||||
padding: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
::ng-deep .p-card {
|
||||
height: 100%;
|
||||
|
||||
.p-card-body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.p-card-content {
|
||||
flex-grow: 1;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DashboardPageComponent } from './dashboard-page.component';
|
||||
|
||||
describe('DashboardPageComponent', () => {
|
||||
let component: DashboardPageComponent;
|
||||
let fixture: ComponentFixture<DashboardPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardPageComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DashboardPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
CardModule,
|
||||
ButtonModule
|
||||
],
|
||||
templateUrl: './dashboard-page.component.html',
|
||||
styleUrl: './dashboard-page.component.scss'
|
||||
})
|
||||
export class DashboardPageComponent {
|
||||
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
<!-- Top Bar -->
|
||||
<p-toolbar styleClass="top-bar">
|
||||
<div class="p-toolbar-group-start">
|
||||
<button pButton
|
||||
class="p-button-text p-button-rounded sidebar-toggle-mobile"
|
||||
icon="pi pi-bars"
|
||||
(click)="toggleMobileSidebar()"
|
||||
aria-label="Toggle menu">
|
||||
</button>
|
||||
<div class="logo-container">
|
||||
<span class="app-name">CleanupErr</span>
|
||||
</div>
|
||||
@@ -15,10 +21,51 @@
|
||||
</div>
|
||||
</p-toolbar>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="layout-content">
|
||||
<div class="layout-content-inner">
|
||||
<router-outlet></router-outlet>
|
||||
<!-- Sidebar for Mobile -->
|
||||
<p-sidebar [visible]="mobileSidebarVisible()" (visibleChange)="mobileSidebarVisible.set($event)" position="left" styleClass="mobile-sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-header">
|
||||
<h3>CleanupErr</h3>
|
||||
</div>
|
||||
<div class="sidebar-menu">
|
||||
<ul class="menu-list">
|
||||
<li *ngFor="let item of menuItems" class="menu-item">
|
||||
<a [routerLink]="item.route" routerLinkActive="active-menu-link" [routerLinkActiveOptions]="{exact: item.route === '/dashboard'}"
|
||||
(click)="toggleMobileSidebar()">
|
||||
<i class="{{item.icon}}"></i>
|
||||
<span>{{item.label}}</span>
|
||||
<span class="badge" *ngIf="item.badge">{{item.badge}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</p-sidebar>
|
||||
|
||||
<div class="layout-main">
|
||||
<!-- Sidebar for Desktop -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-logo">
|
||||
<span class="app-logo">CE</span>
|
||||
</div>
|
||||
<ul class="menu-list">
|
||||
<li *ngFor="let item of menuItems" class="menu-item">
|
||||
<a [routerLink]="item.route" routerLinkActive="active-menu-link" [routerLinkActiveOptions]="{exact: item.route === '/dashboard'}">
|
||||
<i class="{{item.icon}}"></i>
|
||||
<span>{{item.label}}</span>
|
||||
<span class="badge" *ngIf="item.badge">{{item.badge}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="layout-content">
|
||||
<div class="layout-content-inner">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.dark-mode {
|
||||
// Dark mode variables are applied via documentElement.classList.add('dark')
|
||||
@@ -13,42 +13,133 @@
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background-color: var(--surface-card);
|
||||
background-color: var(--surface-card) !important;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.06);
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.pi-sun {
|
||||
color: var(--yellow-500);
|
||||
}
|
||||
|
||||
.pi-moon {
|
||||
color: var(--indigo-300);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-toggle-mobile {
|
||||
display: none;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
// Desktop Sidebar
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--surface-overlay);
|
||||
border-right: 1px solid var(--surface-border);
|
||||
height: calc(100vh - 4rem); // Adjust based on your toolbar height
|
||||
position: sticky;
|
||||
top: 4rem; // Toolbar height
|
||||
z-index: 998;
|
||||
transition: width 0.3s;
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.app-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-left: 0.5rem;
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
|
||||
.app-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
|
||||
.menu-item {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
i {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.pi-sun {
|
||||
color: var(--yellow-500);
|
||||
}
|
||||
|
||||
.pi-moon {
|
||||
color: var(--blue-500);
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
gap: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&.active-menu-link {
|
||||
background-color: var(--primary-100);
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
|
||||
i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +148,10 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: calc(100vh - 4rem);
|
||||
padding: 1.5rem;
|
||||
|
||||
|
||||
.layout-content-inner {
|
||||
flex: 1;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
// PrimeNG Imports
|
||||
@@ -7,6 +7,15 @@ import { ButtonModule } from 'primeng/button';
|
||||
import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MenuModule } from 'primeng/menu';
|
||||
import { SidebarModule } from 'primeng/sidebar';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-main-layout',
|
||||
@@ -14,16 +23,28 @@ import { FormsModule } from '@angular/forms';
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
ButtonModule,
|
||||
ToolbarModule,
|
||||
InputSwitchModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
MenuModule,
|
||||
SidebarModule
|
||||
],
|
||||
templateUrl: './main-layout.component.html',
|
||||
styleUrl: './main-layout.component.scss'
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
darkMode = signal<boolean>(false);
|
||||
menuItems: MenuItem[] = [
|
||||
{ label: 'Dashboard', icon: 'pi pi-home', route: '/dashboard' },
|
||||
{ label: 'Logs', icon: 'pi pi-list', route: '/logs' },
|
||||
{ label: 'Settings', icon: 'pi pi-cog', route: '/settings' }
|
||||
];
|
||||
|
||||
// Mobile menu state
|
||||
mobileSidebarVisible = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Initialize theme based on system preference
|
||||
@@ -45,4 +66,8 @@ export class MainLayoutComponent {
|
||||
documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
toggleMobileSidebar(): void {
|
||||
this.mobileSidebarVisible.update(value => !value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
|
||||
// Services
|
||||
import { LogEntry, SignalrService } from '../../core/services/signalr.service';
|
||||
// Services & Models
|
||||
import { LogHubService } from '../../core/services/log-hub.service';
|
||||
import { LogEntry } from '../../core/models/signalr.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs-viewer',
|
||||
@@ -34,12 +35,12 @@ import { LogEntry, SignalrService } from '../../core/services/signalr.service';
|
||||
TooltipModule,
|
||||
ProgressSpinnerModule
|
||||
],
|
||||
providers: [SignalrService],
|
||||
providers: [LogHubService],
|
||||
templateUrl: './logs-viewer.component.html',
|
||||
styleUrl: './logs-viewer.component.scss'
|
||||
})
|
||||
export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
private signalrService = inject(SignalrService);
|
||||
private logHubService = inject(LogHubService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Signals for reactive state
|
||||
@@ -87,18 +88,18 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
// Connect to SignalR hub
|
||||
this.signalrService.startConnection()
|
||||
.catch((error: Error) => console.error('Failed to connect to SignalR hub:', error));
|
||||
this.logHubService.startConnection()
|
||||
.catch((error: Error) => console.error('Failed to connect to log hub:', error));
|
||||
|
||||
// Subscribe to logs
|
||||
this.signalrService.getLogs()
|
||||
this.logHubService.getLogs()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((logs: LogEntry[]) => {
|
||||
this.logs.set(logs);
|
||||
});
|
||||
|
||||
// Subscribe to connection status
|
||||
this.signalrService.getConnectionStatus()
|
||||
this.logHubService.getConnectionStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((status: boolean) => {
|
||||
this.isConnected.set(status);
|
||||
@@ -150,7 +151,7 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.signalrService.requestRecentLogs();
|
||||
this.logHubService.requestRecentLogs();
|
||||
}
|
||||
|
||||
hasJobInfo(): boolean {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="settings-container">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<p-accordion [multiple]="true" [activeIndex]="[0]">
|
||||
<p-accordionTab header="API Connection Settings">
|
||||
<div class="field-row">
|
||||
<label for="apiUrl">API URL</label>
|
||||
<input id="apiUrl" type="text" pInputText [(ngModel)]="apiUrl" />
|
||||
</div>
|
||||
</p-accordionTab>
|
||||
|
||||
<p-accordionTab header="Logging Settings">
|
||||
<div class="field-row">
|
||||
<label for="enableLogs">Enable Logging</label>
|
||||
<p-inputSwitch id="enableLogs" [(ngModel)]="enableLogs"></p-inputSwitch>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="enableNotifications">Show Log Notifications</label>
|
||||
<p-inputSwitch id="enableNotifications" [(ngModel)]="enableNotifications"></p-inputSwitch>
|
||||
</div>
|
||||
</p-accordionTab>
|
||||
|
||||
<p-accordionTab header="UI Settings">
|
||||
<div class="field-row">
|
||||
<label for="autoRefresh">Auto-refresh Logs</label>
|
||||
<p-inputSwitch id="autoRefresh" [(ngModel)]="autoRefresh"></p-inputSwitch>
|
||||
</div>
|
||||
|
||||
<div class="field-row" *ngIf="autoRefresh">
|
||||
<label for="refreshInterval">Refresh Interval (seconds)</label>
|
||||
<input id="refreshInterval" type="number" pInputText [(ngModel)]="refreshInterval" min="5" max="300" />
|
||||
</div>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
|
||||
<div class="button-container">
|
||||
<button pButton label="Save Changes" icon="pi pi-save" class="p-button-success"></button>
|
||||
<button pButton label="Reset to Defaults" icon="pi pi-refresh" class="p-button-secondary p-button-outlined"></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
.settings-container {
|
||||
padding: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SettingsPageComponent } from './settings-page.component';
|
||||
|
||||
describe('SettingsPageComponent', () => {
|
||||
let component: SettingsPageComponent;
|
||||
let fixture: ComponentFixture<SettingsPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsPageComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
InputSwitchModule,
|
||||
ButtonModule,
|
||||
AccordionModule
|
||||
],
|
||||
templateUrl: './settings-page.component.html',
|
||||
styleUrl: './settings-page.component.scss'
|
||||
})
|
||||
export class SettingsPageComponent {
|
||||
// Sample settings
|
||||
apiUrl = 'http://localhost:5000';
|
||||
enableLogs = true;
|
||||
enableNotifications = true;
|
||||
autoRefresh = false;
|
||||
refreshInterval = 30;
|
||||
}
|
||||
7
code/UI/src/environments/environment.prod.ts
Normal file
7
code/UI/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Environment configuration for production
|
||||
*/
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: '' // Set this dynamically or through build configuration
|
||||
};
|
||||
7
code/UI/src/environments/environment.ts
Normal file
7
code/UI/src/environments/environment.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Environment configuration for development
|
||||
*/
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:5000'
|
||||
};
|
||||
Reference in New Issue
Block a user