Add on-demand job triggers (#310)

This commit is contained in:
Copilot
2025-10-01 10:40:58 +03:00
committed by Flaminel
parent d224b2dea0
commit 128e7e5f11
16 changed files with 632 additions and 195 deletions

View File

@@ -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}'");
}
}

View File

@@ -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);
}

View File

@@ -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");
}
}
}

View File

@@ -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>

View File

@@ -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");
}
}

View File

@@ -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);

View File

@@ -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)

View 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';
}

View File

@@ -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

View 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);
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}