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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyDbContext>(efOpt =>
{
efOpt.SetExceptionHandler<MyExceptionHandlerClass>();
efOpt.UseModelCustomizerForMigrations();
});
options.AddDashboard(uiopt =>
options.AddDashboard(dashboardOptions =>
{
uiopt.BasePath = "/tickerq-dashboard";
uiopt.AddDashboardBasicAuth();
dashboardOptions.SetBasePath("/tickerq/dashboard");
dashboardOptions.WithBasicAuth("admin", "secure-password");
}
});

Expand Down
2 changes: 1 addition & 1 deletion src/TickerQ.Dashboard/DashboardOptionsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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> CorsPolicyBuilder { get; set; }
internal string BackendDomain { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ internal static void AddDashboardService<TTimeTicker, TCronTicker>(this IService

// Register the dashboard configuration for DI
services.AddSingleton(config);
services.AddSingleton(config.Auth);
services.AddScoped<IAuthService, AuthService>();

services.AddRouting();
services.AddSignalR();
Expand Down
21 changes: 20 additions & 1 deletion src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ private static IResult GetOptions<TTimeTicker, TCronTicker>(
maxConcurrency = schedulerOptions.MaxConcurrency,
schedulerOptions.IdleWorkerTimeOut,
currentMachine = schedulerOptions.NodeIdentifier,
executionContext.LastHostExceptionMessage
executionContext.LastHostExceptionMessage,
schedulerTimeZone = schedulerOptions.SchedulerTimeZone?.Id
}, dashboardOptions.DashboardJsonOptions);
}

Expand Down Expand Up @@ -294,6 +295,7 @@ private static async Task<IResult> CreateChainJobs<TTimeTicker, TCronTicker>(
HttpContext context,
ITimeTickerManager<TTimeTicker> timeTickerManager,
DashboardOptionsBuilder dashboardOptions,
string timeZoneId,
CancellationToken cancellationToken)
where TTimeTicker : TimeTickerEntity<TTimeTicker>, new()
where TCronTicker : CronTickerEntity, new()
Expand All @@ -304,6 +306,14 @@ private static async Task<IResult> CreateChainJobs<TTimeTicker, TCronTicker>(

// Use Dashboard-specific JSON options
var chainRoot = JsonSerializer.Deserialize<TTimeTicker>(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);

Expand All @@ -319,6 +329,7 @@ private static async Task<IResult> UpdateTimeTicker<TTimeTicker, TCronTicker>(
HttpContext context,
ITimeTickerManager<TTimeTicker> timeTickerManager,
DashboardOptionsBuilder dashboardOptions,
string timeZoneId,
CancellationToken cancellationToken)
where TTimeTicker : TimeTickerEntity<TTimeTicker>, new()
where TCronTicker : CronTickerEntity, new()
Expand All @@ -332,6 +343,14 @@ private static async Task<IResult> UpdateTimeTicker<TTimeTicker, TCronTicker>(

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

Expand Down
14 changes: 6 additions & 8 deletions src/TickerQ.Dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ services.AddTickerQ<MyTimeTicker, MyCronTicker>(config =>
});
```

### Bearer Token Authentication
### API Key Authentication
```csharp
services.AddTickerQ<MyTimeTicker, MyCronTicker>(config =>
{
config.AddDashboard(dashboard =>
{
dashboard.WithBearerToken("my-secret-api-key-12345");
dashboard.WithApiKey("my-secret-api-key-12345");
});
});
```
Expand All @@ -43,18 +43,16 @@ services.AddTickerQ<MyTimeTicker, MyCronTicker>(config =>
{
config.AddDashboard(dashboard =>
{
dashboard.WithHostAuthentication(
requiredRoles: new[] { "Admin", "TickerQUser" }
);
dashboard.WithHostAuthentication();
});
});
```

## πŸ”§ 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
Expand All @@ -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. πŸŽ‰
That's it! Simple and clean. πŸŽ‰
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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()

Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -283,6 +296,59 @@ const handleForceUIUpdate = () => {
<strong>TickerQ</strong>
</h1>
</div>

<!-- Time Zone Menu -->
<div class="timezone-menu">
<v-menu
v-model="isTimeZoneMenuOpen"
location="bottom"
:close-on-content-click="false"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
size="small"
variant="text"
density="comfortable"
class="timezone-button"
prepend-icon="mdi-earth"
>
<span class="d-none d-sm-inline">
{{ timeZoneStore.effectiveTimeZone }}
</span>
</v-btn>
</template>

<v-card elevation="4" class="timezone-card">
<v-card-title class="text-subtitle-2">
Display Time Zone
</v-card-title>
<v-card-text class="pt-2">
<v-select
density="compact"
variant="outlined"
hide-details="auto"
:items="[
{ label: `Scheduler (${timeZoneStore.schedulerTimeZone || 'UTC'})`, value: null },
...timeZoneStore.availableTimeZones.map(tz => ({ label: tz, value: tz }))
]"
item-title="label"
item-value="value"
v-model="timeZoneStore.selectedTimeZone"
/>
<v-btn
class="mt-3"
size="small"
variant="text"
prepend-icon="mdi-restore"
@click="timeZoneStore.setSelectedTimeZone(null)"
>
Reset to server timezone
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</div>
</div>

<div class="header-center">
Expand Down Expand Up @@ -1100,4 +1166,4 @@ const handleForceUIUpdate = () => {
padding: 20px 0 !important;
}
}
</style>
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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{
Expand All @@ -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({
Expand All @@ -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')
})
Expand All @@ -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')
})
Expand Down
Loading
Loading