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 })}