diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a3040926c..65e65f651f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "cSpell.words": [ "acacode", "autodocs", + "axllent", "azurestorage", "Backdoors", "Bisectable", @@ -45,6 +46,7 @@ "legos", "lucene", "lucide", + "mailpit", "mypass", "nameof", "navigatetofirstpage", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 1d2d918657..2ff08d68bf 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -22,7 +22,9 @@ "test": "npm run test:unit -- --run && npm run test:e2e", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "upgrade": "ncu -i" + "update": "ncu -i", + "update:shadcn": "npx shadcn-svelte@latest update", + "update:skills": "npx skills update" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 2b407c2e42..c5151a01cf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -16,12 +16,14 @@ import type { PersistentEvent } from '../models/index'; + import { getSessionId } from '../utils'; import Environment from './views/environment.svelte'; import Error from './views/error.svelte'; import ExtendedData from './views/extended-data.svelte'; import Overview from './views/overview.svelte'; import PromotedExtendedData from './views/promoted-extended-data.svelte'; import Request from './views/request.svelte'; + import SessionEvents from './views/session-events.svelte'; import TraceLog from './views/trace-log.svelte'; interface Props { @@ -37,7 +39,7 @@ return []; } - const tabs = ['Overview']; + const tabs: TabType[] = ['Overview']; if (hasErrorOrSimpleError(event)) { tabs.push('Exception'); } @@ -54,6 +56,10 @@ tabs.push('Trace Log'); } + if (getSessionId(event)) { + tabs.push('Session Events'); + } + if (!project) { return tabs; } @@ -168,6 +174,8 @@ {:else if tab === 'Trace Log'} + {:else if tab === 'Session Events'} + {:else if tab === 'Extended Data'} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte index 921967df9a..d6678e589a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte @@ -1,6 +1,8 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte new file mode 100644 index 0000000000..55f7916bff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte @@ -0,0 +1,136 @@ + + +{#if !hasPremiumFeatures} + + + Premium Feature + Sessions are a premium feature. Upgrade your plan to view session events. + +{/if} + +
+ {#if isSessionStart} + + + + Duration + + + + + {#if event.data?.sessionend} + (ended ) + {/if} + + + {#if event.data?.sessionend} + (ended ) + {/if} + + + {#if userIdentity} + + User Identity + + {userIdentity} + + {/if} + {#if userName} + + User Name + + {userName} + + {/if} + + + {/if} + +

Session Events

+ + {#if sessionEventsQuery().isPending} +
+ {#each Array.from({ length: 5 }, (_, i) => i) as i (i)} + + {/each} +
+ {:else if sessionEventsQuery().isError} + + Error loading session events + {sessionEventsQuery().error?.message ?? 'Unknown error'} + + {:else if (sessionEventsQuery().data ?? []).length > 0} + + + + Summary + Session Time + + + + {#each sessionEventsQuery().data ?? [] as sessionEvent (sessionEvent.id)} + + + + {sessionEvent.id} + + + + + + + {/each} + + + {:else} +

No session events found.

+ {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts index 5ace934dda..1da46d594b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts @@ -1,5 +1,3 @@ -import { logLevels } from '../options'; - export interface EnvironmentInfo { architecture?: string; available_physical_memory?: number; @@ -129,36 +127,3 @@ export interface UserInfo { identity?: string; name?: string; } - -// TODO: Move to a helper. -export function getLogLevel(level?: LogLevel | null): LogLevel | null { - switch (level?.toLowerCase().trim()) { - case '0': - case 'false': - case 'no': - case 'off': - return 'off'; - case '1': - case 'trace': - case 'true': - case 'yes': - return 'trace'; - case 'debug': - return 'debug'; - case 'error': - return 'error'; - case 'fatal': - return 'fatal'; - case 'info': - return 'info'; - case 'warn': - return 'warn'; - default: - return level ?? null; - } -} - -export function getLogLevelDisplayName(level?: LogLevel | null): LogLevel | null { - const resolvedLevel = getLogLevel(level); - return logLevels.find((l) => l.value === resolvedLevel)?.label ?? level ?? null; -} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/utils/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/utils/index.ts new file mode 100644 index 0000000000..ed1ccf5494 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/utils/index.ts @@ -0,0 +1,76 @@ +import type { PersistentEvent } from '../models'; +import type { LogLevel } from '../models/event-data'; + +import { logLevels } from '../options'; + +export function getLogLevel(level?: LogLevel | null): LogLevel | null { + switch (level?.toLowerCase().trim()) { + case '0': + case 'false': + case 'no': + case 'off': + return 'off'; + case '1': + case 'trace': + case 'true': + case 'yes': + return 'trace'; + case 'debug': + return 'debug'; + case 'error': + return 'error'; + case 'fatal': + return 'fatal'; + case 'info': + return 'info'; + case 'warn': + return 'warn'; + default: + return level ?? null; + } +} + +export function getLogLevelDisplayName(level?: LogLevel | null): LogLevel | null { + const resolvedLevel = getLogLevel(level); + return logLevels.find((l) => l.value === resolvedLevel)?.label ?? level ?? null; +} + +/** + * Determine session ID from event. + * For session start events, use reference_id + * For other events, check @ref:session in data + * @param event + * @returns Session ID or undefined if not found + */ +export function getSessionId(event?: null | PersistentEvent): string | undefined { + if (!event) { + return undefined; + } + + // For session start events, use reference_id + if (event.type === 'session') { + return event.reference_id ?? undefined; + } + + // For other events, check @ref:session in data + return event.data?.['@ref:session'] as string | undefined; +} + +/** + * Returns the session duration in milliseconds for a given event. + * If the session has ended, uses the event value or the difference between sessionend and start. + * If the session is active, returns the duration from start to now. + */ +export function getSessionStartDuration(event: PersistentEvent): number { + if (event.data?.sessionend) { + if (event.value) { + return event.value * 1000; + } + if (event.date) { + return new Date(event.data.sessionend).getTime() - new Date(event.date).getTime(); + } + return 0; + } + // If session is active, duration is from start to now + return Date.now() - new Date(event.date).getTime(); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/leave-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/leave-organization-dialog.svelte index 114995ee87..a2eab35d25 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/leave-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/leave-organization-dialog.svelte @@ -21,7 +21,7 @@ Leave Organization - Are you sure you want to leave "{name}"? + Are you sure you want to leave "{name}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/remove-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/remove-organization-dialog.svelte index d035325b56..fda168a705 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/remove-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/remove-organization-dialog.svelte @@ -21,7 +21,7 @@ Delete Organization - Are you sure you want to delete "{name}"? + Are you sure you want to delete "{name}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/premium-upgrade-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/premium-upgrade-notification.svelte index b6afa92546..46976f1a4e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/premium-upgrade-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/premium-upgrade-notification.svelte @@ -8,15 +8,16 @@ interface Props extends NotificationProps { name: string; organizationId: string; + premiumFeatureName?: string; } - let { name, organizationId, ...restProps }: Props = $props(); + let { name, organizationId, premiumFeatureName = 'search', ...restProps }: Props = $props(); {name} is attempting to use a premium feature. Upgrade now - to enable search and other premium features! + to enable {premiumFeatureName} and other premium features! diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 36a2208b26..4ff0af6085 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -22,6 +22,7 @@ ignoreFree?: boolean; isChatEnabled: boolean; openChat: () => void; + premiumFeatureName?: string; requiresPremium?: boolean; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/hooks/use-premium-feature.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/hooks/use-premium-feature.svelte.ts new file mode 100644 index 0000000000..cc5c1a6089 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/hooks/use-premium-feature.svelte.ts @@ -0,0 +1,44 @@ +import { getContext, onDestroy } from 'svelte'; + +const NOTIFICATION_CONTEXT_KEY = 'organizationNotifications'; + +interface Notification { + feature: string; + id: number; + message: string; + timestamp: number; + type: 'premium-feature'; +} + +/** + * Triggers a premium feature notification for the current organization. + * @param featureName Feature name to display in the notification. + */ +export function usePremiumFeature(featureName?: string) { + const notifications = getContext(NOTIFICATION_CONTEXT_KEY) as undefined | { update: (fn: (n: Notification[]) => Notification[]) => void }; + let notificationId: null | number = null; + + if (notifications && featureName) { + const id = Date.now() + Math.floor(Math.random() * 10000); + notificationId = id; + notifications.update((n: Notification[]) => [ + ...n, + { + feature: featureName, + id, + message: `The feature "${featureName}" is available on premium plans.`, + timestamp: Date.now(), + type: 'premium-feature' + } + ]); + } + + onDestroy(() => { + if (!notifications || notificationId == null) { + return; + } + + notifications.update((n: Notification[]) => n.filter((notification: Notification) => notification.id !== notificationId)); + notificationId = null; + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-config-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-config-dialog.svelte index 14ffc302c8..8fca64b345 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-config-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-config-dialog.svelte @@ -21,7 +21,7 @@ Delete Configuration Value - Are you sure you want to delete "{name}"? + Are you sure you want to delete "{name}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-dialog.svelte index 927171a7c2..b807f102c7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/remove-project-dialog.svelte @@ -21,7 +21,7 @@ Delete Project - Are you sure you want to delete "{name}"? + Are you sure you want to delete "{name}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/reset-project-data-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/reset-project-data-dialog.svelte index a4274c1bc9..27dc7f6a7f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/reset-project-data-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/dialogs/reset-project-data-dialog.svelte @@ -21,8 +21,8 @@ Reset Project Data - Are you sure you want to reset all project data for "{name}"? - This action cannot be undone and will permanently erase all events, stacks, and associated data. + Are you sure you want to reset all project data for "{name}"? This + action cannot be undone and will permanently erase all events, stacks, and associated data. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/project-log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/project-log-level.svelte index 1cc2dcef8b..facb79ab1e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/project-log-level.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/project-log-level.svelte @@ -1,8 +1,10 @@ + +
+ {#if isLoading} + + {:else} + + d.sessions)))]} + {series} + axis={false} + grid={false} + brush={{ + onBrushEnd: (detail) => { + const [start, end] = detail.xDomain ?? []; + if (start instanceof Date && end instanceof Date) { + onRangeSelect?.(start, end); + } + } + }} + props={{ + area: { + curve: curveLinear + }, + canvas: { + class: 'cursor-crosshair' + }, + svg: { + class: 'cursor-crosshair' + } + }} + > + {#snippet tooltip()} + formatDateLabel(v as Date)} /> + {/snippet} + + + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-stats-dashboard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-stats-dashboard.svelte new file mode 100644 index 0000000000..4f6724269a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-stats-dashboard.svelte @@ -0,0 +1,87 @@ + + +
+ + + Sessions + + + + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+ + + + Sessions Per Hour + + + + {#if isLoading} + + {:else} +
+ {avgPerHour?.toFixed(1) ?? '0'} +
+ {/if} +
+
+ + + + Users + + + + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+ + + + Average Duration + + + + {#if isLoading} + + {:else} +
+ + +
+ {/if} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte index 66837ed1cd..15b9fa5ca2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte @@ -120,11 +120,11 @@ onValueSelected(option.value)} value={option.value}>
- +
{option.label} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/calendar/calendar-month-select.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/calendar/calendar-month-select.svelte index 8d88deb552..664afab8d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/calendar/calendar-month-select.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/calendar/calendar-month-select.svelte @@ -18,7 +18,11 @@ className )} > - + {#snippet child({ props, monthItems, selectedMonthItem })} {#each yearItems as yearItem (yearItem.value)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/field/field-error.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/field/field-error.svelte index 1d5cc5f595..94a46c2bf8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/field/field-error.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/field/field-error.svelte @@ -19,7 +19,7 @@ if (children) return true; // no errors - if (!errors) return false; + if (!errors || errors.length === 0) return false; // has an error but no message if (errors.length === 1 && !errors[0]?.message) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts index dbe47abfcd..d4b0b642a9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts @@ -16,10 +16,10 @@ export { Item, Link, PrevButton, //old - NextButton, //old - Ellipsis, - Previous, - Next, + NextButton, //old + Ellipsis, + Previous, + Next, // Root as Pagination, Content as PaginationContent, @@ -27,8 +27,8 @@ export { Item as PaginationItem, Link as PaginationLink, PrevButton as PaginationPrevButton, //old - NextButton as PaginationNextButton, //old - Ellipsis as PaginationEllipsis, - Previous as PaginationPrevious, - Next as PaginationNext + NextButton as PaginationNextButton, //old + Ellipsis as PaginationEllipsis, + Previous as PaginationPrevious, + Next as PaginationNext, }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/select/select-group.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/select/select-group.svelte index 5454fdb395..a1f43bf3d0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/select/select-group.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/select/select-group.svelte @@ -4,4 +4,4 @@ let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props(); - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/separator/separator.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/separator/separator.svelte index e11a6f5bab..f40999fa71 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/separator/separator.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/separator/separator.svelte @@ -14,7 +14,7 @@ bind:ref data-slot={dataSlot} class={cn( - "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", + "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/tooltip/tooltip-content.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/tooltip/tooltip-content.svelte index 788ec34db9..2662522874 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/tooltip/tooltip-content.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/tooltip/tooltip-content.svelte @@ -37,7 +37,7 @@ {#snippet child({ props })}
{ }); }); }); + + describe('Elastic date-math unit enforcement (lowercase d)', () => { + it('should accept now-7d as a valid expression', () => { + const result = parseDateMath('now-7d'); + expect(result.success).toBe(true); + expect(result.date).toEqual(new Date('2025-09-13T14:30:00Z')); + }); + + it('should reject now-7D (uppercase D is not a valid Elastic unit)', () => { + const result = parseDateMath('now-7D'); + expect(result.success).toBe(false); + }); + + it('should accept now-1d and resolve correctly', () => { + const result = parseDateMath('now-1d'); + expect(result.success).toBe(true); + expect(result.date).toEqual(new Date('2025-09-19T14:30:00Z')); + }); + + it('should reject now-1D (uppercase D)', () => { + const result = parseDateMath('now-1D'); + expect(result.success).toBe(false); + }); + + it('should parse [now-7d TO now] range correctly', () => { + const range = parseDateMathRange('[now-7d TO now]'); + expect(range.start).toEqual(new Date('2025-09-13T14:30:00Z')); + expect(range.end).toEqual(new Date('2025-09-20T14:30:00Z')); + }); + + it('should fail to parse [now-7D TO now] range (uppercase D)', () => { + const range = parseDateMathRange('[now-7D TO now]'); + // Should fall back to default range since uppercase D is invalid + expect(range.start).toEqual(new Date('2012-02-01')); + expect(range.end.getTime()).toBeCloseTo(new Date().getTime(), -3); + }); + + it('should accept rounding with lowercase d: now/d', () => { + const result = parseDateMath('now/d'); + expect(result.success).toBe(true); + }); + + it('should accept now-1d/d (subtraction + rounding with lowercase d)', () => { + const result = parseDateMath('now-1d/d'); + expect(result.success).toBe(true); + expect(result.date).toEqual(new Date('2025-09-19T00:00:00Z')); + }); + + it('should only accept documented Elastic units: y, M, w, d, h, H, m, s', () => { + const validUnits = ['y', 'M', 'w', 'd', 'h', 'H', 'm', 's']; + validUnits.forEach((unit) => { + const result = parseDateMath(`now-1${unit}`); + expect(result.success).toBe(true); + }); + }); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.ts index 35fc4b64be..001e7d272f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.ts @@ -34,10 +34,10 @@ export const TIME_UNIT_NAMES: Record = { * Enhanced to match all backend test cases exactly */ const DATE_MATH_REGEX = - /^(?now|\*|(?\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)(?:\|\|)?)(?(?:[+\-/]\d*[yMwdhHms])*)$/i; + /^(?now|\*|(?\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)(?:\|\|)?)(?(?:[+\-/]\d*[yMwdhHms])*)$/; /** Pre-compiled regex for operation parsing - more strict validation */ -const OPERATION_REGEX = /([+\-/])(\d*)([yMwdhHms])/gi; +const OPERATION_REGEX = /([+\-/])(\d*)([yMwdhHms])/g; /** Result of parsing a date math expression */ export interface DateMathResult { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts index 82f18c6562..1711101198 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts @@ -18,7 +18,7 @@ export function getColumns(): ColumnDef[] { enableSorting: false, header: 'API Key', meta: { - class: 'w-[180px]' + class: 'w-45' } }, { @@ -28,7 +28,7 @@ export function getColumns(): ColumnDef[] { enableSorting: false, header: 'Notes', meta: { - class: 'w-[200px]' + class: 'w-50' } }, { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/dialogs/remove-user-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/dialogs/remove-user-dialog.svelte index 58fa5748e1..cd393253de 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/dialogs/remove-user-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/dialogs/remove-user-dialog.svelte @@ -21,7 +21,7 @@ Remove User - Are you sure you want to remove "{name}"? + Are you sure you want to remove "{name}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts index c717ba9bc5..c41834776d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts @@ -18,7 +18,7 @@ export function getColumns(organizationId: string): Colu enableSorting: false, header: 'Email Address', meta: { - class: 'w-[200px]' + class: 'w-50' } }, { @@ -28,7 +28,7 @@ export function getColumns(organizationId: string): Colu enableSorting: false, header: 'Name', meta: { - class: 'w-[200px]' + class: 'w-50' } }, { @@ -38,7 +38,7 @@ export function getColumns(organizationId: string): Colu enableSorting: false, header: 'Invited', meta: { - class: 'w-[100px]' + class: 'w-25' } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/dialogs/remove-webhook-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/dialogs/remove-webhook-dialog.svelte index 3c6f5bbf5e..ecda54b2da 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/dialogs/remove-webhook-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/dialogs/remove-webhook-dialog.svelte @@ -21,7 +21,7 @@ Delete Webhook - Are you sure you want to delete "{url}"? + Are you sure you want to delete "{url}"? diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts index 0ddb02f882..8c5b84c009 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts @@ -17,7 +17,7 @@ export function getColumns(): ColumnDef[] { enableSorting: false, header: 'Url', meta: { - class: 'w-[200px]' + class: 'w-50' } }, { @@ -27,7 +27,7 @@ export function getColumns(): ColumnDef[] { enableSorting: false, header: 'Event Types', meta: { - class: 'w-[200px]' + class: 'w-50' } }, { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index e8a486d5f2..1bba323384 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte @@ -20,6 +20,7 @@ let { footer, header, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); + const reportRoutes = $derived(routes.filter((route) => route.group === 'Reports')); const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); const settingsIsActive = $derived(settingsRoutes.some((route) => route.href === page.url.pathname)); @@ -62,6 +63,27 @@ + {#if reportRoutes.length > 0} + + Reports + + {#each reportRoutes as route (route.href)} + {@const Icon = route.icon} + + + {#snippet child({ props })} + + + {route.title} + + {/snippet} + + + {/each} + + + {/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index eb7e0da037..9c67b4b9b6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -170,7 +170,13 @@ const organizations = $derived(organizationsQuery.data?.data ?? []); const impersonatingOrganizationId = $derived.by(() => { - const isUserOrganization = meQuery.data?.organization_ids.includes(organization.current ?? ''); + // Only consider impersonation if user data is loaded and user has organizations + const userOrgIds = meQuery.data?.organization_ids; + if (!userOrgIds || userOrgIds.length === 0 || !organization.current) { + return undefined; + } + + const isUserOrganization = userOrgIds.includes(organization.current); return isUserOrganization ? undefined : organization.current; }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte index 30a65c3125..796ea9e376 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte @@ -18,15 +18,15 @@
-
+
-
+
-
+
@@ -38,15 +38,15 @@
-
+
-
+
-
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte index 2fd5a2ad59..190b267029 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte @@ -84,7 +84,7 @@ {#if organizationQuery.isLoading}
- +
{:else if organizationQuery.error} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte index 97b665b870..14474fec0d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte @@ -112,7 +112,7 @@ {#if projectQuery.isLoading || organizationQuery.isLoading}
- +
{:else if projectQuery.error || organizationQuery.error} @@ -145,7 +145,7 @@

- + + import type { GetEventsParams } from '$features/events/api.svelte'; + import type { EventSummaryModel, SummaryTemplateKeys } from '$features/events/components/summary/index'; + + import { resolve } from '$app/paths'; + import { page } from '$app/state'; + import * as DataTable from '$comp/data-table'; + import DataTableViewOptions from '$comp/data-table/data-table-view-options.svelte'; + import * as FacetedFilter from '$comp/faceted-filter'; + import RefreshButton from '$comp/refresh-button.svelte'; + import { A, H3 } from '$comp/typography'; + import * as Alert from '$comp/ui/alert'; + import { Button } from '$comp/ui/button'; + import { Label } from '$comp/ui/label'; + import * as Sheet from '$comp/ui/sheet'; + import { Switch } from '$comp/ui/switch'; + import EventsOverview from '$features/events/components/events-overview.svelte'; + import { DateFilter, ProjectFilter, TypeFilter } from '$features/events/components/filters'; + import { + applyTimeFilter, + buildFilterCacheKey, + filterCacheVersionNumber, + filterChanged, + filterRemoved, + getFiltersFromCache, + toFilter, + updateFilterCache + } from '$features/events/components/filters/helpers.svelte'; + import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; + import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; + import { getColumns } from '$features/events/components/table/options.svelte'; + import { getOrganizationQuery } from '$features/organizations/api.svelte'; + import { organization } from '$features/organizations/context.svelte'; + import { getOrganizationSessionsCountQuery } from '$features/sessions/api.svelte'; + import SessionsDashboardChart from '$features/sessions/components/sessions-dashboard-chart.svelte'; + import SessionsStatsDashboard from '$features/sessions/components/sessions-stats-dashboard.svelte'; + import * as agg from '$features/shared/api/aggregations'; + import { getSharedTableOptions, isTableEmpty, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; + import { fillDateSeries } from '$features/shared/utils/charts.js'; + import { parseDateMathRange, toDateMathRange } from '$features/shared/utils/datemath'; + import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; + import { DEFAULT_LIMIT, DEFAULT_OFFSET, useFetchClientStatus } from '$shared/api/api.svelte'; + import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import ExternalLink from '@lucide/svelte/icons/external-link'; + import InfoIcon from '@lucide/svelte/icons/info'; + import { createTable } from '@tanstack/svelte-table'; + import { queryParamsState } from 'kit-query-params'; + import { useEventListener, watch } from 'runed'; + import { throttle } from 'throttle-debounce'; + + let selectedEventId: null | string = $state(null); + function rowclick(row: EventSummaryModel) { + selectedEventId = row.id; + } + + function rowHref(row: EventSummaryModel): string { + return resolve('/(app)/event/[eventId]', { eventId: row.id }); + } + + // Organization query to check premium features + const organizationQuery = getOrganizationQuery({ + route: { + get id() { + return organization.current; + } + } + }); + + const hasPremiumFeatures = $derived(organizationQuery.data?.has_premium_features ?? false); + + // View Active toggle state + let viewActive = $state(false); + + const DEFAULT_TIME_RANGE = '[now-7d TO now]'; + const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new TypeFilter(['session'])]; + const DEFAULT_PARAMS = { + filter: 'type:session', + limit: DEFAULT_LIMIT, + time: DEFAULT_TIME_RANGE + }; + + function filterCacheKey(filter: null | string): string { + return buildFilterCacheKey(organization.current, page.url.pathname, filter); + } + + updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); + const queryParams = queryParamsState({ + default: DEFAULT_PARAMS, + pushHistory: true, + schema: { + filter: 'string', + limit: 'number', + time: 'string' + } + }); + + watch( + () => organization.current, + () => { + updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); + Object.assign(queryParams, DEFAULT_PARAMS); + reset(); + }, + { lazy: true } + ); + + let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter), queryParams.time)); + watch( + [() => queryParams.filter, () => queryParams.time, () => filterCacheVersionNumber()], + ([filter, time]) => { + filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), time); + }, + { lazy: true } + ); + + $effect(() => { + queryParams.limit ??= DEFAULT_LIMIT; + }); + + function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter): void { + updateFilters(filterChanged(filters ?? [], addedOrUpdated)); + selectedEventId = null; + } + + function onFilterRemoved(removed?: FacetedFilter.IFilter): void { + updateFilters(filterRemoved(filters ?? [], removed)); + } + + function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { + const filter = toFilter(updatedFilters.filter((f) => f.type !== 'date')); + + updateFilterCache(filterCacheKey(filter), updatedFilters); + queryParams.time = (updatedFilters.find((f) => f.type === 'date') as DateFilter)?.value as string; + queryParams.filter = filter; + } + + // Build filter with active sessions toggle + function activeFilter(): string { + let filter = queryParams.filter ?? 'type:session'; + if (viewActive) { + filter += ' _missing_:data.sessionend'; + } + return filter; + } + + const eventsQueryParameters: GetEventsParams = $state({ + get filter() { + return activeFilter(); + }, + set filter(value) { + queryParams.filter = value; + }, + get limit() { + return queryParams.limit!; + }, + set limit(value) { + queryParams.limit = value; + }, + mode: 'summary', + offset: DEFAULT_OFFSET, + get time() { + return queryParams.time!; + }, + set time(value) { + queryParams.time = value; + } + }); + + const client = useFetchClient(); + const clientStatus = useFetchClientStatus(client); + let clientResponse = $state[]>>(); + + const table = createTable( + getSharedTableOptions>({ + columnPersistenceKey: 'sessions-column-visibility', + get columns() { + return getColumns>(eventsQueryParameters.mode); + }, + paginationStrategy: 'cursor', + get queryData() { + return clientResponse?.data ?? []; + }, + get queryMeta() { + return clientResponse?.meta; + }, + get queryParameters() { + return eventsQueryParameters; + } + }) + ); + + const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.getState().pagination.pageIndex === 0); + + function reset() { + table.resetRowSelection(); + table.setPageIndex(0); + } + + async function handleRefresh() { + if (!canRefresh) { + reset(); + } + + await loadData(); + } + + async function loadData() { + if (client.isLoading || !organization.current) { + return; + } + + clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events/sessions`, { + params: eventsQueryParameters as Record + }); + } + + const throttledLoadData = throttle(10000, loadData); + + async function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { + if (message.id && message.change_type === ChangeType.Removed) { + removeTableSelection(table, message.id); + + if (removeTableData(table, (doc) => doc.id === message.id)) { + if (isTableEmpty(table)) { + await throttledLoadData(); + return; + } + } + } + } + + useEventListener(document, 'PersistentEventChanged', async (event) => await onPersistentEventChanged((event as CustomEvent).detail)); + + $effect(() => { + loadData(); + }); + + // Session stats query with aggregations + const statsQuery = getOrganizationSessionsCountQuery({ + params: { + get aggregations() { + return `avg:value cardinality:user date:(date${DEFAULT_OFFSET ? `^${DEFAULT_OFFSET}` : ''} cardinality:user)`; + }, + get filter() { + return activeFilter(); + }, + get time() { + return eventsQueryParameters.time; + } + }, + route: { organizationId: organization.current } + }); + + // Compute stats from aggregations + const stats = $derived.by(() => { + if (!statsQuery.data?.aggregations) { + return { + avgDuration: 0, + avgPerHour: 0, + totalSessions: 0, + totalUsers: 0 + }; + } + + const avgValue = agg.average(statsQuery.data.aggregations, 'avg_value')?.value ?? 0; + const cardinalityUser = agg.cardinality(statsQuery.data.aggregations, 'cardinality_user')?.value ?? 0; + const total = statsQuery.data.total ?? 0; + + // Calculate avg per hour based on time range + const timeRange = parseDateMathRange(queryParams.time); + const hours = (timeRange.end.getTime() - timeRange.start.getTime()) / (1000 * 60 * 60); + const avgPerHour = hours > 0 ? total / hours : 0; + + return { + avgDuration: avgValue, + avgPerHour, + totalSessions: total, + totalUsers: cardinalityUser + }; + }); + + // Chart data from date histogram + const chartData = $derived(() => { + const timeRange = parseDateMathRange(queryParams.time); + + const buildZeroFilledSeries = () => + fillDateSeries(timeRange.start, timeRange.end, (date: Date) => ({ + date, + sessions: 0, + users: 0 + })); + + if (!statsQuery.data?.aggregations) { + return buildZeroFilledSeries(); + } + + const dateHistogramBuckets = agg.dateHistogram(statsQuery.data.aggregations, 'date_date')?.buckets ?? []; + if (dateHistogramBuckets.length === 0) { + return buildZeroFilledSeries(); + } + + return dateHistogramBuckets.map((bucket) => ({ + date: new Date(bucket.key), + sessions: bucket.total ?? 0, + users: agg.cardinality(bucket.aggregations, 'cardinality_user')?.value ?? 0 + })); + }); + + function onRangeSelect(start: Date, end: Date) { + onFilterChanged(new DateFilter('date', toDateMathRange(start, end))); + } + + function handleUpgrade() { + // Navigate to billing page + if (organization.current) { + window.location.href = resolve('/(app)/organization/[organizationId]/billing', { organizationId: organization.current }); + } + } + + +
+ {#if organizationQuery.isSuccess && !hasPremiumFeatures} + + + Premium Feature + + Upgrade now to enable sessions and other premium features! + + + {/if} + +
+

Sessions

+
+ + + +
+
+
+ + +
+ + +
+
+ +
+ + + + + + {#snippet footerChildren()} + + +
+ + +
+ {/snippet} +
+
+
+ + (selectedEventId = null)} open={!!selectedEventId}> + + + Event Details + +
+ (selectedEventId = null)} /> +
+
+
diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 68da397f09..4c8927bffa 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -21,6 +21,8 @@ + +