From 34cbf129e98c977869f58540a012419ab9d52b15 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 21 May 2026 00:27:59 -0500 Subject: [PATCH 1/3] Polish dashboards and saved views --- src/Exceptionless.Core/Models/SavedView.cs | 6 + .../components/event-detail-sheet.svelte | 12 +- .../components/events-dashboard-chart.svelte | 8 +- .../components/events-stack-chart.svelte | 4 +- .../components/events-stats-dashboard.svelte | 138 ++++++++++++ .../components/saved-view-picker.svelte | 42 ++++ .../saved-views/use-saved-views.svelte.ts | 21 ++ .../sessions-dashboard-chart.svelte | 4 +- .../sessions-stats-dashboard.svelte | 173 ++++++++++++--- .../components/ui/chart/chart-shell.svelte | 20 ++ .../shared/components/ui/chart/index.ts | 3 +- .../components/ui/sheet/sheet-content.svelte | 4 +- .../ClientApp/src/lib/generated/api.ts | 17 +- .../ClientApp/src/lib/generated/schemas.ts | 11 +- .../(app)/(components)/layouts/navbar.svelte | 2 +- .../(components)/navigation-command.svelte | 210 +++++++++++++++++- .../ClientApp/src/routes/(app)/+page.svelte | 41 +++- .../src/routes/(app)/issues/+page.svelte | 41 +++- .../Models/SavedView/NewSavedView.cs | 4 + .../Models/SavedView/UpdateSavedView.cs | 2 + .../Models/SavedView/ViewSavedView.cs | 2 + .../Controllers/Data/openapi.json | 60 ++++- 22 files changed, 754 insertions(+), 71 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stats-dashboard.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-shell.svelte diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 9ac86d7c6f..84cc7096cd 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 7fed760c38..971710fd14 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 bb4d7fa3fe..e11ff5d9b6 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 49aff3c6e9..bf9a7baff7 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 0000000000..206a16712f --- /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 f3c25de448..a4e8fb60c7 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[]; + showChart?: boolean; + showStats?: boolean; + setShowChart?: (show: boolean) => void; + setShowStats?: (show: boolean) => void; sort?: string; table: Table; time?: string; @@ -67,6 +71,10 @@ onLoadView, onResetToSaved, savedViews, + showChart = true, + showStats = true, + setShowChart, + setShowStats, 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 943d14b2e3..4495eab27d 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 @@ -21,10 +21,14 @@ export interface UseSavedViewsOptions { filterCacheKey: (filter: null | string) => string; getColumnOrder?: () => ColumnOrderState; getColumnVisibility?: () => ColumnVisibilityState; + getShowChart?: () => boolean; + getShowStats?: () => boolean; getFilterDefinitions?: () => string; 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 8405d84b4f..9b3601a8bd 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 67bc326097..ea4164591f 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 0000000000..9b78134528 --- /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 f22375e09f..58bd3b1146 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 703dd4e3b9..9a479f9c25 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?: null | unknown[]; + 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 8667139ebb..bc0e1e7336 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") @@ -423,7 +424,9 @@ export const UpdateSavedViewSchema = object({ .nullable() .optional(), columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), + column_order: array(unknown()).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 c35d0c38df..2774abbe5b 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 633f77c66e..6979800f9c 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 26769721fd..f869cfe778 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 160e52ba4a..e9659fb3e3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -12,6 +12,7 @@ import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; + import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; import { applyTimeFilter, buildFilterCacheKey, @@ -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 0010c0c811..f8e324768d 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 e39852df90..d094e85954 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 7022e650bc..326ff94c88 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/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index db7dddf495..3bba075604 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "http://localhost/" + "url": "https://localhost:7111/" } ], "paths": { @@ -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, @@ -8587,6 +8599,18 @@ "null", "array" ] + }, + "show_stats": { + "type": [ + "null", + "boolean" + ] + }, + "show_chart": { + "type": [ + "null", + "boolean" + ] } }, "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." @@ -9261,6 +9285,18 @@ "type": "string" } }, + "show_stats": { + "type": [ + "null", + "boolean" + ] + }, + "show_chart": { + "type": [ + "null", + "boolean" + ] + }, "name": { "type": "string" }, From 4f1873b969c9738bcac9a9611ef70ce5fc3d35b9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 21 May 2026 00:48:53 -0500 Subject: [PATCH 2/3] Fix PR build failures --- .../saved-views/components/saved-view-picker.svelte | 8 ++++---- .../lib/features/saved-views/use-saved-views.svelte.ts | 2 +- .../ClientApp/src/routes/(app)/issues/+page.svelte | 2 +- tests/Exceptionless.Tests/Controllers/Data/openapi.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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 a4e8fb60c7..9bf8cd3b7d 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,10 +51,10 @@ onLoadView: (id: string) => void; onResetToSaved: () => void; savedViews: SavedView[]; - showChart?: boolean; - showStats?: boolean; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; + showChart?: boolean; + showStats?: boolean; sort?: string; table: Table; time?: string; @@ -71,10 +71,10 @@ onLoadView, onResetToSaved, savedViews, - showChart = true, - showStats = true, setShowChart, setShowStats, + showChart = true, + showStats = true, sort, table, time, 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 4495eab27d..367603fd5a 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 @@ -21,9 +21,9 @@ export interface UseSavedViewsOptions { filterCacheKey: (filter: null | string) => string; getColumnOrder?: () => ColumnOrderState; getColumnVisibility?: () => ColumnVisibilityState; + getFilterDefinitions?: () => string; getShowChart?: () => boolean; getShowStats?: () => boolean; - getFilterDefinitions?: () => string; queryParams: SavedViewQueryParams; setColumnOrder?: (order: ColumnOrderState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; 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 e9659fb3e3..d1c17b8771 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -11,8 +11,8 @@ 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 { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; + import { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; import { applyTimeFilter, buildFilterCacheKey, diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 3bba075604..17106cf3d5 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "https://localhost:7111/" + "url": "http://localhost/" } ], "paths": { From e5a8629d3cfd00b3d0a414dd995e48a8634469a0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 21 May 2026 00:55:01 -0500 Subject: [PATCH 3/3] Fix saved view patch schema metadata --- .../ClientApp/src/lib/generated/api.ts | 2 +- .../ClientApp/src/lib/generated/schemas.ts | 2 +- .../Utility/OpenApi/DeltaSchemaTransformer.cs | 41 ++++++++++++++++++- .../Controllers/Data/openapi.json | 6 ++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 1771e30dbd..9199c50736 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -377,7 +377,7 @@ export interface UpdateSavedView { sort?: null | string; filter_definitions?: null | string; columns?: null | Record; - column_order?: null | unknown[]; + column_order?: string[] | null; show_stats?: null | boolean; show_chart?: null | boolean; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index bc0e1e7336..b4f664af0d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -424,7 +424,7 @@ export const UpdateSavedViewSchema = object({ .nullable() .optional(), columns: record(string(), boolean()).nullable().optional(), - column_order: array(unknown()).nullable().optional(), + column_order: array(string()).nullable().optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), }); diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index dc2c714b4a..34f000b90a 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 17106cf3d5..ace5b2d4cb 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -8595,10 +8595,14 @@ } }, "column_order": { + "maxItems": 50, "type": [ "null", "array" - ] + ], + "items": { + "type": "string" + } }, "show_stats": { "type": [