diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 9ac86d7c6..84cc7096c 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -49,6 +49,12 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates /// Column display order per dashboard table, excluding utility columns. public List? ColumnOrder { get; set; } + /// Whether dashboard statistic cards are shown for this view. Null means use the default. + public bool? ShowStats { get; set; } + + /// Whether the dashboard chart is shown for this view. Null means use the default. + public bool? ShowChart { get; set; } + /// Display name shown in the sidebar and picker. [Required] [MaxLength(100)] diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte index 7fed760c3..971710fd1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte @@ -35,9 +35,17 @@ - + - Event Details + + Event Details + +
{#if eventId} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte index bb4d7fa3f..e11ff5d9b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte @@ -31,7 +31,7 @@ }, stacks: { color: 'var(--chart-2)', - label: 'Unique Events (Stacks)' + label: 'Issues' } } satisfies Chart.ChartConfig; @@ -39,7 +39,7 @@ { color: chartConfig.stacks.color, key: 'stacks', - label: 'Unique Events (Stacks)' + label: 'Issues' }, { color: chartConfig.events.color, @@ -49,7 +49,7 @@ ]; -
+ {#if isLoading} {:else} @@ -98,4 +98,4 @@ {/if} -
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte index 49aff3c6e..bf9a7baff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte @@ -37,7 +37,7 @@ ]; -
+ {#if isLoading} {:else} @@ -68,4 +68,4 @@ {/if} -
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stats-dashboard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stats-dashboard.svelte new file mode 100644 index 000000000..206a16712 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stats-dashboard.svelte @@ -0,0 +1,138 @@ + + +
+ + +
+
+ + + + Total event occurrences matching the current filters. + +
+ + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+ + + +
+
+ + + + Unique issues matching the current filters. + +
+ + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+ + + +
+
+ + + + Issues with their first occurrence in the selected time range. + +
+ + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+ + + +
+
+ + + + Average event occurrences per hour across the selected time range. + +
+ + {#if isLoading} + + {:else} +
+ +
+ {/if} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index f3c25de44..9bf8cd3b7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -51,6 +51,10 @@ onLoadView: (id: string) => void; onResetToSaved: () => void; savedViews: SavedView[]; + setShowChart?: (show: boolean) => void; + setShowStats?: (show: boolean) => void; + showChart?: boolean; + showStats?: boolean; sort?: string; table: Table; time?: string; @@ -67,6 +71,10 @@ onLoadView, onResetToSaved, savedViews, + setShowChart, + setShowStats, + showChart = true, + showStats = true, sort, table, time, @@ -234,6 +242,8 @@ is_private: isPrivate || undefined, name, organization_id: organizationId, + show_chart: showChart, + show_stats: showStats, sort: sort || undefined, time: time || undefined, view_type: view @@ -273,6 +283,8 @@ columns: columnVisibility, filter: currentFilterString || null, filter_definitions: serializeFilters(filters), + show_chart: showChart, + show_stats: showStats, sort: sort || null, time: time || null }; @@ -346,6 +358,36 @@ {/if} + {#if setShowStats || setShowChart} + + + Display + {#if setShowStats} + { + event.preventDefault(); + setShowStats(!showStats); + }} + onSelect={(event) => event.preventDefault()} + > + Stat boxes + + {/if} + {#if setShowChart} + { + event.preventDefault(); + setShowChart(!showChart); + }} + onSelect={(event) => event.preventDefault()} + > + Chart + + {/if} + + {/if} {#if reorderableColumns.length > 0} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 943d14b2e..367603fd5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -22,9 +22,13 @@ export interface UseSavedViewsOptions { getColumnOrder?: () => ColumnOrderState; getColumnVisibility?: () => ColumnVisibilityState; getFilterDefinitions?: () => string; + getShowChart?: () => boolean; + getShowStats?: () => boolean; queryParams: SavedViewQueryParams; setColumnOrder?: (order: ColumnOrderState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; + setShowChart?: (show: boolean) => void; + setShowStats?: (show: boolean) => void; updateFilterCache: (key: string, filters: IFilter[]) => void; view: string; } @@ -90,6 +94,11 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } } + function applyDisplayState(view: Pick | undefined): void { + options.setShowStats?.(view?.show_stats ?? true); + options.setShowChart?.(view?.show_chart ?? true); + } + // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found. // lastLoadedViewId prevents re-hydration on background refetches (which would stomp user edits). let lastLoadedViewId = ''; @@ -103,6 +112,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if (!savedId) { if (lastLoadedViewId !== '') { applyColumnState(undefined); + applyDisplayState(undefined); } lastLoadedViewId = ''; @@ -148,6 +158,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); applyColumnState(view); + applyDisplayState(view); }); // Detect if current filters or columns differ from the active saved view @@ -181,6 +192,14 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } + if (options.getShowStats && options.getShowStats() !== (view.show_stats ?? true)) { + return true; + } + + if (options.getShowChart && options.getShowChart() !== (view.show_chart ?? true)) { + return true; + } + return false; }); @@ -207,6 +226,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); applyColumnState(view); + applyDisplayState(view); } function handleClearSavedView() { @@ -215,6 +235,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); applyColumnState(undefined); + applyDisplayState(undefined); } return { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte index 8405d84b4..9b3601a8b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/sessions-dashboard-chart.svelte @@ -49,7 +49,7 @@ ]; -
+ {#if isLoading} {:else} @@ -98,4 +98,4 @@ {/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 index 67bc32609..ea4164591 100644 --- 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 @@ -3,10 +3,13 @@ import Number from '$comp/formatters/number.svelte'; import * as Card from '$comp/ui/card'; import { Skeleton } from '$comp/ui/skeleton'; + import * as Tooltip from '$comp/ui/tooltip'; import AreaChart from '@lucide/svelte/icons/area-chart'; import Clock from '@lucide/svelte/icons/clock'; + import Info from '@lucide/svelte/icons/info'; import LineChart from '@lucide/svelte/icons/trending-up'; import Users from '@lucide/svelte/icons/users'; + import prettyMilliseconds from 'pretty-ms'; interface Props { avgDuration?: number; @@ -17,75 +20,175 @@ } let { avgDuration = 0, avgPerHour = 0, isLoading = false, totalSessions = 0, totalUsers = 0 }: Props = $props(); + + const metricCardClass = + "relative h-[66px]! justify-between gap-1! overflow-hidden bg-card py-2! ring-[color-mix(in_oklab,var(--chart-1)_42%,transparent)] before:absolute before:inset-x-0 before:top-0 before:h-1 before:bg-[linear-gradient(90deg,var(--chart-1),var(--chart-2))] before:content-['']"; + const metricHeaderClass = 'flex flex-row items-center justify-between gap-1.5 px-3 pb-0'; + const metricTitleClass = 'min-w-0 truncate text-xs font-semibold text-[color-mix(in_oklab,var(--chart-2)_82%,var(--foreground))]'; + const metricIconClass = 'size-3.5 shrink-0 text-[var(--chart-2)]'; + const metricValueClass = 'truncate text-lg leading-none font-bold text-[var(--chart-2)] tabular-nums sm:text-xl'; + + const compactAverageDuration = $derived(avgDuration > 0 ? prettyMilliseconds(avgDuration * 1000, { compact: true, secondsDecimalDigits: 0 }) : 'โ€”'); + const preciseAverageDuration = $derived(avgDuration > 0 ? prettyMilliseconds(avgDuration * 1000, { secondsDecimalDigits: 0, unitCount: 2 }) : 'โ€”'); -
- - - Sessions - +
+ + +
+
+ + + + Total sessions matching the current filters. +
- + {#if isLoading} - + {:else} -
+
{/if} - - - Sessions Per Hour - + + +
+
+ + + + Average sessions per hour across the selected time range. +
- + {#if isLoading} - + {:else} -
+
{/if} - - - Users - + + +
+
+ + + + Unique users seen in matching sessions. +
- + {#if isLoading} - + {:else} -
+
{/if} - - - Average Duration - + + +
+
+ + + + + Average session duration. + {#if avgDuration > 0} + Full value: . + {/if} + +
- + {#if isLoading} - + {:else} -
- {#if avgDuration > 0} - - - {:else} - โ€” - {/if} +
+ {compactAverageDuration} + {preciseAverageDuration}
{/if}
+ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-shell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-shell.svelte new file mode 100644 index 000000000..9b7813452 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-shell.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
\ No newline at end of file diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/index.ts index f22375e09..58bd3b114 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/index.ts @@ -1,6 +1,7 @@ import ChartContainer from "./chart-container.svelte"; +import ChartShell from "./chart-shell.svelte"; import ChartTooltip from "./chart-tooltip.svelte"; export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js"; -export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip }; +export { ChartContainer, ChartShell, ChartTooltip, ChartContainer as Container, ChartShell as Shell, ChartTooltip as Tooltip }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sheet/sheet-content.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sheet/sheet-content.svelte index 703dd4e3b..9a479f9c2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sheet/sheet-content.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sheet/sheet-content.svelte @@ -17,10 +17,12 @@ class: className, side = "right", showCloseButton = true, + overlayProps, portalProps, children, ...restProps }: WithoutChildrenOrChild & { + overlayProps?: WithoutChildrenOrChild>; portalProps?: WithoutChildrenOrChild>; side?: Side; showCloseButton?: boolean; @@ -29,7 +31,7 @@ - + ; - column_order?: null | string[]; + column_order?: string[] | null; + show_stats?: null | boolean; + show_chart?: null | boolean; /** If true, the view will only be visible to the current user. Defaults to false. */ is_private?: null | boolean; } @@ -219,7 +221,10 @@ export interface PersistentEvent { created_utc: string; /** Used to store primitive data type custom data values for searching the event. */ idx?: null | Record; - /** The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. */ + /** + * The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. + * Nullable in transit; the pipeline infers a default before save. Validated as required on repository save. + */ type?: null | string; /** The event source (ie. machine name, log name, feature name). */ source?: null | string; @@ -372,7 +377,9 @@ export interface UpdateSavedView { sort?: null | string; filter_definitions?: null | string; columns?: null | Record; - column_order?: null | string[]; + column_order?: string[] | null; + show_stats?: null | boolean; + show_chart?: null | boolean; } /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ @@ -564,7 +571,9 @@ export interface ViewSavedView { filter?: null | string; filter_definitions?: null | string; columns?: null | Record; - column_order?: null | string[]; + column_order?: string[] | null; + show_stats?: null | boolean; + show_chart?: null | boolean; name: string; time?: null | string; sort?: null | string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 8667139eb..b4f664af0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -202,6 +202,8 @@ export const NewSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + show_stats: boolean().nullable().optional(), + show_chart: boolean().nullable().optional(), is_private: boolean().nullable().optional(), }); export type NewSavedViewFormData = Infer; @@ -288,8 +290,7 @@ export const PersistentEventSchema = object({ type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") - .nullable() - .optional(), + .nullable(), source: string() .min(1, "Source is required") .max(2000, "Source must be at most 2000 characters") @@ -424,6 +425,8 @@ export const UpdateSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + show_stats: boolean().nullable().optional(), + show_chart: boolean().nullable().optional(), }); export type UpdateSavedViewFormData = Infer; @@ -613,6 +616,8 @@ export const ViewSavedViewSchema = object({ .optional(), columns: record(string(), boolean()).nullable().optional(), column_order: array(string()).nullable().optional(), + show_stats: boolean().nullable().optional(), + show_chart: boolean().nullable().optional(), name: string().min(1, "Name is required"), time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index c35d0c38d..2774abbe5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -27,7 +27,7 @@ {#if isMediumScreenQuery.current} - + {:else} Exceptionless Logo {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte index 633f77c66..6979800f9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte @@ -1,9 +1,21 @@ {#key resetKey} - - + + No results found. + {#if hasSearchText && showRemoteSearchResults} + {#if showEventSearchResults} + + {#if eventSearchQuery.isPending} + + + Searching events... + + {:else} + {#each eventMatches as event (event.id)} + + +
+ {getResultTitle(event)} + {#if getResultDescription(event)} + {getResultDescription(event)} + {/if} +
+
+ {/each} + {#if hasMoreEventMatches} + + + View all matching events + + {/if} + {/if} +
+ {/if} + {#if showEventSearchResults && showIssueSearchResults} + + {/if} + {#if showIssueSearchResults} + + {#if issueSearchQuery.isPending} + + + Searching issues... + + {:else} + {#each issueMatches as issue (issue.id)} + + +
+ {getResultTitle(issue)} + {#if getResultDescription(issue)} + {getResultDescription(issue)} + {/if} +
+
+ {/each} + {#if hasMoreIssueMatches} + + + View all matching issues + + {/if} + {/if} +
+ {/if} + + {/if} {#each Object.entries(groupedRoutes) as [group, items], index (group)} {#each items as route (route.href)} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 26769721f..f869cfe77 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -12,6 +12,7 @@ import { getOrganizationCountQuery } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; + import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; import { DateFilter, ProjectFilter, StatusFilter } from '$features/events/components/filters'; import { applyTimeFilter, @@ -92,15 +93,21 @@ }); const VIEW = 'events'; + let showStats = $state(true); + let showChart = $state(true); const savedViewsState = useSavedViews({ defaultColumnVisibility: defaultEventColumnVisibility, filterCacheKey, getColumnOrder: () => table.store.state.columnOrder, getColumnVisibility: () => table.store.state.columnVisibility, getFilterDefinitions: () => serializeFilters(filters ?? []), + getShowChart: () => showChart, + getShowStats: () => showStats, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), setColumnVisibility: (v) => table.setColumnVisibility(v), + setShowChart: (v) => (showChart = v), + setShowStats: (v) => (showStats = v), updateFilterCache, view: VIEW }); @@ -297,6 +304,22 @@ })); }); + const stats = $derived.by(() => { + const aggregations = chartDataQuery.data?.aggregations; + const timeRange = parseDateMathRange(getQueryTime() || undefined); + const totalEvents = agg.sum(aggregations, 'sum_count')?.value ?? chartDataQuery.data?.total ?? 0; + const totalIssues = agg.cardinality(aggregations, 'cardinality_stack')?.value ?? 0; + const newIssues = agg.terms(aggregations, 'terms_first')?.buckets[0]?.total ?? 0; + const hours = Math.max((timeRange.end.getTime() - timeRange.start.getTime()) / 3_600_000, 1); + + return { + eventsPerHour: totalEvents / hours, + newIssues, + totalEvents, + totalIssues + }; + }); + function onRangeSelect(start: Date, end: Date) { onFilterChanged(new DateFilter('date', toDateMathRange(start, end))); } @@ -322,6 +345,10 @@ onClearSavedView={savedViewsState.handleClearSavedView} onResetToSaved={savedViewsState.handleResetToSaved} savedViews={savedViewsState.savedViews} + {showChart} + {showStats} + setShowChart={(v) => (showChart = v)} + setShowStats={(v) => (showStats = v)} sort={queryParams.sort ?? undefined} {table} time={queryParams.time ?? undefined} @@ -338,7 +365,19 @@
- + {#if showStats} + + {/if} + + {#if showChart} + + {/if} {#snippet footerChildren()} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index 160e52ba4..d1c17b877 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -11,6 +11,7 @@ import { type GetEventsParams, getOrganizationCountQuery, getStackEventsQuery } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; + import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; import { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; import { applyTimeFilter, @@ -106,14 +107,20 @@ }); const VIEW = 'issues'; + let showStats = $state(true); + let showChart = $state(true); const savedViewsState = useSavedViews({ filterCacheKey, getColumnOrder: () => table.store.state.columnOrder, getColumnVisibility: () => table.store.state.columnVisibility, getFilterDefinitions: () => serializeFilters(filters ?? []), + getShowChart: () => showChart, + getShowStats: () => showStats, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), setColumnVisibility: (v) => table.setColumnVisibility(v), + setShowChart: (v) => (showChart = v), + setShowStats: (v) => (showStats = v), updateFilterCache, view: VIEW }); @@ -310,6 +317,22 @@ })); }); + const stats = $derived.by(() => { + const aggregations = chartDataQuery.data?.aggregations; + const timeRange = parseDateMathRange(queryParams.time); + const totalEvents = agg.sum(aggregations, 'sum_count')?.value ?? chartDataQuery.data?.total ?? 0; + const totalIssues = agg.cardinality(aggregations, 'cardinality_stack')?.value ?? 0; + const newIssues = agg.terms(aggregations, 'terms_first')?.buckets[0]?.total ?? 0; + const hours = Math.max((timeRange.end.getTime() - timeRange.start.getTime()) / 3_600_000, 1); + + return { + eventsPerHour: totalEvents / hours, + newIssues, + totalEvents, + totalIssues + }; + }); + function onRangeSelect(start: Date, end: Date) { onFilterChanged(new DateFilter('date', toDateMathRange(start, end))); } @@ -335,6 +358,10 @@ onClearSavedView={savedViewsState.handleClearSavedView} onResetToSaved={savedViewsState.handleResetToSaved} savedViews={savedViewsState.savedViews} + {showChart} + {showStats} + setShowChart={(v) => (showChart = v)} + setShowStats={(v) => (showStats = v)} {table} time={queryParams.time ?? undefined} view={VIEW} @@ -350,7 +377,19 @@
- + {#if showStats} + + {/if} + + {#if showChart} + + {/if} {#snippet footerChildren()} diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index 0010c0c81..f8e324768 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -54,6 +54,10 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject [MaxLength(50)] public List? ColumnOrder { get; set; } + public bool? ShowStats { get; set; } + + public bool? ShowChart { get; set; } + /// If true, the view will only be visible to the current user. Defaults to false. public bool? IsPrivate { get; set; } diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs index e39852df9..d094e8595 100644 --- a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -19,6 +19,8 @@ public class UpdateSavedView : IValidatableObject public Dictionary? Columns { get; set; } [MaxLength(50)] public List? ColumnOrder { get; set; } + public bool? ShowStats { get; set; } + public bool? ShowChart { get; set; } public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs index 7022e650b..326ff94c8 100644 --- a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -24,6 +24,8 @@ public record ViewSavedView : IIdentity, IHaveDates public string? FilterDefinitions { get; set; } public Dictionary? Columns { get; set; } public List? ColumnOrder { get; set; } + public bool? ShowStats { get; set; } + public bool? ShowChart { get; set; } public string Name { get; set; } = null!; public string? Time { get; set; } public string? Sort { get; set; } diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index dc2c714b4..34f000b90 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Reflection; using Exceptionless.Core.Extensions; using Microsoft.AspNetCore.OpenApi; @@ -40,6 +41,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Apply data annotations from the inner type's property DataAnnotationHelper.ApplyToSchema(propertySchema, property); + ApplyArrayAnnotations(propertySchema, property); string propertyName = property.Name.ToLowerUnderscoredWords(); schema.Properties[propertyName] = propertySchema; @@ -133,9 +135,10 @@ private static OpenApiSchema CreateSchemaForType(Type type, bool isNullable) schema.AdditionalProperties = CreateSchemaForType(valueType, false); } } - else if (type.IsArray || (type.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(type))) + else if (TryGetEnumerableElementType(type, out var elementType)) { schemaType |= JsonSchemaType.Array; + schema.Items = CreateSchemaForType(elementType, false); } else { @@ -145,4 +148,40 @@ private static OpenApiSchema CreateSchemaForType(Type type, bool isNullable) schema.Type = schemaType; return schema; } + + private static void ApplyArrayAnnotations(OpenApiSchema schema, PropertyInfo property) + { + if (!schema.Type.HasValue || (schema.Type.Value & JsonSchemaType.Array) != JsonSchemaType.Array) + { + return; + } + + var maxLength = property.GetCustomAttribute(); + if (maxLength is { Length: > -1 }) + { + schema.MaxItems = maxLength.Length; + } + } + + private static bool TryGetEnumerableElementType(Type type, out Type elementType) + { + if (type.IsArray) + { + elementType = type.GetElementType() ?? typeof(object); + return true; + } + + if (type == typeof(string) || !typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) + { + elementType = typeof(object); + return false; + } + + var enumerableType = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ? type + : type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + + elementType = enumerableType?.GetGenericArguments()[0] ?? typeof(object); + return true; + } } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index db7dddf49..ace5b2d4c 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -440,7 +440,7 @@ "Token" ], "summary": "Create for organization", - "description": "This is a helper action that makes it easier to create a token for a specific organization.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "description": "This is a helper action that makes it easier to create a token for a specific organization.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { "name": "organizationId", @@ -564,7 +564,7 @@ "Token" ], "summary": "Create for project", - "description": "This is a helper action that makes it easier to create a token for a specific project.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "description": "This is a helper action that makes it easier to create a token for a specific project.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { "name": "projectId", @@ -1071,7 +1071,7 @@ "Auth" ], "summary": "Login", - "description": "Log in with your email address and password to generate a token scoped with your users roles.\n\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\n\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\nor append it onto the query string: ?access_token=MY_TOKEN\n\nPlease note that you can also use this token on the documentation site by placing it in the\nheaders api_key input box.", + "description": "Log in with your email address and password to generate a token scoped with your users roles.\r\n\r\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\r\n\r\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\r\nor append it onto the query string: ?access_token=MY_TOKEN\r\n\r\nPlease note that you can also use this token on the documentation site by placing it in the\r\nheaders api_key input box.", "requestBody": { "content": { "application/json": { @@ -1928,7 +1928,7 @@ "Event" ], "summary": "Submit event by POST", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", "parameters": [ { "name": "userAgent", @@ -2214,7 +2214,7 @@ "Event" ], "summary": "Submit event by POST for a specific project", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", "parameters": [ { "name": "projectId", @@ -3547,7 +3547,7 @@ "Event" ], "summary": "Submit event by GET", - "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n\nFeature usage named build with a duration of 10:\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -3681,7 +3681,7 @@ "Event" ], "summary": "Submit event type by GET", - "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage event named build with a value of 10:\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\n\nLog event with message, geo and extended data\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage event named build with a value of 10:\r\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\r\n\r\nLog event with message, geo and extended data\r\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -3817,7 +3817,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -3953,7 +3953,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -4683,7 +4683,7 @@ "Organization" ], "summary": "Change plan", - "description": "Upgrades or downgrades the organization's plan.\nAccepts parameters via JSON body (preferred) or query string (legacy).", + "description": "Upgrades or downgrades the organization's plan.\r\nAccepts parameters via JSON body (preferred) or query string (legacy).", "parameters": [ { "name": "id", @@ -7929,6 +7929,18 @@ "type": "string" } }, + "show_stats": { + "type": [ + "null", + "boolean" + ] + }, + "show_chart": { + "type": [ + "null", + "boolean" + ] + }, "is_private": { "type": [ "null", @@ -8152,7 +8164,7 @@ "null", "string" ], - "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types.\nNullable in transit; the pipeline infers a default before save. Validated as required on repository save." + "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types.\r\nNullable in transit; the pipeline infers a default before save. Validated as required on repository save." }, "source": { "maxLength": 2000, @@ -8583,9 +8595,25 @@ } }, "column_order": { + "maxItems": 50, "type": [ "null", "array" + ], + "items": { + "type": "string" + } + }, + "show_stats": { + "type": [ + "null", + "boolean" + ] + }, + "show_chart": { + "type": [ + "null", + "boolean" ] } }, @@ -9261,6 +9289,18 @@ "type": "string" } }, + "show_stats": { + "type": [ + "null", + "boolean" + ] + }, + "show_chart": { + "type": [ + "null", + "boolean" + ] + }, "name": { "type": "string" },