Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ public static void MapDashboardEndpoints<TTimeTicker, TCronTicker>(this IEndpoin
.WithName("UpdateCronTicker")
.WithSummary("Update cron ticker");

apiGroup.MapPut("/cron-ticker/toggle", ToggleCronTicker<TTimeTicker, TCronTicker>)
.WithName("ToggleCronTicker")
.WithSummary("Toggle cron ticker enabled/disabled");

apiGroup.MapPost("/cron-ticker/run", RunCronTickerOnDemand<TTimeTicker, TCronTicker>)
.WithName("RunCronTickerOnDemand")
.WithSummary("Run cron ticker on demand");
Expand Down Expand Up @@ -551,6 +555,23 @@ private static async Task<IResult> UpdateCronTicker<TTimeTicker, TCronTicker>(
}, dashboardOptions.DashboardJsonOptions);
}

private static async Task<IResult> ToggleCronTicker<TTimeTicker, TCronTicker>(
Guid id,
bool isEnabled,
ITickerDashboardRepository<TTimeTicker, TCronTicker> repository,
DashboardOptionsBuilder dashboardOptions,
CancellationToken cancellationToken)
where TTimeTicker : TimeTickerEntity<TTimeTicker>, 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<IResult> RunCronTickerOnDemand<TTimeTicker, TCronTicker>(
Guid id,
ITickerDashboardRepository<TTimeTicker, TCronTicker> repository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,26 @@ public async Task<IList<CronOccurrenceTickerGraphData>> GetCronTickersOccurrence
return finalData;
}

public async Task<bool> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,24 @@ const getTimeTickersGraphData = () => {
};
}

const toggleCronTicker = () => {
const baseHttp = useBaseHttpService<object, object>('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,
updateCronTicker,
addCronTicker,
deleteCronTicker,
runCronTickerOnDemand,
toggleCronTicker,
getTimeTickersGraphDataRange,
getTimeTickersGraphDataRangeById,
getTimeTickersGraphData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class GetCronTickerResponse {
createdAt!:string;
updatedAt!:string;
retries!:number;
isEnabled!:boolean;
actions:string|undefined = undefined;
}

Expand All @@ -22,6 +23,7 @@ export class UpdateCronTickerRequest {
retries?: number;
description?: string;
intervals?:number[];
isEnabled?: boolean;
}

export class GetCronTickerGraphDataRangeResponse{
Expand Down
89 changes: 89 additions & 0 deletions src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -76,6 +77,9 @@ const handlePageSizeChange = async (size: number) => {
const confirmDialog = useDialog<ConfirmDialogProps & { id: string }>().withComponent(
() => import('@/components/common/ConfirmDialog.vue'),
)
const toggleConfirmDialog = useDialog<ConfirmDialogProps & { id: string; isEnabled: boolean }>().withComponent(
() => import('@/components/common/ConfirmDialog.vue'),
)
const cronOccurrenceDialog = useDialog<{
id: string
retries: number
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1221,6 +1255,23 @@ const refreshData = async () => {

<template v-slot:item.actions="{ item }">
<div class="action-buttons-container">
<!-- Enable/Disable Button -->
<div class="action-btn-wrapper">
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<button
v-bind="props"
@click="ToggleCronTickerEnabled(item.id, !item.isEnabled)"
class="modern-action-btn"
:class="item.isEnabled ? 'disable-btn' : 'enable-btn'"
>
<v-icon size="16">mdi-power</v-icon>
</button>
</template>
<span>{{ item.isEnabled ? 'Disable' : 'Enable' }}</span>
</v-tooltip>
</div>

<!-- Chart Button -->
<div class="action-btn-wrapper">
<v-tooltip location="top">
Expand Down Expand Up @@ -1288,6 +1339,8 @@ const refreshData = async () => {
v-bind="props"
@click="RunCronTickerOnDemand(item.id)"
class="modern-action-btn run-btn"
:class="{ 'btn-disabled': !item.isEnabled }"
:disabled="!item.isEnabled"
>
<v-icon size="16">mdi-play-outline</v-icon>
</button>
Expand Down Expand Up @@ -1354,6 +1407,12 @@ const refreshData = async () => {
@close="confirmDialog.close()"
@confirm="onSubmitConfirmDialog"
/>
<toggleConfirmDialog.Component
:is-open="toggleConfirmDialog.isOpen"
:dialog-props="toggleConfirmDialog.propData"
@close="toggleConfirmDialog.close()"
@confirm="onSubmitToggleConfirmDialog"
/>
</div>
</template>

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public void Configure(EntityTypeBuilder<TCronTicker> builder)
builder.HasIndex("Function", "Expression")
.HasDatabaseName("IX_Function_Expression");

builder.Property(e => e.IsEnabled)
.IsRequired()
.HasDefaultValue(true);

builder.ToTable("CronTickers", _schema);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ public async Task<CronTickerEntity[]> GetAllCronTickerExpressions(CancellationTo
var dbContext = session.Context;
return await dbContext.Set<TCronTicker>()
.AsNoTracking()
.Where(x => x.IsEnabled)
.Select(MappingExtensions.ForCronTickerExpressions<CronTickerEntity>())
.ToArrayAsync(ct)
.ConfigureAwait(false);
Expand All @@ -361,6 +362,7 @@ public async Task<CronTickerEntity[]> GetAllCronTickerExpressions(CancellationTo
var dbContext = session.Context;
return await dbContext.Set<TCronTicker>()
.AsNoTracking()
.Where(x => x.IsEnabled)
.Select(MappingExtensions.ForCronTickerExpressions<CronTickerEntity>())
.ToArrayAsync(cancellationToken).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public static Expression<Func<TCronTicker, CronTickerEntity>> ForCronTickerExpre
Expression = e.Expression,
Function = e.Function,
RetryIntervals = e.RetryIntervals,
Retries = e.Retries
Retries = e.Retries,
IsEnabled = e.IsEnabled
};

internal static Expression<Func<TTimeTicker, TimeTickerEntity>> ForQueueTimeTickers<TTimeTicker>()
Expand Down
1 change: 1 addition & 0 deletions src/TickerQ.Utilities/Entities/CronTickerEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal interface ITickerDashboardRepository<TTimeTicker, TCronTicker>
Task<CronTickerOccurrenceEntity<TCronTicker>[]> GetCronTickersOccurrencesAsync(Guid guid, CancellationToken cancellationToken = default);
Task<PaginationResult<CronTickerOccurrenceEntity<TCronTicker>>> GetCronTickersOccurrencesPaginatedAsync(Guid guid, int pageNumber, int pageSize, CancellationToken cancellationToken = default);
Task<IList<CronOccurrenceTickerGraphData>> GetCronTickersOccurrencesGraphDataAsync(Guid guid, CancellationToken cancellationToken = default);
Task<bool> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,9 +563,10 @@ public Task MigrateDefinedCronTickers((string Function, string Expression)[] cro
public Task<CronTickerEntity[]> GetAllCronTickerExpressions(CancellationToken cancellationToken)
{
var result = CronTickers.Values
.Where(x => x.IsEnabled)
.Cast<CronTickerEntity>()
.ToArray();

return Task.FromResult(result);
}

Expand Down
9 changes: 8 additions & 1 deletion tests/TickerQ.Tests/RetryBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 7 additions & 1 deletion tests/TickerQ.Tests/RetryExhaustionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/TickerQ.Tests/TickerCancellationTokenManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace TickerQ.Tests;

[Collection("TickerCancellationTokenState")]
public class TickerCancellationTokenManagerTests : IDisposable
{
public void Dispose()
Expand Down
8 changes: 7 additions & 1 deletion tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading