diff --git a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs index 18c30a4d..5bcff086 100644 --- a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs +++ b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs @@ -138,6 +138,10 @@ public static void MapDashboardEndpoints(this IEndpoin .WithName("UpdateCronTicker") .WithSummary("Update cron ticker"); + apiGroup.MapPut("/cron-ticker/toggle", ToggleCronTicker) + .WithName("ToggleCronTicker") + .WithSummary("Toggle cron ticker enabled/disabled"); + apiGroup.MapPost("/cron-ticker/run", RunCronTickerOnDemand) .WithName("RunCronTickerOnDemand") .WithSummary("Run cron ticker on demand"); @@ -551,6 +555,23 @@ private static async Task UpdateCronTicker( }, dashboardOptions.DashboardJsonOptions); } + private static async Task ToggleCronTicker( + Guid id, + bool isEnabled, + ITickerDashboardRepository repository, + DashboardOptionsBuilder dashboardOptions, + CancellationToken cancellationToken) + where TTimeTicker : TimeTickerEntity, new() + where TCronTicker : CronTickerEntity, new() + { + var success = await repository.ToggleCronTickerAsync(id, isEnabled, cancellationToken); + + return Results.Json(new { + success, + message = success ? $"Cron ticker {(isEnabled ? "enabled" : "disabled")} successfully" : "Failed to toggle cron ticker" + }, dashboardOptions.DashboardJsonOptions); + } + private static async Task RunCronTickerOnDemand( Guid id, ITickerDashboardRepository repository, diff --git a/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs b/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs index 0794defa..c04fd0a8 100644 --- a/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs +++ b/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs @@ -569,6 +569,26 @@ public async Task> GetCronTickersOccurrence return finalData; } + public async Task ToggleCronTickerAsync(Guid id, bool isEnabled, CancellationToken cancellationToken) + { + var cronTicker = await _persistenceProvider.GetCronTickerById(id, cancellationToken); + if (cronTicker == null) + return false; + + cronTicker.IsEnabled = isEnabled; + cronTicker.UpdatedAt = DateTime.UtcNow; + + var affectedRows = await _persistenceProvider.UpdateCronTickers([cronTicker], cancellationToken); + + if (affectedRows > 0) + { + _tickerQHostScheduler.Restart(); + await _notificationHubSender.UpdateCronTickerNotifyAsync(cronTicker); + } + + return affectedRows > 0; + } + public bool CancelTickerById(Guid tickerId) => TickerCancellationTokenManager.RequestTickerCancellationById(tickerId); diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts index dca0a2ff..af2228d1 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts @@ -176,6 +176,16 @@ const getTimeTickersGraphData = () => { }; } +const toggleCronTicker = () => { + const baseHttp = useBaseHttpService('single') + const requestAsync = async (id: string, isEnabled: boolean) => (await baseHttp.sendAsync("PUT", "cron-ticker/toggle", { paramData: { id, isEnabled } })); + + return { + ...baseHttp, + requestAsync + }; +} + export const cronTickerService = { getCronTickers, getCronTickersPaginated, @@ -183,6 +193,7 @@ export const cronTickerService = { addCronTicker, deleteCronTicker, runCronTickerOnDemand, + toggleCronTicker, getTimeTickersGraphDataRange, getTimeTickersGraphDataRangeById, getTimeTickersGraphData diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/types/cronTickerService.types.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/types/cronTickerService.types.ts index 3b3b5067..8d42d102 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/types/cronTickerService.types.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/types/cronTickerService.types.ts @@ -12,6 +12,7 @@ export class GetCronTickerResponse { createdAt!:string; updatedAt!:string; retries!:number; + isEnabled!:boolean; actions:string|undefined = undefined; } @@ -22,6 +23,7 @@ export class UpdateCronTickerRequest { retries?: number; description?: string; intervals?:number[]; + isEnabled?: boolean; } export class GetCronTickerGraphDataRangeResponse{ diff --git a/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue b/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue index f6e95567..67d5d7a1 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue @@ -42,6 +42,7 @@ const getCronTickerRangeGraphDataById = cronTickerService.getTimeTickersGraphDat const getCronTickersGraphDataAndParseToGraph = cronTickerService.getTimeTickersGraphData() const deleteCronTicker = cronTickerService.deleteCronTicker() const runCronTickerOnDemand = cronTickerService.runCronTickerOnDemand() +const toggleCronTicker = cronTickerService.toggleCronTicker() // Pagination state const currentPage = ref(1) @@ -76,6 +77,9 @@ const handlePageSizeChange = async (size: number) => { const confirmDialog = useDialog().withComponent( () => import('@/components/common/ConfirmDialog.vue'), ) +const toggleConfirmDialog = useDialog().withComponent( + () => import('@/components/common/ConfirmDialog.vue'), +) const cronOccurrenceDialog = useDialog<{ id: string retries: number @@ -468,6 +472,36 @@ const RunCronTickerOnDemand = async (id: string) => { await loadPageData() } +const ToggleCronTickerEnabled = (id: string, isEnabled: boolean) => { + toggleConfirmDialog.open({ + ...new ConfirmDialogProps(), + id, + isEnabled, + icon: isEnabled ? 'mdi-power-off' : 'mdi-power', + iconColor: isEnabled ? '#F44336' : '#4CAF50', + title: isEnabled ? 'Disable Cron Ticker' : 'Enable Cron Ticker', + text: isEnabled + ? 'Are you sure you want to disable this cron ticker? It will stop generating new occurrences.' + : 'Are you sure you want to enable this cron ticker? It will start generating occurrences again.', + confirmText: isEnabled ? 'Disable' : 'Enable', + confirmColor: isEnabled ? '#F44336' : '#4CAF50', + }) +} + +const onSubmitToggleConfirmDialog = async () => { + try { + const id = toggleConfirmDialog.propData?.id! + const isEnabled = toggleConfirmDialog.propData?.isEnabled! + toggleConfirmDialog.close() + await toggleCronTicker.requestAsync(id, isEnabled) + await loadPageData() + } catch (error: any) { + if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') { + return + } + } +} + onMounted(async () => { try { isMounted.value = true @@ -1221,6 +1255,23 @@ const refreshData = async () => { @@ -1872,6 +1931,28 @@ const refreshData = async () => { border-color: rgba(255, 255, 255, 0.15); } +.enable-btn { + color: #4caf50; + border-color: rgba(76, 175, 80, 0.2); +} + +.enable-btn:hover { + border-color: rgba(76, 175, 80, 0.5); + box-shadow: 0 8px 25px rgba(76, 175, 80, 0.4); + background: rgba(76, 175, 80, 0.15); +} + +.disable-btn { + color: #ffa726; + border-color: rgba(255, 167, 38, 0.2); +} + +.disable-btn:hover { + border-color: rgba(255, 167, 38, 0.5); + box-shadow: 0 8px 25px rgba(255, 167, 38, 0.4); + background: rgba(255, 167, 38, 0.15); +} + .table-container { background: linear-gradient(135deg, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0.15) 100%); border-radius: 12px; @@ -2064,6 +2145,14 @@ const refreshData = async () => { background: rgba(229, 115, 115, 0.15); } +.btn-disabled { + color: #616161 !important; + border-color: rgba(97, 97, 97, 0.2) !important; + opacity: 0.5; + cursor: not-allowed !important; + pointer-events: none; +} + .expression-tooltip { cursor: help; text-decoration: underline; diff --git a/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs b/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs index 94bd9d32..ffcf2a5a 100644 --- a/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs +++ b/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs @@ -26,6 +26,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex("Function", "Expression") .HasDatabaseName("IX_Function_Expression"); + builder.Property(e => e.IsEnabled) + .IsRequired() + .HasDefaultValue(true); + builder.ToTable("CronTickers", _schema); } } diff --git a/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs b/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs index 5f625c0f..d7579cf6 100644 --- a/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs +++ b/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs @@ -347,6 +347,7 @@ public async Task GetAllCronTickerExpressions(CancellationTo var dbContext = session.Context; return await dbContext.Set() .AsNoTracking() + .Where(x => x.IsEnabled) .Select(MappingExtensions.ForCronTickerExpressions()) .ToArrayAsync(ct) .ConfigureAwait(false); @@ -361,6 +362,7 @@ public async Task GetAllCronTickerExpressions(CancellationTo var dbContext = session.Context; return await dbContext.Set() .AsNoTracking() + .Where(x => x.IsEnabled) .Select(MappingExtensions.ForCronTickerExpressions()) .ToArrayAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/TickerQ.EntityFrameworkCore/Infrastructure/MappingExtensions.cs b/src/TickerQ.EntityFrameworkCore/Infrastructure/MappingExtensions.cs index cc63f881..6419681c 100644 --- a/src/TickerQ.EntityFrameworkCore/Infrastructure/MappingExtensions.cs +++ b/src/TickerQ.EntityFrameworkCore/Infrastructure/MappingExtensions.cs @@ -18,7 +18,8 @@ public static Expression> ForCronTickerExpre Expression = e.Expression, Function = e.Function, RetryIntervals = e.RetryIntervals, - Retries = e.Retries + Retries = e.Retries, + IsEnabled = e.IsEnabled }; internal static Expression> ForQueueTimeTickers() diff --git a/src/TickerQ.Utilities/Entities/CronTickerEntity.cs b/src/TickerQ.Utilities/Entities/CronTickerEntity.cs index 98897ed8..13d0196b 100644 --- a/src/TickerQ.Utilities/Entities/CronTickerEntity.cs +++ b/src/TickerQ.Utilities/Entities/CronTickerEntity.cs @@ -9,5 +9,6 @@ public class CronTickerEntity : BaseTickerEntity public virtual byte[] Request { get; set; } public virtual int Retries { get; set; } public virtual int[] RetryIntervals { get; set; } + public virtual bool IsEnabled { get; set; } = true; } } \ No newline at end of file diff --git a/src/TickerQ.Utilities/Interfaces/ITickerDashboardRepository.cs b/src/TickerQ.Utilities/Interfaces/ITickerDashboardRepository.cs index c947c98a..a4538d3c 100644 --- a/src/TickerQ.Utilities/Interfaces/ITickerDashboardRepository.cs +++ b/src/TickerQ.Utilities/Interfaces/ITickerDashboardRepository.cs @@ -26,6 +26,7 @@ internal interface ITickerDashboardRepository Task[]> GetCronTickersOccurrencesAsync(Guid guid, CancellationToken cancellationToken = default); Task>> GetCronTickersOccurrencesPaginatedAsync(Guid guid, int pageNumber, int pageSize, CancellationToken cancellationToken = default); Task> GetCronTickersOccurrencesGraphDataAsync(Guid guid, CancellationToken cancellationToken = default); + Task ToggleCronTickerAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default); bool CancelTickerById(Guid tickerId); Task DeleteCronTickerOccurrenceByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<(string, int)> GetTickerRequestByIdAsync(Guid tickerId, TickerType tickerType, CancellationToken cancellationToken = default); diff --git a/src/TickerQ/Src/Provider/TickerInMemoryPersistenceProvider.cs b/src/TickerQ/Src/Provider/TickerInMemoryPersistenceProvider.cs index 39544c40..45ff9dbd 100644 --- a/src/TickerQ/Src/Provider/TickerInMemoryPersistenceProvider.cs +++ b/src/TickerQ/Src/Provider/TickerInMemoryPersistenceProvider.cs @@ -563,9 +563,10 @@ public Task MigrateDefinedCronTickers((string Function, string Expression)[] cro public Task GetAllCronTickerExpressions(CancellationToken cancellationToken) { var result = CronTickers.Values + .Where(x => x.IsEnabled) .Cast() .ToArray(); - + return Task.FromResult(result); } diff --git a/tests/TickerQ.Tests/RetryBehaviorTests.cs b/tests/TickerQ.Tests/RetryBehaviorTests.cs index dd1870f5..14a3bac1 100644 --- a/tests/TickerQ.Tests/RetryBehaviorTests.cs +++ b/tests/TickerQ.Tests/RetryBehaviorTests.cs @@ -4,12 +4,19 @@ using TickerQ.Utilities.Interfaces.Managers; using Microsoft.Extensions.DependencyInjection; using TickerQ.Utilities.Instrumentation; +using TickerQ.Utilities; using TickerQ.Utilities.Models; namespace TickerQ.Tests; -public class RetryBehaviorTests +[Collection("TickerCancellationTokenState")] +public class RetryBehaviorTests : IDisposable { + public void Dispose() + { + TickerCancellationTokenManager.CleanUpTickerCancellationTokens(); + } + // End-to-end unit tests that call the public ExecuteTaskAsync with a CronTickerOccurrence // so RunContextFunctionAsync + retry logic is exercised. Tests use short intervals (1..3s). diff --git a/tests/TickerQ.Tests/RetryExhaustionTests.cs b/tests/TickerQ.Tests/RetryExhaustionTests.cs index da295551..5c3d6cc5 100644 --- a/tests/TickerQ.Tests/RetryExhaustionTests.cs +++ b/tests/TickerQ.Tests/RetryExhaustionTests.cs @@ -9,8 +9,14 @@ namespace TickerQ.Tests; -public class RetryExhaustionTests +[Collection("TickerCancellationTokenState")] +public class RetryExhaustionTests : IDisposable { + public void Dispose() + { + TickerCancellationTokenManager.CleanUpTickerCancellationTokens(); + } + private readonly ITickerClock _clock; private readonly IInternalTickerManager _internalManager; private readonly ITickerQInstrumentation _instrumentation; diff --git a/tests/TickerQ.Tests/TickerCancellationTokenManagerTests.cs b/tests/TickerQ.Tests/TickerCancellationTokenManagerTests.cs index 2310057a..80ceb2dd 100644 --- a/tests/TickerQ.Tests/TickerCancellationTokenManagerTests.cs +++ b/tests/TickerQ.Tests/TickerCancellationTokenManagerTests.cs @@ -7,6 +7,7 @@ namespace TickerQ.Tests; +[Collection("TickerCancellationTokenState")] public class TickerCancellationTokenManagerTests : IDisposable { public void Dispose() diff --git a/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs index 7f1139bf..d27d6510 100644 --- a/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs +++ b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs @@ -10,8 +10,14 @@ namespace TickerQ.Tests; -public class TickerExecutionTaskHandlerTests +[Collection("TickerCancellationTokenState")] +public class TickerExecutionTaskHandlerTests : IDisposable { + public void Dispose() + { + TickerCancellationTokenManager.CleanUpTickerCancellationTokens(); + } + private readonly ITickerClock _clock; private readonly IInternalTickerManager _internalManager; private readonly ITickerQInstrumentation _instrumentation;