mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-23 22:18:39 -05:00
Add on-demand job triggers (#310)
This commit is contained in:
@@ -76,63 +76,23 @@ public class JobsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/stop")]
|
||||
public async Task<IActionResult> StopJob(JobType jobType)
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.StopJob(jobType);
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to stop job '{jobType}'");
|
||||
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/pause")]
|
||||
public async Task<IActionResult> PauseJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.PauseJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to pause job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/resume")]
|
||||
public async Task<IActionResult> ResumeJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.ResumeJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to resume job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
|
||||
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,16 +231,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Schedule the main trigger
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
|
||||
// Trigger immediate execution for startup using a one-time trigger
|
||||
var startupTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression}",
|
||||
typeName, cronExpression);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
|
||||
@@ -24,12 +28,39 @@ public sealed class GenericJob<T> : IJob
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
|
||||
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
|
||||
{
|
||||
try
|
||||
{
|
||||
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
|
||||
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
|
||||
|
||||
if (isFinished)
|
||||
{
|
||||
jobInfo.Status = "Scheduled";
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to broadcast job status update");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -13,16 +14,18 @@ namespace Cleanuparr.Infrastructure.Hubs;
|
||||
/// </summary>
|
||||
public class AppHub : Hub
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly ILogger<AppHub> _logger;
|
||||
private readonly EventsContext _context;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SignalRLogSink _logSink;
|
||||
private readonly AppStatusSnapshot _statusSnapshot;
|
||||
|
||||
public AppHub(EventsContext context, ILogger<AppHub> logger, AppStatusSnapshot statusSnapshot)
|
||||
public AppHub(EventsContext context, ILogger<AppHub> logger, AppStatusSnapshot statusSnapshot, IJobManagementService jobManagementService)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_statusSnapshot = statusSnapshot;
|
||||
_jobManagementService = jobManagementService;
|
||||
_logSink = SignalRLogSink.Instance;
|
||||
}
|
||||
|
||||
@@ -39,7 +42,7 @@ public class AppHub : Hub
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send recent logs to client {connectionId}", Context.ConnectionId);
|
||||
_logger.LogError(ex, "Failed to send recent logs to client");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +63,23 @@ public class AppHub : Hub
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send recent events to client {connectionId}", Context.ConnectionId);
|
||||
_logger.LogError(ex, "Failed to send recent events to client");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client requests current job statuses
|
||||
/// </summary>
|
||||
public async Task GetJobStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobs = await _jobManagementService.GetAllJobs();
|
||||
await Clients.All.SendAsync("JobsStatusUpdate", jobs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send job status to client");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ public interface IJobManagementService
|
||||
{
|
||||
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
|
||||
Task<bool> StopJob(JobType jobType);
|
||||
Task<bool> PauseJob(JobType jobType);
|
||||
Task<bool> ResumeJob(JobType jobType);
|
||||
Task<bool> TriggerJobOnce(JobType jobType);
|
||||
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
|
||||
Task<JobInfo> GetJob(JobType jobType);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
@@ -13,12 +14,14 @@ public class JobManagementService : IJobManagementService
|
||||
{
|
||||
private readonly ILogger<JobManagementService> _logger;
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly IHubContext<Hubs.AppHub> _hubContext;
|
||||
private readonly ConcurrentDictionary<string, JobKey> _jobKeys = new();
|
||||
|
||||
public JobManagementService(ILogger<JobManagementService> logger, ISchedulerFactory schedulerFactory)
|
||||
public JobManagementService(ILogger<JobManagementService> logger, ISchedulerFactory schedulerFactory, IHubContext<Hubs.AppHub> hubContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null)
|
||||
@@ -192,8 +195,7 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<bool> StopJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
@@ -221,56 +223,6 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PauseJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobName);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
_logger.LogError("Job {name} does not exist", jobName);
|
||||
return false;
|
||||
}
|
||||
|
||||
await scheduler.PauseJob(jobKey);
|
||||
_logger.LogInformation("Job {name} paused successfully", jobName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error pausing job {jobName}", jobName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ResumeJob(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobName);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
_logger.LogError("Job {name} does not exist", jobName);
|
||||
return false;
|
||||
}
|
||||
|
||||
await scheduler.ResumeJob(jobKey);
|
||||
_logger.LogInformation("Job {name} resumed successfully", jobName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resuming job {name}", jobName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null)
|
||||
{
|
||||
try
|
||||
@@ -289,7 +241,7 @@ public class JobManagementService : IJobManagementService
|
||||
var jobInfo = new JobInfo
|
||||
{
|
||||
Name = jobKey.Name,
|
||||
JobType = jobDetail.JobType.Name,
|
||||
JobType = jobKey.Name, // Use the job key name instead of generic type
|
||||
Status = "Not Scheduled"
|
||||
};
|
||||
|
||||
@@ -300,12 +252,12 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
jobInfo.Status = triggerState switch
|
||||
{
|
||||
TriggerState.Normal => "Running",
|
||||
TriggerState.Normal => "Scheduled",
|
||||
TriggerState.Paused => "Paused",
|
||||
TriggerState.Complete => "Complete",
|
||||
TriggerState.Error => "Error",
|
||||
TriggerState.Blocked => "Blocked",
|
||||
TriggerState.None => "None",
|
||||
TriggerState.Blocked => "Running",
|
||||
TriggerState.None => "Not Scheduled",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
@@ -351,7 +303,7 @@ public class JobManagementService : IJobManagementService
|
||||
var jobInfo = new JobInfo
|
||||
{
|
||||
Name = jobName,
|
||||
JobType = jobDetail.JobType.Name,
|
||||
JobType = jobName, // Use the job key name instead of generic type
|
||||
Status = "Not Scheduled"
|
||||
};
|
||||
|
||||
@@ -362,12 +314,12 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
jobInfo.Status = state switch
|
||||
{
|
||||
TriggerState.Normal => "Running",
|
||||
TriggerState.Normal => "Scheduled", // Normal means trigger is scheduled and ready to fire
|
||||
TriggerState.Paused => "Paused",
|
||||
TriggerState.Complete => "Complete",
|
||||
TriggerState.Error => "Error",
|
||||
TriggerState.Blocked => "Blocked",
|
||||
TriggerState.None => "None",
|
||||
TriggerState.Blocked => "Running", // Blocked typically means job is currently executing
|
||||
TriggerState.None => "Not Scheduled",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
@@ -405,6 +357,7 @@ public class JobManagementService : IJobManagementService
|
||||
|
||||
await TriggerJobImmediately(scheduler, jobKey, "manual");
|
||||
_logger.LogInformation("Job {name} triggered for one-time execution", jobName);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
34
code/frontend/src/app/core/models/job.models.ts
Normal file
34
code/frontend/src/app/core/models/job.models.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface JobInfo {
|
||||
name: string;
|
||||
status: string;
|
||||
schedule: string;
|
||||
nextRunTime?: Date;
|
||||
previousRunTime?: Date;
|
||||
jobType: string;
|
||||
}
|
||||
|
||||
export enum JobType {
|
||||
QueueCleaner = 'QueueCleaner',
|
||||
MalwareBlocker = 'MalwareBlocker',
|
||||
DownloadCleaner = 'DownloadCleaner',
|
||||
BlacklistSynchronizer = 'BlacklistSynchronizer'
|
||||
}
|
||||
|
||||
export interface JobSchedule {
|
||||
every: number;
|
||||
type: ScheduleType;
|
||||
}
|
||||
|
||||
export enum ScheduleType {
|
||||
Minutes = 'Minutes',
|
||||
Hours = 'Hours',
|
||||
Days = 'Days'
|
||||
}
|
||||
|
||||
export interface JobAction {
|
||||
label: string;
|
||||
icon: string;
|
||||
action: (jobType: JobType) => void;
|
||||
disabled?: (job: JobInfo) => boolean;
|
||||
severity?: 'primary' | 'secondary' | 'success' | 'info' | 'warn' | 'danger';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as signalR from '@microsoft/signalr';
|
||||
import { LogEntry } from '../models/signalr.models';
|
||||
import { AppEvent } from '../models/event.models';
|
||||
import { AppStatus } from '../models/app-status.model';
|
||||
import { JobInfo } from '../models/job.models';
|
||||
import { ApplicationPathService } from './base-path.service';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ export class AppHubService {
|
||||
private logsSubject = new BehaviorSubject<LogEntry[]>([]);
|
||||
private eventsSubject = new BehaviorSubject<AppEvent[]>([]);
|
||||
private appStatusSubject = new BehaviorSubject<AppStatus | null>(null);
|
||||
private jobsSubject = new BehaviorSubject<JobInfo[]>([]);
|
||||
private readonly ApplicationPathService = inject(ApplicationPathService);
|
||||
|
||||
private logBuffer: LogEntry[] = [];
|
||||
@@ -128,6 +130,26 @@ export class AppHubService {
|
||||
|
||||
this.appStatusSubject.next(normalized);
|
||||
});
|
||||
|
||||
// Handle job status updates
|
||||
this.hubConnection.on('JobsStatusUpdate', (jobs: JobInfo[]) => {
|
||||
if (jobs) {
|
||||
this.jobsSubject.next(jobs);
|
||||
}
|
||||
});
|
||||
|
||||
this.hubConnection.on('JobStatusUpdate', (job: JobInfo) => {
|
||||
if (job) {
|
||||
const currentJobs = this.jobsSubject.value;
|
||||
const jobIndex = currentJobs.findIndex(j => j.name === job.name);
|
||||
if (jobIndex !== -1) {
|
||||
currentJobs[jobIndex] = job;
|
||||
this.jobsSubject.next([...currentJobs]);
|
||||
} else {
|
||||
this.jobsSubject.next([...currentJobs, job]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +158,7 @@ export class AppHubService {
|
||||
private requestInitialData(): void {
|
||||
this.requestRecentLogs();
|
||||
this.requestRecentEvents();
|
||||
this.requestJobStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +247,31 @@ export class AppHubService {
|
||||
public getEvents(): Observable<AppEvent[]> {
|
||||
return this.eventsSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs as an observable
|
||||
*/
|
||||
public getJobs(): Observable<JobInfo[]> {
|
||||
return this.jobsSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs connection status as an observable
|
||||
* For consistency with logs and events connection status
|
||||
*/
|
||||
public getJobsConnectionStatus(): Observable<boolean> {
|
||||
return this.connectionStatusSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request job status from the server
|
||||
*/
|
||||
public requestJobStatus(): void {
|
||||
if (this.isConnected()) {
|
||||
this.hubConnection.invoke('GetJobStatus')
|
||||
.catch(err => console.error('Error requesting job status:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status as an observable
|
||||
|
||||
64
code/frontend/src/app/core/services/jobs.service.ts
Normal file
64
code/frontend/src/app/core/services/jobs.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ApplicationPathService } from './base-path.service';
|
||||
import { JobInfo, JobType } from '../models/job.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class JobsService {
|
||||
private http = inject(HttpClient);
|
||||
private pathService = inject(ApplicationPathService);
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.pathService.buildApiUrl('/jobs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs information
|
||||
*/
|
||||
getAllJobs(): Observable<JobInfo[]> {
|
||||
return this.http.get<JobInfo[]>(this.baseUrl)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job information
|
||||
*/
|
||||
getJob(jobType: JobType): Observable<JobInfo> {
|
||||
return this.http.get<JobInfo>(`${this.baseUrl}/${jobType}`)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a job with optional schedule
|
||||
*/
|
||||
startJob(jobType: JobType, schedule?: any): Observable<any> {
|
||||
const body = schedule ? { schedule } : {};
|
||||
return this.http.post(`${this.baseUrl}/${jobType}/start`, body)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a job for one-time execution
|
||||
*/
|
||||
triggerJob(jobType: JobType): Observable<any> {
|
||||
return this.http.post(`${this.baseUrl}/${jobType}/trigger`, {})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update job schedule
|
||||
*/
|
||||
updateJobSchedule(jobType: JobType, schedule: any): Observable<any> {
|
||||
return this.http.put(`${this.baseUrl}/${jobType}/schedule`, { schedule })
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
console.error('Jobs service error:', error);
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
@@ -33,42 +33,45 @@
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="timeline-container" *ngIf="displayLogs().length > 0; else noLogsTemplate">
|
||||
<div class="timeline-item" *ngFor="let log of displayLogs(); let i = index">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-icon" [ngClass]="getLogIconClass(log.level)">
|
||||
<i [class]="getLogIcon(log.level)"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex align-items-start justify-content-between mb-1">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-tag [severity]="getLogSeverity(log.level)" [value]="log.level"></p-tag>
|
||||
<span class="text-xs text-color-secondary" *ngIf="log.category">{{log.category}}</span>
|
||||
<!-- Scrollable Logs View -->
|
||||
<div class="viewer-console">
|
||||
<div class="timeline-container" *ngIf="displayLogs().length > 0; else noLogsTemplate">
|
||||
<div class="timeline-item" *ngFor="let log of displayLogs(); let i = index">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-icon" [ngClass]="getLogIconClass(log.level)">
|
||||
<i [class]="getLogIcon(log.level)"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex align-items-start justify-content-between mb-1">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-tag [severity]="getLogSeverity(log.level)" [value]="log.level"></p-tag>
|
||||
<span class="text-xs text-color-secondary" *ngIf="log.category">{{log.category}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{ log.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="log.message"
|
||||
tooltipPosition="top"
|
||||
[showDelay]="500">
|
||||
{{truncateMessage(log.message)}}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary mt-1" *ngIf="log.jobName">
|
||||
Job: {{log.jobName}}
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{ log.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="log.message"
|
||||
tooltipPosition="top"
|
||||
[showDelay]="500">
|
||||
{{truncateMessage(log.message)}}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary mt-1" *ngIf="log.jobName">
|
||||
Job: {{log.jobName}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noLogsTemplate>
|
||||
<div class="empty-state text-center py-4">
|
||||
<i class="pi pi-list text-4xl text-color-secondary mb-3"></i>
|
||||
<p class="text-color-secondary">No recent logs available</p>
|
||||
<p-progressSpinner *ngIf="!connected()" styleClass="w-2rem h-2rem"></p-progressSpinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #noLogsTemplate>
|
||||
<div class="empty-state text-center py-4">
|
||||
<i class="pi pi-list text-4xl text-color-secondary mb-3"></i>
|
||||
<p class="text-color-secondary">No recent logs available</p>
|
||||
<p-progressSpinner *ngIf="!connected()" styleClass="w-2rem h-2rem"></p-progressSpinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-footer mt-3">
|
||||
<button pButton label="View All Logs" icon="pi pi-arrow-right" routerLink="/logs" iconPos="right" class="p-button-outlined"></button>
|
||||
</div>
|
||||
@@ -77,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Recent Events Card -->
|
||||
<div class="col-12 lg:col-6">
|
||||
<div class="col-12 lg:col-6 events-card">
|
||||
<p-card styleClass="dashboard-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
@@ -99,47 +102,55 @@
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="timeline-container" *ngIf="displayEvents().length > 0; else noEventsTemplate">
|
||||
<div class="timeline-item" *ngFor="let event of displayEvents(); let i = index">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-icon" [ngClass]="getEventIconClass(event.eventType, event.severity)">
|
||||
<i [class]="getEventIcon(event.eventType)"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex align-items-start justify-content-between mb-1">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-tag [severity]="getEventSeverity(event.severity)" [value]="event.severity"></p-tag>
|
||||
<span class="text-xs text-color-secondary">{{formatEventType(event.eventType)}}</span>
|
||||
<!-- Scrollable Events View -->
|
||||
<div class="viewer-console">
|
||||
<div class="timeline-container" *ngIf="displayEvents().length > 0; else noEventsTemplate">
|
||||
<div class="timeline-item" *ngFor="let event of displayEvents(); let i = index">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-icon" [ngClass]="getEventIconClass(event.eventType, event.severity)">
|
||||
<i [class]="getEventIcon(event.eventType)"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex align-items-start justify-content-between mb-1">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-tag [severity]="getEventSeverity(event.severity)" [value]="event.severity"></p-tag>
|
||||
<span class="text-xs text-color-secondary">{{formatEventType(event.eventType)}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="event.message"
|
||||
tooltipPosition="top"
|
||||
[showDelay]="500">
|
||||
{{truncateMessage(event.message)}}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary mt-1" *ngIf="event.trackingId">
|
||||
Tracking: {{event.trackingId}}
|
||||
</div>
|
||||
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
|
||||
</div>
|
||||
<div class="timeline-message"
|
||||
[pTooltip]="event.message"
|
||||
tooltipPosition="top"
|
||||
[showDelay]="500">
|
||||
{{truncateMessage(event.message)}}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary mt-1" *ngIf="event.trackingId">
|
||||
Tracking: {{event.trackingId}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noEventsTemplate>
|
||||
<div class="empty-state text-center py-4">
|
||||
<i class="pi pi-calendar text-4xl text-color-secondary mb-3"></i>
|
||||
<p class="text-color-secondary">No recent events available</p>
|
||||
<p-progressSpinner *ngIf="!connected()" styleClass="w-2rem h-2rem"></p-progressSpinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #noEventsTemplate>
|
||||
<div class="empty-state text-center py-4">
|
||||
<i class="pi pi-calendar text-4xl text-color-secondary mb-3"></i>
|
||||
<p class="text-color-secondary">No recent events available</p>
|
||||
<p-progressSpinner *ngIf="!connected()" styleClass="w-2rem h-2rem"></p-progressSpinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-footer mt-3">
|
||||
<button pButton label="View All Events" icon="pi pi-arrow-right" routerLink="/events" iconPos="right" class="p-button-outlined"></button>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Management Card -->
|
||||
<div class="col-12">
|
||||
<app-jobs-management></app-jobs-management>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -94,16 +96,19 @@
|
||||
}
|
||||
|
||||
.p-card-body {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-card-content {
|
||||
flex-grow: 1;
|
||||
padding: 1.5rem;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +206,21 @@
|
||||
|
||||
/* Card content styling */
|
||||
.card-content {
|
||||
min-height: 180px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 1.5rem;
|
||||
|
||||
// Console style viewer for dashboard cards
|
||||
.viewer-console {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@@ -343,6 +358,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.events-card {
|
||||
.timeline-item {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Log icon styling
|
||||
.log-icon-error {
|
||||
background-color: var(--red-100);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { GeneralConfig } from '../../shared/models/general-config.model';
|
||||
|
||||
// Components
|
||||
import { SupportSectionComponent } from '../../shared/components/support-section/support-section.component';
|
||||
import { JobsManagementComponent } from '../../shared/components/jobs-management/jobs-management.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-page',
|
||||
@@ -33,7 +34,8 @@ import { SupportSectionComponent } from '../../shared/components/support-section
|
||||
TagModule,
|
||||
TooltipModule,
|
||||
ProgressSpinnerModule,
|
||||
SupportSectionComponent
|
||||
SupportSectionComponent,
|
||||
JobsManagementComponent
|
||||
],
|
||||
templateUrl: './dashboard-page.component.html',
|
||||
styleUrl: './dashboard-page.component.scss'
|
||||
@@ -53,13 +55,13 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
|
||||
displayLogs = computed(() => {
|
||||
return this.recentLogs()
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
|
||||
.slice(-5); // Take the last 5 (most recent);
|
||||
.slice(-5); // Take the last 10 (most recent);
|
||||
});
|
||||
|
||||
displayEvents = computed(() => {
|
||||
return this.recentEvents()
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
|
||||
.slice(-5); // Take the last 5 (most recent)
|
||||
.slice(-5); // Take the last 10 (most recent)
|
||||
});
|
||||
|
||||
// Computed value for showing support section
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<p-card styleClass="jobs-management-card">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Jobs Overview</h2>
|
||||
<p-tag
|
||||
[severity]="connected() ? 'success' : 'danger'"
|
||||
[value]="connected() ? 'Connected' : 'Disconnected'"
|
||||
[pTooltip]="connected() ? 'Connected to app hub' : 'Attempting to reconnect...'"
|
||||
tooltipPosition="right"
|
||||
styleClass="status-tag"
|
||||
></p-tag>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="jobs-content">
|
||||
<!-- Loading State -->
|
||||
<div *ngIf="loading() && jobs().length === 0" class="text-center py-4">
|
||||
<p-progressSpinner styleClass="w-3rem h-3rem"></p-progressSpinner>
|
||||
<p class="text-color-secondary mt-2">Loading jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<p-table
|
||||
*ngIf="!loading() || jobs().length > 0"
|
||||
[value]="jobs()"
|
||||
[loading]="loading()"
|
||||
styleClass="p-datatable-sm"
|
||||
[scrollable]="true"
|
||||
responsiveLayout="scroll">
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th>Job Name</th>
|
||||
<th>Status</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next Run</th>
|
||||
<th class="text-center">Actions</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-job>
|
||||
<tr>
|
||||
<!-- Job Name -->
|
||||
<td>
|
||||
<div class="flex align-items-center">
|
||||
<i class="pi pi-calendar-clock mr-2 text-primary"></i>
|
||||
<span class="font-semibold">{{ getJobDisplayName(job.name) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td>
|
||||
<p-tag
|
||||
[value]="job.status"
|
||||
[severity]="getStatusSeverity(job.status)">
|
||||
</p-tag>
|
||||
</td>
|
||||
|
||||
<!-- Schedule -->
|
||||
<td>
|
||||
<span
|
||||
class="text-sm text-color-secondary"
|
||||
[pTooltip]="job.schedule || 'No schedule configured'"
|
||||
tooltipPosition="top">
|
||||
{{ job.schedule || 'Not scheduled' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Next Run -->
|
||||
<td>
|
||||
<span class="text-sm">
|
||||
{{ formatDateTime(job.nextRunTime) }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-center">
|
||||
<div class="flex align-items-center justify-content-center gap-1">
|
||||
<button
|
||||
*ngFor="let action of jobActions"
|
||||
pButton
|
||||
[icon]="action.icon"
|
||||
[pTooltip]="action.label"
|
||||
tooltipPosition="top"
|
||||
[disabled]="action.disabled ? action.disabled(job) : false"
|
||||
[class]="'p-button-sm p-button-outlined p-button-' + (action.severity || 'secondary')"
|
||||
(click)="action.action(getJobTypeEnum(job.jobType))"
|
||||
></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-calendar-clock text-4xl text-color-secondary mb-3"></i>
|
||||
<p class="text-color-secondary mb-0">No jobs configured</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</p-card>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '../../../dashboard/dashboard-page/dashboard-page.component.scss';
|
||||
|
||||
::ng-deep {
|
||||
.p-datatable {
|
||||
.p-datatable-thead {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.p-datatable-tbody {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.p-datatable-thead > tr > th {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr > td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Component, OnInit, OnDestroy, signal, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, takeUntil, finalize } from 'rxjs';
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { TagModule } from 'primeng/tag';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
|
||||
// Services & Models
|
||||
import { JobsService } from '../../../core/services/jobs.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { AppHubService } from '../../../core/services/app-hub.service';
|
||||
import { JobInfo, JobType, JobAction } from '../../../core/models/job.models';
|
||||
import { ConfirmationService } from 'primeng/api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-jobs-management',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
CardModule,
|
||||
ButtonModule,
|
||||
TagModule,
|
||||
TooltipModule,
|
||||
TableModule,
|
||||
ProgressSpinnerModule,
|
||||
ConfirmDialogModule
|
||||
],
|
||||
providers: [ConfirmationService],
|
||||
templateUrl: './jobs-management.component.html',
|
||||
styleUrl: './jobs-management.component.scss'
|
||||
})
|
||||
export class JobsManagementComponent implements OnInit, OnDestroy {
|
||||
private jobsService = inject(JobsService);
|
||||
private notificationService = inject(NotificationService);
|
||||
private appHubService = inject(AppHubService);
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Expose JobType for template
|
||||
JobType = JobType;
|
||||
|
||||
// Signals for reactive state
|
||||
jobs = signal<JobInfo[]>([]);
|
||||
loading = signal<boolean>(false);
|
||||
connected = signal<boolean>(false);
|
||||
|
||||
// Job actions configuration
|
||||
jobActions: JobAction[] = [
|
||||
{
|
||||
label: 'Run Now',
|
||||
icon: 'pi pi-play',
|
||||
severity: 'success',
|
||||
action: (jobType: JobType) => this.triggerJob(jobType),
|
||||
disabled: (job: JobInfo) => job.status === 'Error'
|
||||
}
|
||||
];
|
||||
|
||||
ngOnInit() {
|
||||
this.initializeJobsData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeJobsData(): void {
|
||||
// Subscribe to connection status
|
||||
this.appHubService.getJobsConnectionStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (connected) => {
|
||||
this.connected.set(connected);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to real-time job updates via SignalR
|
||||
this.appHubService.getJobs()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (jobs) => {
|
||||
this.jobs.set(jobs);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error receiving job updates:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
triggerJob(jobType: JobType): void {
|
||||
const jobName = this.getJobDisplayName(jobType);
|
||||
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to trigger ${jobName} to run now?`,
|
||||
header: 'Trigger Job',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptButtonStyleClass: 'p-button-success',
|
||||
accept: () => {
|
||||
this.loading.set(true);
|
||||
this.jobsService.triggerJob(jobType)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
finalize(() => this.loading.set(false))
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.notificationService.showSuccess(`${jobName} triggered successfully`);
|
||||
// Job status will be updated automatically via SignalR
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to trigger job:', error);
|
||||
this.notificationService.showError(`Failed to trigger ${jobName}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getJobDisplayName(jobName: string): string {
|
||||
switch (jobName) {
|
||||
case 'QueueCleaner':
|
||||
return 'Queue Cleaner';
|
||||
case 'MalwareBlocker':
|
||||
return 'Malware Blocker';
|
||||
case 'DownloadCleaner':
|
||||
return 'Download Cleaner';
|
||||
case 'BlacklistSynchronizer':
|
||||
return 'Blacklist Synchronizer';
|
||||
default:
|
||||
return jobName;
|
||||
}
|
||||
}
|
||||
|
||||
getJobTypeEnum(jobTypeString: string): JobType {
|
||||
return jobTypeString as JobType;
|
||||
}
|
||||
|
||||
getStatusSeverity(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'scheduled':
|
||||
return 'info';
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'paused':
|
||||
return 'warn';
|
||||
case 'error':
|
||||
return 'danger';
|
||||
case 'complete':
|
||||
return 'success';
|
||||
case 'not scheduled':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
formatDateTime(date?: Date): string {
|
||||
if (!date) return 'Never';
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user