diff --git a/README.md b/README.md index 3df7b5d2..2aba89be 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,20 @@ dotnet add package TickerQ.Dashboard ```csharp builder.Services.AddTickerQ(options => { - opt.SetMaxConcurrency(10); + options.ConfigureScheduler(scheduler => + { + scheduler.MaxConcurrency = 10; + }); + options.AddOperationalStore(efOpt => { efOpt.SetExceptionHandler(); efOpt.UseModelCustomizerForMigrations(); }); - options.AddDashboard(uiopt => + options.AddDashboard(dashboardOptions => { - uiopt.BasePath = "/tickerq-dashboard"; - uiopt.AddDashboardBasicAuth(); + dashboardOptions.SetBasePath("/tickerq/dashboard"); + dashboardOptions.WithBasicAuth("admin", "secure-password"); } }); diff --git a/src/TickerQ.Dashboard/DashboardOptionsBuilder.cs b/src/TickerQ.Dashboard/DashboardOptionsBuilder.cs index 887c57d7..49e3d80b 100644 --- a/src/TickerQ.Dashboard/DashboardOptionsBuilder.cs +++ b/src/TickerQ.Dashboard/DashboardOptionsBuilder.cs @@ -9,7 +9,7 @@ namespace TickerQ.Dashboard; public class DashboardOptionsBuilder { - internal string BasePath { get; set; } = "/"; + internal string BasePath { get; set; } = "/tickerq/dashboard"; internal Action CorsPolicyBuilder { get; set; } internal string BackendDomain { get; set; } diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs index 8646c3b9..c1c28724 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -43,8 +43,6 @@ internal static void AddDashboardService(this IService // Register the dashboard configuration for DI services.AddSingleton(config); - services.AddSingleton(config.Auth); - services.AddScoped(); services.AddRouting(); services.AddSignalR(); diff --git a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs index 35c8ff32..c3baaa39 100644 --- a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs +++ b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs @@ -242,7 +242,8 @@ private static IResult GetOptions( maxConcurrency = schedulerOptions.MaxConcurrency, schedulerOptions.IdleWorkerTimeOut, currentMachine = schedulerOptions.NodeIdentifier, - executionContext.LastHostExceptionMessage + executionContext.LastHostExceptionMessage, + schedulerTimeZone = schedulerOptions.SchedulerTimeZone?.Id }, dashboardOptions.DashboardJsonOptions); } @@ -294,6 +295,7 @@ private static async Task CreateChainJobs( HttpContext context, ITimeTickerManager timeTickerManager, DashboardOptionsBuilder dashboardOptions, + string timeZoneId, CancellationToken cancellationToken) where TTimeTicker : TimeTickerEntity, new() where TCronTicker : CronTickerEntity, new() @@ -304,6 +306,14 @@ private static async Task CreateChainJobs( // Use Dashboard-specific JSON options var chainRoot = JsonSerializer.Deserialize(jsonString, dashboardOptions.DashboardJsonOptions); + + if (chainRoot?.ExecutionTime is DateTime executionTime && !string.IsNullOrEmpty(timeZoneId)) + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + var unspecified = DateTime.SpecifyKind(executionTime, DateTimeKind.Unspecified); + var utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz); + chainRoot.ExecutionTime = DateTime.SpecifyKind(utc, DateTimeKind.Utc); + } var result = await timeTickerManager.AddAsync(chainRoot, cancellationToken); @@ -319,6 +329,7 @@ private static async Task UpdateTimeTicker( HttpContext context, ITimeTickerManager timeTickerManager, DashboardOptionsBuilder dashboardOptions, + string timeZoneId, CancellationToken cancellationToken) where TTimeTicker : TimeTickerEntity, new() where TCronTicker : CronTickerEntity, new() @@ -332,6 +343,14 @@ private static async Task UpdateTimeTicker( // Ensure the ID matches timeTicker.Id = id; + + if (timeTicker.ExecutionTime is DateTime executionTime && !string.IsNullOrEmpty(timeZoneId)) + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + var unspecified = DateTime.SpecifyKind(executionTime, DateTimeKind.Unspecified); + var utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz); + timeTicker.ExecutionTime = DateTime.SpecifyKind(utc, DateTimeKind.Utc); + } var result = await timeTickerManager.UpdateAsync(timeTicker, cancellationToken); diff --git a/src/TickerQ.Dashboard/README.md b/src/TickerQ.Dashboard/README.md index d0687b8f..ce8a45b7 100644 --- a/src/TickerQ.Dashboard/README.md +++ b/src/TickerQ.Dashboard/README.md @@ -26,13 +26,13 @@ services.AddTickerQ(config => }); ``` -### Bearer Token Authentication +### API Key Authentication ```csharp services.AddTickerQ(config => { config.AddDashboard(dashboard => { - dashboard.WithBearerToken("my-secret-api-key-12345"); + dashboard.WithApiKey("my-secret-api-key-12345"); }); }); ``` @@ -43,9 +43,7 @@ services.AddTickerQ(config => { config.AddDashboard(dashboard => { - dashboard.WithHostAuthentication( - requiredRoles: new[] { "Admin", "TickerQUser" } - ); + dashboard.WithHostAuthentication(); }); }); ``` @@ -53,8 +51,8 @@ services.AddTickerQ(config => ## πŸ”§ Fluent API Methods - `WithBasicAuth(username, password)` - Enable username/password authentication -- `WithBearerToken(apiKey)` - Enable API key authentication -- `WithHostAuthentication(roles?, policies?)` - Use your app's existing auth +- `WithApiKey(apiKey)` - Enable API key authentication +- `WithHostAuthentication()` - Use your app's existing auth - `SetBasePath(path)` - Set dashboard URL path - `SetBackendDomain(domain)` - Set backend API domain - `SetCorsPolicy(policy)` - Configure CORS @@ -75,4 +73,4 @@ The frontend automatically adapts based on your backend configuration: - Handles SignalR authentication - Supports both header and query parameter auth (for WebSockets) -That's it! Simple and clean. πŸŽ‰ \ No newline at end of file +That's it! Simple and clean. πŸŽ‰ diff --git a/src/TickerQ.Dashboard/wwwroot/src/components/layout/DashboardLayout.vue b/src/TickerQ.Dashboard/wwwroot/src/components/layout/DashboardLayout.vue index 3481310d..90a620ac 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/components/layout/DashboardLayout.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/components/layout/DashboardLayout.vue @@ -6,6 +6,7 @@ import { useDialog } from '../../composables/useDialog' import { ConfirmDialogProps } from '../common/ConfirmDialog.vue' import { useDashboardStore } from '../../stores/dashboardStore' import { useConnectionStore } from '../../stores/connectionStore' +import { useTimeZoneStore } from '@/stores/timeZoneStore' import AuthHeader from '../common/AuthHeader.vue' const navigationLinks = [ @@ -29,6 +30,12 @@ const dashboardStore = useDashboardStore() // Connection store const connectionStore = useConnectionStore() +// Time zone store +const timeZoneStore = useTimeZoneStore() + +// Time zone menu state +const isTimeZoneMenuOpen = ref(false) + // Router const router = useRouter() @@ -105,8 +112,14 @@ const loadInitialData = async () => { // Check if response is available before accessing it if (getOptions.response?.value) { - currentMachine.value = getOptions.response.value.currentMachine || 'Unknown' - lastHostExceptionMessage.value = getOptions.response.value.lastHostExceptionMessage || '' + const options = getOptions.response.value + currentMachine.value = options.currentMachine || 'Unknown' + lastHostExceptionMessage.value = options.lastHostExceptionMessage || '' + + // Initialize scheduler time zone from server options + if (options.schedulerTimeZone) { + timeZoneStore.setSchedulerTimeZone(options.schedulerTimeZone) + } } else { currentMachine.value = 'Loading...' lastHostExceptionMessage.value = '' @@ -283,6 +296,59 @@ const handleForceUIUpdate = () => { TickerQ + + +
+ + + + + + Display Time Zone + + + + + Reset to server timezone + + + + +
@@ -1100,4 +1166,4 @@ const handleForceUIUpdate = () => { padding: 20px 0 !important; } } - \ No newline at end of file + diff --git a/src/TickerQ.Dashboard/wwwroot/src/components/timetickerComponents/CRUDTimeTickerDialogComponent.vue b/src/TickerQ.Dashboard/wwwroot/src/components/timetickerComponents/CRUDTimeTickerDialogComponent.vue index 44f6965f..237ad603 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/components/timetickerComponents/CRUDTimeTickerDialogComponent.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/components/timetickerComponents/CRUDTimeTickerDialogComponent.vue @@ -5,9 +5,11 @@ import { useFunctionNameStore } from '@/stores/functionNames' import { useForm } from '@/composables/useCustomForm' import { tickerService } from '@/http/services/tickerService' import { timeTickerService } from '@/http/services/timeTickerService' -import { formatFromUtcToLocal, formatTime } from '@/utilities/dateTimeParser' +import { formatTime } from '@/utilities/dateTimeParser' +import { useTimeZoneStore } from '@/stores/timeZoneStore' const functionNamesStore = useFunctionNameStore() +const timeZoneStore = useTimeZoneStore() const getTickerRequestData = tickerService.getRequestData() const addTimeTicker = timeTickerService.addTimeTicker() const updateTimeTicker = timeTickerService.updateTimeTicker() @@ -56,10 +58,62 @@ const formatJsonForDisplay = (json: string, isHtml: boolean = false) => { } } -const formatDate = (date: string) => { - const datePart = date.split('T')[0] - const timePart = date.split('T')[1].split('.')[0] - return { datePart, timePart } +const parseUtcToDisplayDateTime = (utcString: string) => { + if (!utcString) { + return { date: null as Date | null, time: '' } + } + + let iso = utcString.trim() + if (!iso.endsWith('Z')) { + iso = iso.replace(' ', 'T') + 'Z' + } + + const utcDate = new Date(iso) + // Use the dashboard's effective display timezone for UI + const tz = timeZoneStore.effectiveTimeZone || 'UTC' + + try { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + + const parts = fmt.formatToParts(utcDate) + const get = (type: string) => parts.find(p => p.type === type)?.value ?? '00' + + const year = Number(get('year')) + const month = Number(get('month')) - 1 + const day = Number(get('day')) + const hour = get('hour') + const minute = get('minute') + const second = get('second') + + const dateObj = new Date(year, month, day) + const timeStr = `${hour}:${minute}:${second}` + + return { date: dateObj, time: timeStr } + } catch { + // If timeZone is invalid in this environment, fall back to UTC date-only + return { date: new Date(utcDate.getFullYear(), utcDate.getMonth(), utcDate.getDate()), time: '' } + } +} + +const formatLocalDateTimeWithoutZ = (date: Date): string => { + const yyyy = date.getFullYear() + const MM = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + const ss = String(date.getSeconds()).padStart(2, '0') + + // Return ISO-like string without timezone suffix so the server treats it as "unspecified" + return `${yyyy}-${MM}-${dd}T${hh}:${mm}:${ss}` } const { resetForm, handleSubmit, bindField, setFieldValue, getFieldValue, values } = useForm({ @@ -179,8 +233,13 @@ const { resetForm, handleSubmit, bindField, setFieldValue, getFieldValue, values }); setFieldValue('retries', props.dialogProps.retries as number); setFieldValue('description', props.dialogProps.description); - setFieldValue('executionDate', new Date(props.dialogProps.executionTime)) - setFieldValue('executionTime', formatDate(props.dialogProps.executionTime).timePart) + const parsed = parseUtcToDisplayDateTime(props.dialogProps.executionTime) + if (parsed.date) { + setFieldValue('executionDate', parsed.date) + } + if (parsed.time) { + setFieldValue('executionTime', parsed.time) + } setFieldValue('ignoreDateTime', false); } else{ @@ -191,17 +250,17 @@ const { resetForm, handleSubmit, bindField, setFieldValue, getFieldValue, values if (!errors) { const [hours, minutes, seconds] = values.executionTime.split(':').map(Number) - const parsedExecutionDate = new Date(values.executionDate!).setHours( - hours, - minutes, - seconds, - 0, - ) + const localDate = new Date(values.executionDate!) + localDate.setHours(hours, minutes, seconds, 0) const executionDateTime = !values.ignoreDateTime - ? new Date(parsedExecutionDate).toISOString() + ? formatLocalDateTimeWithoutZ(localDate) : undefined + // Use the scheduler timezone for scheduling semantics (fallback to effective/display timezone) + const schedulingTimeZone = + timeZoneStore.schedulerTimeZone || timeZoneStore.effectiveTimeZone + if (props.dialogProps.isFromDuplicate) { addTimeTicker .requestAsync({ @@ -211,7 +270,7 @@ const { resetForm, handleSubmit, bindField, setFieldValue, getFieldValue, values retries: parseInt(`${values.retries}`), description: values.description, intervals: comboBoxModel.value.map((item) => item.value), - }) + }, schedulingTimeZone) .then(() => { emit('confirm') }) @@ -224,7 +283,7 @@ const { resetForm, handleSubmit, bindField, setFieldValue, getFieldValue, values retries: parseInt(`${values.retries}`), description: values.description, intervals: comboBoxModel.value.map((item) => item.value), - }) + }, schedulingTimeZone) .then(() => { emit('confirm') }) diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerOccurrenceService.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerOccurrenceService.ts index 03f8543e..cab9e972 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerOccurrenceService.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerOccurrenceService.ts @@ -5,6 +5,7 @@ import { Status } from './types/base/baseHttpResponse.types'; import { GetCronTickerOccurrenceGraphDataRequest, GetCronTickerOccurrenceGraphDataResponse, GetCronTickerOccurrenceRequest, GetCronTickerOccurrenceResponse } from './types/cronTickerOccurrenceService.types'; import { format} from 'timeago.js'; import { nameof } from '@/utilities/nameof'; +import { useTimeZoneStore } from '@/stores/timeZoneStore'; interface PaginatedCronTickerOccurrenceResponse { items: GetCronTickerOccurrenceResponse[] @@ -14,6 +15,7 @@ interface PaginatedCronTickerOccurrenceResponse { } const getByCronTickerId = () => { + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('array') .FixToResponseModel(GetCronTickerOccurrenceResponse, response => { if (!response) { @@ -32,8 +34,8 @@ const getByCronTickerId = () => { } const utcExecutionTime = response.executionTime.endsWith('Z') ? response.executionTime : response.executionTime + 'Z'; - response.executionTimeFormatted = formatDate(utcExecutionTime); - response.lockedAt = formatDate(response.lockedAt) + response.executionTimeFormatted = formatDate(utcExecutionTime, true, timeZoneStore.effectiveTimeZone); + response.lockedAt = formatDate(response.lockedAt, true, timeZoneStore.effectiveTimeZone) return response; }) .FixToHeaders((header) => { @@ -63,6 +65,7 @@ const getByCronTickerId = () => { } const getByCronTickerIdPaginated = () => { + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('single'); const processResponse = (response: PaginatedCronTickerOccurrenceResponse): PaginatedCronTickerOccurrenceResponse => { @@ -86,8 +89,8 @@ const getByCronTickerIdPaginated = () => { } const utcExecutionTime = item.executionTime.endsWith('Z') ? item.executionTime : item.executionTime + 'Z'; - item.executionTimeFormatted = formatDate(utcExecutionTime); - item.lockedAt = formatDate(item.lockedAt); + item.executionTimeFormatted = formatDate(utcExecutionTime, true, timeZoneStore.effectiveTimeZone); + item.lockedAt = formatDate(item.lockedAt, true, timeZoneStore.effectiveTimeZone); return item; }); @@ -126,11 +129,12 @@ const deleteCronTickerOccurrence = () => { } const getCronTickerOccurrenceGraphData = () => { + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('array') .FixToResponseModel(GetCronTickerOccurrenceGraphDataResponse, (item) => { return { ...item, - date: formatDate(item.date), + date: formatDate(item.date, false, timeZoneStore.effectiveTimeZone), type: "line", statuses: item.results.map(x => Status[x.item1]) } diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts index 328111dc..dca0a2ff 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/cronTickerService.ts @@ -4,6 +4,7 @@ import { useBaseHttpService } from '../base/baseHttpService'; import { AddCronTickerRequest, GetCronTickerGraphDataRangeResponse, GetCronTickerGraphDataResponse, GetCronTickerRequest, GetCronTickerResponse, UpdateCronTickerRequest } from './types/cronTickerService.types'; import { nameof } from '@/utilities/nameof'; import { useFunctionNameStore } from '@/stores/functionNames'; +import { useTimeZoneStore } from '@/stores/timeZoneStore'; interface PaginatedCronTickerResponse { items: GetCronTickerResponse[] @@ -14,12 +15,13 @@ interface PaginatedCronTickerResponse { const getCronTickers = () => { const functionNamesStore = useFunctionNameStore(); + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('array') .FixToResponseModel(GetCronTickerResponse, response => { response.requestType = functionNamesStore.getNamespaceOrNull(response.function) ?? 'N/A'; - response.createdAt = formatDate(response.createdAt); - response.updatedAt = formatDate(response.updatedAt); + response.createdAt = formatDate(response.createdAt, true, timeZoneStore.effectiveTimeZone); + response.updatedAt = formatDate(response.updatedAt, true, timeZoneStore.effectiveTimeZone); response.initIdentifier = response.initIdentifier?.split("_").slice(0, 2).join("_"); if ((response.retryIntervals == null || response.retryIntervals.length == 0) && (response.retries == null || (response.retries as number) == 0)) response.retryIntervals = []; @@ -50,6 +52,7 @@ const getCronTickers = () => { const getCronTickersPaginated = () => { const functionNamesStore = useFunctionNameStore(); + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('single'); @@ -58,8 +61,8 @@ const getCronTickersPaginated = () => { if (response && response.items && Array.isArray(response.items)) { response.items = response.items.map((item: GetCronTickerResponse) => { item.requestType = functionNamesStore.getNamespaceOrNull(item.function) ?? 'N/A'; - item.createdAt = formatDate(item.createdAt); - item.updatedAt = formatDate(item.updatedAt); + item.createdAt = formatDate(item.createdAt, true, timeZoneStore.effectiveTimeZone); + item.updatedAt = formatDate(item.updatedAt, true, timeZoneStore.effectiveTimeZone); item.initIdentifier = item.initIdentifier?.split("_").slice(0, 2).join("_"); if ((item.retryIntervals == null || item.retryIntervals.length == 0) && (item.retries == null || (item.retries as number) == 0)) item.retryIntervals = []; @@ -184,4 +187,3 @@ export const cronTickerService = { getTimeTickersGraphDataRangeById, getTimeTickersGraphData }; - diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/timeTickerService.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/timeTickerService.ts index 9b699e48..9b8ba7f1 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/timeTickerService.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/timeTickerService.ts @@ -13,6 +13,7 @@ import { import { nameof } from '@/utilities/nameof'; import { format} from 'timeago.js'; import { useFunctionNameStore } from '@/stores/functionNames'; +import { useTimeZoneStore } from '@/stores/timeZoneStore'; interface PaginatedTimeTickerResponse { items: GetTimeTickerResponse[] @@ -23,6 +24,7 @@ interface PaginatedTimeTickerResponse { const getTimeTickers = () => { const functionNamesStore = useFunctionNameStore(); + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('array') .FixToResponseModel(GetTimeTickerResponse, response => { @@ -41,7 +43,7 @@ const getTimeTickers = () => { if (item.executedAt != null || item.executedAt != undefined) item.executedAt = `${format(item.executedAt)} (took ${formatTime(item.elapsedTime as number, true)})`; - item.executionTimeFormatted = formatDate(item.executionTime); + item.executionTimeFormatted = formatDate(item.executionTime, true, timeZoneStore.effectiveTimeZone); item.requestType = functionNamesStore.getNamespaceOrNull(item.function) ?? ''; if (item.retryIntervals == null || item.retryIntervals.length == 0 && item.retries != null && (item.retries as number) > 0) @@ -97,6 +99,7 @@ const getTimeTickers = () => { const getTimeTickersPaginated = () => { const functionNamesStore = useFunctionNameStore(); + const timeZoneStore = useTimeZoneStore(); const baseHttp = useBaseHttpService('single'); @@ -112,7 +115,7 @@ const getTimeTickersPaginated = () => { if (item.executedAt != null || item.executedAt != undefined) item.executedAt = `${format(item.executedAt)} (took ${formatTime(item.elapsedTime as number, true)})`; - item.executionTimeFormatted = formatDate(item.executionTime); + item.executionTimeFormatted = formatDate(item.executionTime, true, timeZoneStore.effectiveTimeZone); item.requestType = functionNamesStore.getNamespaceOrNull(item.function) ?? ''; if (item.retryIntervals == null || item.retryIntervals.length == 0 && item.retries != null && (item.retries as number) > 0) @@ -197,7 +200,13 @@ const deleteTimeTicker = () => { const addTimeTicker = () => { const baseHttp = useBaseHttpService('single'); - const requestAsync = async (data: AddTimeTickerRequest) => (await baseHttp.sendAsync("POST", "time-ticker/add", { bodyData: data })); + const requestAsync = async (data: AddTimeTickerRequest, timeZoneId?: string | null) => { + const paramData: Record = {}; + if (timeZoneId) { + paramData.timeZoneId = timeZoneId; + } + return await baseHttp.sendAsync("POST", "time-ticker/add", { bodyData: data, paramData }); + }; return { ...baseHttp, @@ -208,7 +217,13 @@ const addTimeTicker = () => { const updateTimeTicker = () => { const baseHttp = useBaseHttpService('single'); - const requestAsync = async (id: string, data: UpdateTimeTickerRequest) => (await baseHttp.sendAsync("PUT", "time-ticker/update", { bodyData: data, paramData: { id: id } })); + const requestAsync = async (id: string, data: UpdateTimeTickerRequest, timeZoneId?: string | null) => { + const paramData: Record = { id }; + if (timeZoneId) { + paramData.timeZoneId = timeZoneId; + } + return await baseHttp.sendAsync("PUT", "time-ticker/update", { bodyData: data, paramData }); + }; return { ...baseHttp, diff --git a/src/TickerQ.Dashboard/wwwroot/src/http/services/types/tickerService.types.ts b/src/TickerQ.Dashboard/wwwroot/src/http/services/types/tickerService.types.ts index a87ee525..ff1c8c39 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/http/services/types/tickerService.types.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/http/services/types/tickerService.types.ts @@ -40,6 +40,7 @@ export class GetOptions{ maxConcurrency!:number; currentMachine!:string; lastHostExceptionMessage!:string; + schedulerTimeZone?:string; } export class GetMachineJobs{ @@ -56,4 +57,4 @@ export class GetJobStatusesPastWeek{ export class GetJobStatusesOverall{ item1!:string; item2!:number; -} \ No newline at end of file +} diff --git a/src/TickerQ.Dashboard/wwwroot/src/hub/base/baseHub.ts b/src/TickerQ.Dashboard/wwwroot/src/hub/base/baseHub.ts index 1bbdc4b2..48f6d36e 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/hub/base/baseHub.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/hub/base/baseHub.ts @@ -52,7 +52,10 @@ class BaseHub { if (backendUrl) { hubUrl = `${backendUrl}/ticker-notification-hub`; } else { - hubUrl = `${basePath}/ticker-notification-hub`; + // Avoid leading '//' when basePath is '/' + hubUrl = basePath === '/' + ? '/ticker-notification-hub' + : `${basePath}/ticker-notification-hub`; } const authInfo = getAuthInfo(); @@ -201,4 +204,4 @@ class BaseHub { } } -export default BaseHub; \ No newline at end of file +export default BaseHub; diff --git a/src/TickerQ.Dashboard/wwwroot/src/stores/timeZoneStore.ts b/src/TickerQ.Dashboard/wwwroot/src/stores/timeZoneStore.ts new file mode 100644 index 00000000..36feecd4 --- /dev/null +++ b/src/TickerQ.Dashboard/wwwroot/src/stores/timeZoneStore.ts @@ -0,0 +1,90 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' + +export const useTimeZoneStore = defineStore('timeZone', () => { + const STORAGE_KEY = 'tickerq:dashboard:timezone' + + const schedulerTimeZone = ref(null) + const selectedTimeZone = ref(null) + + // A small curated list of common IANA time zones. + // The scheduler's configured time zone will be added dynamically if it's not in this list. + const commonTimeZones = ref([ + 'UTC', + 'Europe/London', + 'Europe/Berlin', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Australia/Sydney' + ]) + + const availableTimeZones = computed(() => { + const zones = new Set(commonTimeZones.value) + + if (schedulerTimeZone.value) { + zones.add(schedulerTimeZone.value) + } + + // Browser local time zone (if available) + try { + const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone + if (browserTz) { + zones.add(browserTz) + } + } catch { + // ignore + } + + return Array.from(zones) + }) + + const effectiveTimeZone = computed(() => { + return selectedTimeZone.value || schedulerTimeZone.value || 'UTC' + }) + + // Initialize from localStorage (if available) + if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored) { + selectedTimeZone.value = stored + } + } + + function setSchedulerTimeZone(id: string | null | undefined) { + schedulerTimeZone.value = id || null + } + + function setSelectedTimeZone(id: string | null) { + selectedTimeZone.value = id + } + + // Persist user selection to localStorage + watch( + selectedTimeZone, + (val) => { + if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') { + return + } + + if (val) { + window.localStorage.setItem(STORAGE_KEY, val) + } else { + window.localStorage.removeItem(STORAGE_KEY) + } + }, + { immediate: false } + ) + + return { + schedulerTimeZone, + selectedTimeZone, + availableTimeZones, + effectiveTimeZone, + setSchedulerTimeZone, + setSelectedTimeZone + } +}) diff --git a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts index 501586c1..6102b001 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts @@ -1,7 +1,8 @@ export function formatDate( utcDateString: string, - includeTime = true + includeTime = true, + timeZone?: string ): string { if (!utcDateString) { // nothing to format, return empty (or some placeholder) @@ -14,7 +15,7 @@ export function formatDate( } // 2) Now JS knows β€œthat’s UTC” and will shift to local when you read getHours() - const dateObj = new Date(iso); + const dateObj = toTimeZoneDate(iso, timeZone); // 3) Extract with local getters const dd = String(dateObj.getDate()).padStart(2, '0'); @@ -32,6 +33,43 @@ export function formatDate( return `${dd}.${MM}.${yyyy} ${hh}:${mm}:${ss}`; } +function toTimeZoneDate(utcIsoString: string, timeZone?: string): Date { + const utcDate = new Date(utcIsoString); + + if (!timeZone) { + return utcDate; + } + + try { + // Use Intl API to get local parts in the target time zone + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + + const parts = fmt.formatToParts(utcDate); + const get = (type: string) => parts.find(p => p.type === type)?.value ?? '00'; + + const year = Number(get('year')); + const month = Number(get('month')) - 1; + const day = Number(get('day')); + const hour = Number(get('hour')); + const minute = Number(get('minute')); + const second = Number(get('second')); + + return new Date(year, month, day, hour, minute, second); + } catch { + // Fallback: return original UTC date if timeZone is invalid + return utcDate; + } +} + export function formatTime(time: number, inputInMilliseconds = false): string { if (inputInMilliseconds && time < 1000) { return time + 'ms'; @@ -87,4 +125,4 @@ export function formatFromUtcToLocal(utcDateString: string): string { const seconds = String(utcDate.getSeconds()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` -} \ No newline at end of file +} diff --git a/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue b/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue index fed83b6e..f6e95567 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/views/CronTicker.vue @@ -15,6 +15,7 @@ import { } from '@/http/services/types/cronTickerService.types' import TickerNotificationHub, { methodName } from '@/hub/tickerNotificationHub' import { useConnectionStore } from '@/stores/connectionStore' +import { useTimeZoneStore } from '@/stores/timeZoneStore' import { TitleComponent, TooltipComponent, @@ -91,6 +92,8 @@ const selectedCronTickerGraphData: Ref = ref(undefined) const chartLoading = ref(false) const isMounted = ref(false) +const timeZoneStore = useTimeZoneStore() + const onSubmitConfirmDialog = async () => { try { const deletedId = confirmDialog.propData?.id! @@ -521,6 +524,36 @@ onMounted(async () => { } }) +// Reload data when display timezone changes +watch( + () => timeZoneStore.effectiveTimeZone, + async () => { + try { + if (!isMounted.value) return + + await loadPageData() + + // Reload main range chart and pie distribution + const range = await getCronTickerRangeGraphData.requestAsync(-3, 3) + GetCronTickerRangeGraphData(range) + await getTimeTickersGraphDataAndParseToGraph() + + // If a specific ticker is selected, update its view + if (selectedCronTickerGraphData.value) { + const res = await getCronTickerRangeGraphDataById.requestAsync( + selectedCronTickerGraphData.value, + -3, + 3 + ) + GetCronTickerRangeGraphData(res) + await updatePieChartForSelectedTicker(selectedCronTickerGraphData.value, -3, 3) + } + } catch { + // ignore errors on timezone-driven refresh + } + } +) + onUnmounted(() => { isMounted.value = false @@ -2192,4 +2225,4 @@ const refreshData = async () => { padding: 16px; } } - \ No newline at end of file + diff --git a/src/TickerQ.Dashboard/wwwroot/src/views/Dashboard.vue b/src/TickerQ.Dashboard/wwwroot/src/views/Dashboard.vue index ce5b840f..bbdf7c03 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/views/Dashboard.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/views/Dashboard.vue @@ -5,6 +5,7 @@ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue' import TickerNotificationHub, { methodName } from '@/hub/tickerNotificationHub' import { useFunctionNameStore } from '@/stores/functionNames' import { useDashboardStore } from '@/stores/dashboardStore' +import { useTimeZoneStore } from '@/stores/timeZoneStore' import { Status } from '@/http/services/types/base/baseHttpResponse.types' const getNextPlannedTicker = tickerService.getNextPlannedTicker() @@ -14,6 +15,7 @@ const getJobStatusesPastWeek = tickerService.getJobStatusesPastWeek() const getJobStatusesOverall = tickerService.getJobStatusesOverall() const functionNamesStore = useFunctionNameStore() const dashboardStore = useDashboardStore() +const timeZoneStore = useTimeZoneStore() const activeThreads = ref(0) @@ -24,6 +26,10 @@ onMounted(async () => { dashboardStore.setNextOccurrence(getNextPlannedTicker.response.value.nextOccurrence) } await getOptions.requestAsync() + + if (getOptions.response.value?.schedulerTimeZone) { + timeZoneStore.setSchedulerTimeZone(getOptions.response.value.schedulerTimeZone) + } await getJobStatusesOverall.requestAsync().then((res) => { const total = res.reduce((sum, item) => sum + item.item2, 0) @@ -247,7 +253,7 @@ const getVisiblePageNumbers = () => { dashboardStore.displayNextOccurrence === 'Not Scheduled' || dashboardStore.displayNextOccurrence == undefined ? 'Not Scheduled' - : formatDate(dashboardStore.displayNextOccurrence) + : formatDate(dashboardStore.displayNextOccurrence, true, timeZoneStore.effectiveTimeZone) }}

diff --git a/src/TickerQ.Dashboard/wwwroot/src/views/TimeTicker.vue b/src/TickerQ.Dashboard/wwwroot/src/views/TimeTicker.vue index 3caf1e1b..81c49304 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/views/TimeTicker.vue +++ b/src/TickerQ.Dashboard/wwwroot/src/views/TimeTicker.vue @@ -8,8 +8,9 @@ import { useDialog } from '@/composables/useDialog' import { ConfirmDialogProps } from '@/components/common/ConfirmDialog.vue' import ChainJobsModal from '@/components/ChainJobsModal.vue' import TickerNotificationHub, { methodName } from '@/hub/tickerNotificationHub' -import { formatDate, formatFromUtcToLocal } from '@/utilities/dateTimeParser' +import { formatDate } from '@/utilities/dateTimeParser' import { useConnectionStore } from '@/stores/connectionStore' +import { useTimeZoneStore } from '@/stores/timeZoneStore' import PaginationFooter from '@/components/PaginationFooter.vue' import { use } from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' @@ -96,6 +97,8 @@ const pieChartKey = ref(0) // Provide theme for charts provide(THEME_KEY, 'dark') +const timeZoneStore = useTimeZoneStore() + onMounted(async () => { // Initialize WebSocket connection try { @@ -128,6 +131,20 @@ onMounted(async () => { } }) +// Reload data when display timezone changes +watch( + () => timeZoneStore.effectiveTimeZone, + async () => { + try { + await loadPageData() + await loadTimeSeriesChartData(-3, 3) + await loadPieChartData() + } catch { + // ignore errors on timezone-driven refresh + } + } +) + onUnmounted(() => { TickerNotificationHub.stopReceiver(methodName.onReceiveAddTimeTicker) TickerNotificationHub.stopReceiver(methodName.onReceiveUpdateTimeTicker) @@ -332,7 +349,7 @@ const addHubListeners = async () => { crudTimeTickerDialog.setPropData({ ...currentData, // Keep existing data (including lockHolder, function, etc.) ...response, // Apply updates (status, executedAt, etc.) - executionTime: response.executionTime ? formatFromUtcToLocal(response.executionTime) : currentData.executionTime, + executionTime: response.executionTime ?? currentData.executionTime, isFromDuplicate: false, // Preserve fields that WebSocket updates don't include lockHolder: response.lockHolder || currentData.lockHolder, @@ -1262,7 +1279,7 @@ const canBeForceDeleted = ref([]) @click=" crudTimeTickerDialog.open({ ...item, - executionTime: formatFromUtcToLocal(item.executionTime), + executionTime: item.executionTime, isFromDuplicate: false, }) " @@ -1276,7 +1293,7 @@ const canBeForceDeleted = ref([]) @click=" crudTimeTickerDialog.open({ ...item, - executionTime: formatFromUtcToLocal(item.executionTime), + executionTime: item.executionTime, isFromDuplicate: true, }) " diff --git a/src/TickerQ.EntityFrameworkCore/DependencyInjection/ServiceExtension.cs b/src/TickerQ.EntityFrameworkCore/DependencyInjection/ServiceExtension.cs index 2afc2367..8933cc99 100644 --- a/src/TickerQ.EntityFrameworkCore/DependencyInjection/ServiceExtension.cs +++ b/src/TickerQ.EntityFrameworkCore/DependencyInjection/ServiceExtension.cs @@ -8,6 +8,7 @@ using TickerQ.Utilities.Instrumentation; using TickerQ.Utilities.Interfaces.Managers; using TickerQ.Utilities.Managers; +using TickerQ.Utilities.Interfaces; namespace TickerQ.EntityFrameworkCore.DependencyInjection; @@ -41,45 +42,26 @@ private static void UseApplicationService(TickerOption { tickerConfiguration.UseExternalProviderApplication((serviceProvider) => { - var loggerInstrumentation = serviceProvider.GetService(); var internalTickerManager = serviceProvider.GetRequiredService(); - var timeTickerManager = serviceProvider.GetService>(); - var cronTickerManager = serviceProvider.GetService>(); var hostLifetime = serviceProvider.GetService(); var schedulerOptions = serviceProvider.GetService(); + var hostScheduler = serviceProvider.GetService(); hostLifetime.ApplicationStarted.Register(() => { Task.Run(async () => { + // Release resources held by dead nodes before the scheduler starts processing. await internalTickerManager.ReleaseDeadNodeResources(schedulerOptions.NodeIdentifier); - - var functionsToSeed = TickerFunctionProvider.TickerFunctions - .Where(x => !string.IsNullOrEmpty(x.Value.cronExpression)) - .Select(x => (x.Key, x.Value.cronExpression)).ToArray(); - - if (options.SeedDefinedCronTickers) - { - loggerInstrumentation.LogSeedingDataStarted($"SeedDefinedCronTickers({typeof(TCronTicker).Name})"); - await internalTickerManager.MigrateDefinedCronTickers(functionsToSeed); - loggerInstrumentation.LogSeedingDataCompleted($"SeedDefinedCronTickers({typeof(TCronTicker).Name})"); - } - - if (options.TimeSeeder != null) - { - loggerInstrumentation.LogSeedingDataStarted(typeof(TCronTicker).Name); - await options.TimeSeeder(timeTickerManager); - loggerInstrumentation.LogSeedingDataCompleted(typeof(TCronTicker).Name); - } - - if (options.CronSeeder != null) + + // After cleanup, restart the host scheduler so it immediately + // picks up newly seeded cron tickers and jobs configured via the core pipeline. + if (hostScheduler != null && hostScheduler.IsRunning) { - loggerInstrumentation.LogSeedingDataStarted(typeof(TCronTicker).Name); - await options.CronSeeder(cronTickerManager); - loggerInstrumentation.LogSeedingDataCompleted(typeof(TCronTicker).Name); + hostScheduler.Restart(); } }); }); }); } -} \ No newline at end of file +} diff --git a/src/TickerQ.EntityFrameworkCore/EfCoreOptionBuilder.cs b/src/TickerQ.EntityFrameworkCore/EfCoreOptionBuilder.cs index 53d3f01e..8c0ec99d 100644 --- a/src/TickerQ.EntityFrameworkCore/EfCoreOptionBuilder.cs +++ b/src/TickerQ.EntityFrameworkCore/EfCoreOptionBuilder.cs @@ -13,36 +13,9 @@ public class TickerQEfCoreOptionBuilder where TTimeTicker : TimeTickerEntity, new() where TCronTicker : CronTickerEntity, new() { - internal bool SeedDefinedCronTickers { get; private set; } = true; - internal Func, Task> TimeSeeder { get; private set; } - internal Func, Task> CronSeeder { get; private set; } internal Action ConfigureServices { get; set; } internal int PoolSize { get; set; } = 1024; internal string Schema { get; set; } = "ticker"; - public TickerQEfCoreOptionBuilder IgnoreSeedDefinedCronTickers() - { - SeedDefinedCronTickers = false; - return this; - } - - public TickerQEfCoreOptionBuilder UseTickerSeeder(Func, Task> timeTickerAsync) - { - TimeSeeder = async t => await timeTickerAsync(t); - return this; - } - - public TickerQEfCoreOptionBuilder UseTickerSeeder(Func, Task> cronTickerAsync) - { - CronSeeder = async c => await cronTickerAsync(c); - return this; - } - - public TickerQEfCoreOptionBuilder UseTickerSeeder(Func, Task> timeTickerAsync, Func, Task> cronTickerAsync) - { - TimeSeeder = async t => await timeTickerAsync(t); - CronSeeder = async c => await cronTickerAsync(c); - return this; - } public TickerQEfCoreOptionBuilder UseApplicationDbContext(ConfigurationType configurationType) where TDbContext : DbContext { @@ -65,4 +38,4 @@ public TickerQEfCoreOptionBuilder SetDbContextPoolSize return this; } } -} \ No newline at end of file +} diff --git a/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs b/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs index fc7010b8..ce9e8780 100644 --- a/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs +++ b/src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs @@ -213,26 +213,78 @@ public async Task MigrateDefinedCronTickers((string Function, string Expression) await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var now = _clock.UtcNow; - var entitiesToUpsert = cronTickers.Select(x => + var functions = cronTickers.Select(x => x.Function).ToArray(); + var cronSet = dbContext.Set(); + + // Identify seeded cron tickers (created from in-memory definitions) + const string seedPrefix = "MemoryTicker_Seeded_"; + + var seededCron = await cronSet + .Where(c => c.InitIdentifier != null && c.InitIdentifier.StartsWith(seedPrefix)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var newFunctionSet = functions.ToHashSet(StringComparer.Ordinal); + + // Delete seeded cron tickers whose function no longer exists in the code definitions + var seededToDelete = seededCron + .Where(c => !newFunctionSet.Contains(c.Function)) + .Select(c => c.Id) + .ToArray(); + + if (seededToDelete.Length > 0) + { + // Delete related occurrences first (if any), then the cron tickers + await dbContext.Set>() + .Where(o => seededToDelete.Contains(o.CronTickerId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await cronSet + .Where(c => seededToDelete.Contains(c.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + // Load existing (remaining) cron tickers for the current function set + var existing = await cronSet + .Where(c => functions.Contains(c.Function)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var existingByFunction = existing + .GroupBy(c => c.Function) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var (function, expression) in cronTickers) { - var id = Guid.NewGuid(); - return new TCronTicker + if (existingByFunction.TryGetValue(function, out var cron)) { - Id = id, - Function = x.Function, - Expression = x.Expression, - InitIdentifier = $"MemoryTicker_Seeded_{id}", - CreatedAt = now, - UpdatedAt = now, - Request = [] - }; - }).ToList(); + // Update expression if it changed + if (!string.Equals(cron.Expression, expression, StringComparison.Ordinal)) + { + cron.Expression = expression; + cron.UpdatedAt = now; + } + } + else + { + // Insert new seeded cron ticker + var entity = new TCronTicker + { + Id = Guid.NewGuid(), + Function = function, + Expression = expression, + InitIdentifier = $"MemoryTicker_Seeded_{function}", + CreatedAt = now, + UpdatedAt = now, + Request = Array.Empty() + }; + await cronSet.AddAsync(entity, cancellationToken).ConfigureAwait(false); + } + } - await dbContext.Set() - .UpsertRange(entitiesToUpsert) - .On(v => new { v.Function, v.Expression, v.Request }) - .NoUpdate() - .RunAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task GetAllCronTickerExpressions(CancellationToken cancellationToken = default) diff --git a/src/TickerQ.Utilities/Base/TickerFunctionContext.cs b/src/TickerQ.Utilities/Base/TickerFunctionContext.cs index d6f36c45..54b0853b 100644 --- a/src/TickerQ.Utilities/Base/TickerFunctionContext.cs +++ b/src/TickerQ.Utilities/Base/TickerFunctionContext.cs @@ -13,7 +13,7 @@ public TickerFunctionContext(TickerFunctionContext tickerFunctionContext, TReque Type = tickerFunctionContext.Type; RetryCount = tickerFunctionContext.RetryCount; IsDue = tickerFunctionContext.IsDue; - CancelOperationAction = tickerFunctionContext.CancelOperationAction; + RequestCancelOperationAction = tickerFunctionContext.RequestCancelOperationAction; CronOccurrenceOperations = tickerFunctionContext.CronOccurrenceOperations; FunctionName = tickerFunctionContext.FunctionName; } @@ -24,15 +24,15 @@ public TickerFunctionContext(TickerFunctionContext tickerFunctionContext, TReque public class TickerFunctionContext { internal AsyncServiceScope ServiceScope { get; set; } - internal Action CancelOperationAction { get; set; } + internal Action RequestCancelOperationAction { get; set; } public Guid Id { get; internal set; } public TickerType Type { get; internal set; } public int RetryCount { get; internal set; } public bool IsDue { get; internal set; } public string FunctionName { get; internal set; } public CronOccurrenceOperations CronOccurrenceOperations { get; internal set; } - public void CancelOperation() - => CancelOperationAction(); + public void RequestCancellation() + => RequestCancelOperationAction(); internal void SetServiceScope(AsyncServiceScope serviceScope) => ServiceScope = serviceScope; } diff --git a/src/TickerQ.Utilities/TickerExecutionContext.cs b/src/TickerQ.Utilities/TickerExecutionContext.cs index 7e1008c9..9b87d60b 100644 --- a/src/TickerQ.Utilities/TickerExecutionContext.cs +++ b/src/TickerQ.Utilities/TickerExecutionContext.cs @@ -2,11 +2,19 @@ using System.Runtime.InteropServices; using System.Threading; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using TickerQ.Utilities.Enums; using TickerQ.Utilities.Models; namespace TickerQ.Utilities; +internal interface ITickerOptionsSeeding +{ + bool SeedDefinedCronTickers { get; } + Func TimeSeederAction { get; } + Func CronSeederAction { get; } +} + internal class TickerExecutionContext { private long _nextOccurrenceTicks; @@ -14,6 +22,7 @@ internal class TickerExecutionContext internal Action DashboardApplicationAction { get; set; } public Action NotifyCoreAction { get; set; } public string LastHostExceptionMessage { get; set; } + internal ITickerOptionsSeeding OptionsSeeding { get; set; } internal volatile InternalFunctionContext[] Functions = []; diff --git a/src/TickerQ.Utilities/TickerHelper.cs b/src/TickerQ.Utilities/TickerHelper.cs index 15eec1fa..e4456e07 100644 --- a/src/TickerQ.Utilities/TickerHelper.cs +++ b/src/TickerQ.Utilities/TickerHelper.cs @@ -2,6 +2,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; using System.Text.Json; namespace TickerQ.Utilities @@ -15,21 +16,43 @@ public static class TickerHelper /// Can be configured during application startup via TickerOptionsBuilder. /// public static JsonSerializerOptions RequestJsonSerializerOptions { get; set; } = new(); + + /// + /// Controls whether ticker requests are GZip-compressed. + /// When false (default), requests are stored as plain UTF-8 JSON bytes without compression. + /// + public static bool UseGZipCompression { get; set; } = false; public static byte[] CreateTickerRequest(T data) { - // If data is already a compressed byte array with signature, return as-is - if (data is byte[] existingBytes && existingBytes.Length >= GZipSignature.Length && - existingBytes.TakeLast(GZipSignature.Length).SequenceEqual(GZipSignature)) + // If data is already a byte array, short-circuit where possible + if (data is byte[] existingBytes) { - return existingBytes; + // If compression is enabled and data already has the GZip signature, assume it is in the final format + if (UseGZipCompression && + existingBytes.Length >= GZipSignature.Length && + existingBytes.TakeLast(GZipSignature.Length).SequenceEqual(GZipSignature)) + { + return existingBytes; + } + + // If compression is disabled, treat the provided bytes as the final representation + if (!UseGZipCompression) + { + return existingBytes; + } } - - Span compressedBytes; + var serialized = data is byte[] bytes ? bytes : JsonSerializer.SerializeToUtf8Bytes(data, RequestJsonSerializerOptions); - + + if (!UseGZipCompression) + { + return serialized; + } + + Span compressedBytes; using (var memoryStream = new MemoryStream()) { using (var stream = new GZipStream(memoryStream, CompressionMode.Compress, true)) @@ -57,6 +80,12 @@ public static T ReadTickerRequest(byte[] gzipBytes) public static string ReadTickerRequestAsString(byte[] gzipBytes) { + if (!UseGZipCompression) + { + // When compression is disabled, treat the bytes as plain UTF-8 JSON + return Encoding.UTF8.GetString(gzipBytes); + } + if (!gzipBytes.TakeLast(GZipSignature.Length).SequenceEqual(GZipSignature)) { throw new Exception("The bytes are not GZip compressed."); diff --git a/src/TickerQ.Utilities/TickerOptionsBuilder.cs b/src/TickerQ.Utilities/TickerOptionsBuilder.cs index 62cf3ad8..fc782fb3 100644 --- a/src/TickerQ.Utilities/TickerOptionsBuilder.cs +++ b/src/TickerQ.Utilities/TickerOptionsBuilder.cs @@ -4,10 +4,11 @@ using Microsoft.Extensions.DependencyInjection; using TickerQ.Utilities.Entities; using TickerQ.Utilities.Interfaces; +using TickerQ.Utilities.Interfaces.Managers; namespace TickerQ.Utilities { - public class TickerOptionsBuilder + public class TickerOptionsBuilder : ITickerOptionsSeeding where TTimeTicker : TimeTickerEntity, new() where TCronTicker : CronTickerEntity, new() { @@ -18,8 +19,37 @@ internal TickerOptionsBuilder(TickerExecutionContext tickerExecutionContext, Sch { _tickerExecutionContext = tickerExecutionContext; _schedulerOptions = schedulerOptions; + // Store this instance in the execution context for later retrieval + tickerExecutionContext.OptionsSeeding = this; } + /// + /// Internal flag for request GZip compression. + /// Defaults to false (plain JSON bytes). + /// + internal bool RequestGZipCompressionEnabled { get; set; } = false; + + /// + /// Controls whether code-defined cron tickers are seeded on startup. + /// Defaults to true. + /// + internal bool SeedDefinedCronTickers { get; set; } = true; + + /// + /// Seeding delegate for time tickers, executed with the application's service provider. + /// + internal Func TimeSeederAction { get; set; } + + /// + /// Seeding delegate for cron tickers, executed with the application's service provider. + /// + internal Func CronSeederAction { get; set; } + + // Explicit interface implementation for ITickerOptionsSeeding + bool ITickerOptionsSeeding.SeedDefinedCronTickers => SeedDefinedCronTickers; + Func ITickerOptionsSeeding.TimeSeederAction => TimeSeederAction; + Func ITickerOptionsSeeding.CronSeederAction => CronSeederAction; + internal Action ExternalProviderConfigServiceAction { get; set; } internal Action DashboardServiceAction { get; set; } internal Type TickerExceptionHandlerType { get; private set; } @@ -48,6 +78,72 @@ public TickerOptionsBuilder ConfigureRequestJsonOption configure?.Invoke(RequestJsonSerializerOptions); return this; } + + /// + /// Enables GZip compression for ticker request payloads. + /// When not called, requests are stored as plain UTF-8 JSON bytes. + /// + /// The TickerOptionsBuilder for method chaining + public TickerOptionsBuilder UseGZipCompression() + { + RequestGZipCompressionEnabled = true; + return this; + } + + /// + /// Disable automatic seeding of code-defined cron tickers on startup. + /// + public TickerOptionsBuilder IgnoreSeedDefinedCronTickers() + { + SeedDefinedCronTickers = false; + return this; + } + + /// + /// Configure a custom seeder for time tickers, executed on application startup. + /// + public TickerOptionsBuilder UseTickerSeeder( + Func, System.Threading.Tasks.Task> timeSeeder) + { + if (timeSeeder == null) return this; + + TimeSeederAction = async sp => + { + var manager = sp.GetRequiredService>(); + await timeSeeder(manager).ConfigureAwait(false); + }; + + return this; + } + + /// + /// Configure a custom seeder for cron tickers, executed on application startup. + /// + public TickerOptionsBuilder UseTickerSeeder( + Func, System.Threading.Tasks.Task> cronSeeder) + { + if (cronSeeder == null) return this; + + CronSeederAction = async sp => + { + var manager = sp.GetRequiredService>(); + await cronSeeder(manager).ConfigureAwait(false); + }; + + return this; + } + + /// + /// Configure custom seeders for both time and cron tickers, executed on application startup. + /// + public TickerOptionsBuilder UseTickerSeeder( + Func, System.Threading.Tasks.Task> timeSeeder, + Func, System.Threading.Tasks.Task> cronSeeder) + { + UseTickerSeeder(timeSeeder); + UseTickerSeeder(cronSeeder); + return this; + } public TickerOptionsBuilder SetExceptionHandler() where THandler : ITickerExceptionHandler { diff --git a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs index e57e3fc8..ed4afb80 100644 --- a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs +++ b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs @@ -38,6 +38,9 @@ public static IServiceCollection AddTickerQ(this IServ { TickerHelper.RequestJsonSerializerOptions = optionInstance.RequestJsonSerializerOptions; } + + // Configure whether ticker request payloads should use GZip compression + TickerHelper.UseGZipCompression = optionInstance.RequestGZipCompressionEnabled; services.AddSingleton, TickerManager>(); services.AddSingleton, TickerManager>(); services.AddSingleton>(); @@ -99,14 +102,31 @@ public static IApplicationBuilder UseTickerQ(this IApplicationBuilder app, Ticke else if (type == CoreNotifyActionType.NotifyThreadCount) notificationHubSender.UpdateActiveThreads(value); }; + + // Run core seeding pipeline based on main options (works for both in-memory and EF providers). + var options = tickerExecutionContext.OptionsSeeding; + if (options == null || options.SeedDefinedCronTickers) + { + SeedDefinedCronTickers(serviceProvider).GetAwaiter().GetResult(); + } + + if (options?.TimeSeederAction != null) + { + options.TimeSeederAction(serviceProvider).GetAwaiter().GetResult(); + } + + if (options?.CronSeederAction != null) + { + options.CronSeederAction(serviceProvider).GetAwaiter().GetResult(); + } + + // Let external providers (e.g., EF Core) perform their own startup logic (dead-node cleanup, etc.). if (tickerExecutionContext.ExternalProviderApplicationAction != null) { tickerExecutionContext.ExternalProviderApplicationAction(serviceProvider); tickerExecutionContext.ExternalProviderApplicationAction = null; } - else - SeedDefinedCronTickers(serviceProvider).GetAwaiter().GetResult(); if (tickerExecutionContext?.DashboardApplicationAction != null) { diff --git a/src/TickerQ/Exceptions/TerminateExecutionException.cs b/src/TickerQ/Exceptions/TerminateExecutionException.cs index 26227930..4c4d9b64 100644 --- a/src/TickerQ/Exceptions/TerminateExecutionException.cs +++ b/src/TickerQ/Exceptions/TerminateExecutionException.cs @@ -3,7 +3,7 @@ namespace TickerQ.Exceptions { - internal class TerminateExecutionException : Exception + public class TerminateExecutionException : Exception { internal readonly TickerStatus Status = TickerStatus.Skipped; public TerminateExecutionException(string message) : base(message) { } diff --git a/src/TickerQ/Src/BackgroundServices/TickerQFallbackBackgroundService.cs b/src/TickerQ/Src/BackgroundServices/TickerQFallbackBackgroundService.cs index fe5517d0..840c0d33 100644 --- a/src/TickerQ/Src/BackgroundServices/TickerQFallbackBackgroundService.cs +++ b/src/TickerQ/Src/BackgroundServices/TickerQFallbackBackgroundService.cs @@ -34,6 +34,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { + // If the scheduler is frozen or disposed (e.g., manual start mode or shutdown), + // skip queuing fallback work to avoid throwing and stopping the host. + if (_tickerQTaskScheduler.IsFrozen || _tickerQTaskScheduler.IsDisposed) + { + await Task.Delay(_fallbackJobPeriod, stoppingToken); + continue; + } + var functions = await _internalTickerManager.RunTimedOutTickers(stoppingToken); if (functions.Length != 0) @@ -63,8 +71,19 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } } - - await _tickerQTaskScheduler.QueueAsync(ct => _tickerExecutionTaskHandler.ExecuteTaskAsync(function, true, ct), function.CachedPriority, stoppingToken); + + try + { + await _tickerQTaskScheduler.QueueAsync( + ct => _tickerExecutionTaskHandler.ExecuteTaskAsync(function, true, ct), + function.CachedPriority, + stoppingToken); + } + catch (InvalidOperationException) when (_tickerQTaskScheduler.IsFrozen || _tickerQTaskScheduler.IsDisposed) + { + // Scheduler is frozen/disposed – ignore and let loop delay + break; + } } await Task.Delay(TimeSpan.FromMilliseconds(10), stoppingToken); diff --git a/src/TickerQ/Src/TickerExecutionTaskHandler.cs b/src/TickerQ/Src/TickerExecutionTaskHandler.cs index c91a0081..7145d88d 100644 --- a/src/TickerQ/Src/TickerExecutionTaskHandler.cs +++ b/src/TickerQ/Src/TickerExecutionTaskHandler.cs @@ -147,7 +147,7 @@ private async Task RunContextFunctionAsync(InternalFunctionContext context, bool Id = context.TickerId, Type = context.Type, IsDue = isDue, - CancelOperationAction = () => cancellationTokenSource.Cancel(), + RequestCancelOperationAction = () => cancellationTokenSource.Cancel(), CronOccurrenceOperations = new CronOccurrenceOperations { SkipIfAlreadyRunningAction = () =>