From f98e6b9224285b364639239af04877ffdbfbdaa4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 23 Jul 2026 23:23:50 -0500 Subject: [PATCH 01/12] Improve event column picker options --- .../Models/EventSummaryModel.cs | 3 ++ .../Api/Handlers/EventHandler.cs | 5 ++ .../events/components/summary/index.ts | 3 ++ .../table/event-tags-summary-cell.svelte | 26 ++++++++++ .../event-tags-summary-cell.svelte.test.ts | 22 +++++++++ .../components/table/options.svelte.test.ts | 17 +++++++ .../events/components/table/options.svelte.ts | 28 +++++++++++ .../column-management-dialog.svelte | 47 ++++++++----------- .../Api/Endpoints/EventEndpointTests.cs | 24 ++++++++++ 9 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts diff --git a/src/Exceptionless.Core/Models/EventSummaryModel.cs b/src/Exceptionless.Core/Models/EventSummaryModel.cs index b38dc61ed6..6bfdbd4782 100644 --- a/src/Exceptionless.Core/Models/EventSummaryModel.cs +++ b/src/Exceptionless.Core/Models/EventSummaryModel.cs @@ -3,6 +3,9 @@ public record EventSummaryModel : SummaryData { public DateTimeOffset Date { get; set; } + public string ProjectId { get; set; } = null!; + public string? ProjectName { get; set; } + public IReadOnlyCollection Tags { get; set; } = []; public string? Type { get; set; } public string? Version { get; set; } } diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index 1d5cdd7105..1526ea16f5 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -764,6 +764,8 @@ private async Task>> GetInternalAsync(AppFilter sf, T { case "summary": events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after, includeTotal, httpContext.Request); + var projects = await projectRepository.GetByIdsAsync(events.Documents.Select(e => e.ProjectId).Distinct().ToArray(), o => o.Cache()); + var projectNames = projects.ToDictionary(p => p.Id, p => p.Name); var summaries = events.Documents.Select(e => { var summaryData = formattingPluginManager.GetEventSummaryData(e); @@ -772,6 +774,9 @@ private async Task>> GetInternalAsync(AppFilter sf, T Id = summaryData.Id, TemplateKey = summaryData.TemplateKey, Date = e.Date, + ProjectId = e.ProjectId, + ProjectName = projectNames.GetValueOrDefault(e.ProjectId), + Tags = e.Tags?.OfType().Order(StringComparer.OrdinalIgnoreCase).ToArray() ?? [], Type = e.Type, Version = e.GetVersion(), Data = summaryData.Data diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts index ae25ea353f..f14a064908 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts @@ -61,6 +61,9 @@ export interface EventSummaryData { export interface EventSummaryModel extends SummaryModel { /** @format date-time */ date: string; + project_id: string; + project_name?: string; + tags: string[]; type?: string; version?: string; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte new file mode 100644 index 0000000000..f29b70d5d8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte @@ -0,0 +1,26 @@ + + +{#if visibleTags.length > 0} +
+ {#each visibleTags as tag (tag)} + {tag} + {/each} + {#if hiddenTagCount > 0} + +{hiddenTagCount} + {/if} +
+{:else} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts new file mode 100644 index 0000000000..98f3f5565a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte.test.ts @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import EventTagsSummaryCell from './event-tags-summary-cell.svelte'; + +describe('EventTagsSummaryCell', () => { + it('shows two tags and summarizes the remaining tags', () => { + render(EventTagsSummaryCell, { tags: ['api', 'production', 'critical', 'customer'] }); + + expect(screen.getByText('api')).toBeTruthy(); + expect(screen.getByText('production')).toBeTruthy(); + expect(screen.getByText('+2')).toBeTruthy(); + expect(screen.queryByText('critical')).toBeNull(); + expect(screen.getByLabelText('Tags: api, production, critical, customer').getAttribute('title')).toBe('api, production, critical, customer'); + }); + + it('shows an empty value when there are no tags', () => { + render(EventTagsSummaryCell, { tags: [] }); + + expect(screen.getByText('—')).toBeTruthy(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts new file mode 100644 index 0000000000..1eaf16c01d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import type { EventSummaryModel, SummaryTemplateKeys } from '../summary'; + +import { defaultEventColumnVisibility, getColumns } from './options.svelte'; + +describe('event table columns', () => { + it('offers project and tags as hidden optional columns', () => { + const columns = getColumns>(); + const columnIds = columns.map((column) => column.id); + + expect(columnIds).toContain('project'); + expect(columnIds).toContain('tags'); + expect(defaultEventColumnVisibility.project).toBe(false); + expect(defaultEventColumnVisibility.tags).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index a1d39b5f98..cb400dae10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -11,6 +11,7 @@ import type { EventSummaryModel, StackSummaryModel, SummaryModel, SummaryTemplat import LogLevel from '../log-level.svelte'; import Summary from '../summary/summary.svelte'; +import EventTagsSummaryCell from './event-tags-summary-cell.svelte'; import EventsUserIdentitySummaryCell from './events-user-identity-summary-cell.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackUsersSummaryCell from './stack-users-summary-cell.svelte'; @@ -20,7 +21,9 @@ export const defaultEventColumnVisibility: ColumnVisibilityState = { level: false, message: false, name: false, + project: false, source: false, + tags: false, type: false, version: false }; @@ -84,6 +87,26 @@ export function getColumns getProject(row), + cell: (prop) => formatTextColumn(prop.getValue()), + enableSorting: false, + header: 'Project', + id: 'project', + meta: { + class: 'w-40 min-w-40 max-w-40' + } + }, + { + accessorKey: nameof>('tags'), + cell: (prop) => renderComponent(EventTagsSummaryCell, { tags: prop.getValue() }), + enableSorting: false, + header: 'Tags', + id: 'tags', + meta: { + class: 'w-52 min-w-52 max-w-52' + } + }, { accessorFn: (row) => getSummaryDataValue(row, 'Message'), cell: (prop) => formatTextColumn(prop.getValue()), @@ -212,6 +235,11 @@ function formatTextColumn(value: unknown): string { return typeof value === 'string' && value.length > 0 ? value : '—'; } +function getProject>(summary: TSummaryModel): string | undefined { + const eventSummary = summary as Partial, 'project_id' | 'project_name'>> & TSummaryModel; + return eventSummary.project_name ?? eventSummary.project_id; +} + function getSource>(summary: TSummaryModel): string | undefined { return getSummaryDataValue(summary, 'SourceShortName') ?? getSummaryDataValue(summary, 'Source'); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte index 01fd0d843c..758cd0e3ac 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte @@ -9,7 +9,6 @@ import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; - import { Checkbox } from '$comp/ui/checkbox'; import * as Dialog from '$comp/ui/dialog'; import * as InputGroup from '$comp/ui/input-group'; import { Separator } from '$comp/ui/separator'; @@ -171,15 +170,15 @@

No columns match your search

{:else} {#each filteredAvailableColumns as column (column.id)} -
- addColumn(column)} /> - {getColumnLabel(column)} -
{/each} @@ -217,7 +216,7 @@ {#each visibleColumns as column, index (column.id)}
- removeColumn(column)} - /> - {getColumnLabel(column)} + aria-label={`Remove ${getColumnLabel(column)} column`} + > + {getColumnLabel(column)} + {#if canRemoveColumn(column)} +
diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 00be49ac24..777b2c7425 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1202,6 +1202,30 @@ public async Task GetEvents_SummaryMode_IncludesVersion() Assert.Equal("3.2.1-beta1", summary.Version); } + [Fact] + public async Task GetEvents_SummaryMode_IncludesProjectAndTags() + { + // Arrange + var (_, events) = await CreateDataAsync(d => d.Event().TestProject().Tag("production", "critical")); + var persistentEvent = Assert.Single(events); + var project = await _projectRepository.GetByIdAsync(persistentEvent.ProjectId); + + // Act + var results = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("filter", $"id:{persistentEvent.Id}") + .QueryString("mode", "summary") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(results); + var summary = Assert.Single(results); + Assert.Equal(persistentEvent.ProjectId, summary.ProjectId); + Assert.Equal(project?.Name, summary.ProjectName); + Assert.Equal(["critical", "production"], summary.Tags); + } + [Fact] public async Task GetEvents_StackFrequentMode_DeserializesStackSummaryModelWithRequiredFields() { From 1a57a8fd4cc9e2095ab20bce138fa92f882515b8 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 10:59:47 -0500 Subject: [PATCH 02/12] Add project column to stacks --- .../Models/StackSummaryModel.cs | 2 ++ .../Api/Handlers/EventHandler.cs | 4 ++++ .../Api/Handlers/StackHandler.cs | 4 ++++ .../events/components/summary/index.ts | 2 ++ .../components/table/options.svelte.test.ts | 12 ++++++++-- .../events/components/table/options.svelte.ts | 24 +++++++++++-------- .../src/routes/(app)/stack/+page.svelte | 3 ++- .../Api/Endpoints/EventEndpointTests.cs | 2 ++ 8 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/Exceptionless.Core/Models/StackSummaryModel.cs b/src/Exceptionless.Core/Models/StackSummaryModel.cs index 197065fc71..9101f059dc 100644 --- a/src/Exceptionless.Core/Models/StackSummaryModel.cs +++ b/src/Exceptionless.Core/Models/StackSummaryModel.cs @@ -5,6 +5,8 @@ namespace Exceptionless.Core.Models; [DebuggerDisplay("Id: {Id}, Status: {Status}, Title: {Title}, First: {FirstOccurrence}, Last: {LastOccurrence}")] public record StackSummaryModel : SummaryData { + public string ProjectId { get; init; } = null!; + public string? ProjectName { get; init; } public required string Title { get; init; } public StackStatus Status { get; init; } public DateTime FirstOccurrence { get; init; } diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index 1526ea16f5..30ea271cae 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -922,6 +922,8 @@ private async Task> GetStackSummariesAsync(List(0); + var projects = await projectRepository.GetByIdsAsync(stacks.Select(s => s.ProjectId).Distinct().ToArray(), o => o.Cache()); + var projectNames = projects.ToDictionary(p => p.Id, p => p.Name); var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => { @@ -931,6 +933,8 @@ private async Task> GetStackSummariesAsync(List("min_date")?.Value ?? stack.FirstOccurrence, diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index b61206d1d1..067f0d69e8 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -464,6 +464,8 @@ private async Task> GetStackSummariesAsync(IColle if (stacks.Count == 0) return new List(0); + var projects = await projectRepository.GetByIdsAsync(stacks.Select(s => s.ProjectId).Distinct().ToArray(), o => o.Cache()); + var projectNames = projects.ToDictionary(p => p.Id, p => p.Name); var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => { @@ -473,6 +475,8 @@ private async Task> GetStackSummariesAsync(IColle Id = data.Id, TemplateKey = data.TemplateKey, Data = data.Data, + ProjectId = stack.ProjectId, + ProjectName = projectNames.GetValueOrDefault(stack.ProjectId), Title = stack.Title, Status = stack.Status, FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts index f14a064908..2db2c41a81 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts @@ -98,6 +98,8 @@ export interface StackSummaryModel extends Summar first_occurrence: string; /** @format date-time */ last_occurrence: string; + project_id: string; + project_name?: string; status: StackStatus; title: string; /** @format int64 */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 1eaf16c01d..9fe4cc080b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import type { EventSummaryModel, SummaryTemplateKeys } from '../summary'; +import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from '../summary'; -import { defaultEventColumnVisibility, getColumns } from './options.svelte'; +import { defaultEventColumnVisibility, defaultStackColumnVisibility, getColumns } from './options.svelte'; describe('event table columns', () => { it('offers project and tags as hidden optional columns', () => { @@ -14,4 +14,12 @@ describe('event table columns', () => { expect(defaultEventColumnVisibility.project).toBe(false); expect(defaultEventColumnVisibility.tags).toBe(false); }); + + it('offers project as a hidden optional stack column', () => { + const columns = getColumns>('stack_frequent'); + const columnIds = columns.map((column) => column.id); + + expect(columnIds).toContain('project'); + expect(defaultStackColumnVisibility.project).toBe(false); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index cb400dae10..3727340705 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -28,6 +28,10 @@ export const defaultEventColumnVisibility: ColumnVisibilityState = { version: false }; +export const defaultStackColumnVisibility: ColumnVisibilityState = { + project: false +}; + export function getColumns>( mode: GetEventsMode = 'summary', options?: { showType?: boolean } @@ -64,6 +68,16 @@ export function getColumns getProject(row), + cell: (prop) => formatTextColumn(prop.getValue()), + enableSorting: false, + header: 'Project', + id: 'project', + meta: { + class: 'w-40 min-w-40 max-w-40' + } } ]; @@ -87,16 +101,6 @@ export function getColumns getProject(row), - cell: (prop) => formatTextColumn(prop.getValue()), - enableSorting: false, - header: 'Project', - id: 'project', - meta: { - class: 'w-40 min-w-40 max-w-40' - } - }, { accessorKey: nameof>('tags'), cell: (prop) => renderComponent(EventTagsSummaryCell, { tags: prop.getValue() }), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index cbb73914c0..1544e1e8af 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -38,7 +38,7 @@ } from '$features/events/components/filters/helpers.svelte'; import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; - import { getColumns } from '$features/events/components/table/options.svelte'; + import { defaultStackColumnVisibility, getColumns } from '$features/events/components/table/options.svelte'; import { organization } from '$features/organizations/context.svelte'; import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; @@ -632,6 +632,7 @@ showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); }, + defaultColumnVisibility: defaultStackColumnVisibility, paginationStrategy: 'offset', get queryData() { return clientResponse?.data ?? []; diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 777b2c7425..c8d9777326 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1246,6 +1246,8 @@ public async Task GetEvents_StackFrequentMode_DeserializesStackSummaryModelWithR Assert.NotEmpty(results); Assert.All(results, summary => { + Assert.False(String.IsNullOrWhiteSpace(summary.ProjectId)); + Assert.False(String.IsNullOrWhiteSpace(summary.ProjectName)); Assert.False(String.IsNullOrWhiteSpace(summary.Title)); Assert.NotEqual(default, summary.FirstOccurrence); Assert.NotEqual(default, summary.LastOccurrence); From 624f0f6b9924884f4fd85b58b96ffcb2495fdf27 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 11:11:26 -0500 Subject: [PATCH 03/12] Unify clickable tag styling --- .../table/event-tags-summary-cell.svelte | 22 +-- .../events/components/table/options.svelte.ts | 4 +- .../events/components/views/overview.svelte | 9 +- .../shared/components/tag-list.svelte | 127 ++++++++++++++++++ .../shared/components/tag-list.svelte.test.ts | 36 +++++ .../stacks/components/stack-card.svelte | 17 +-- .../components/table/stack-tags-cell.svelte | 96 +------------ .../src/routes/(app)/event/+page.svelte | 1 + .../src/routes/(app)/stream/+page.svelte | 3 +- 9 files changed, 180 insertions(+), 135 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte index f29b70d5d8..81711f8886 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte @@ -1,26 +1,12 @@ -{#if visibleTags.length > 0} -
- {#each visibleTags as tag (tag)} - {tag} - {/each} - {#if hiddenTagCount > 0} - +{hiddenTagCount} - {/if} -
-{:else} - -{/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 3727340705..f6cb8b16d7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -34,7 +34,7 @@ export const defaultStackColumnVisibility: ColumnVisibilityState = { export function getColumns>( mode: GetEventsMode = 'summary', - options?: { showType?: boolean } + options?: { onTagClick?: (tag: string) => Promise | void; showType?: boolean } ): ColumnDef[] { const showType = options?.showType ?? true; const columns: ColumnDef[] = [ @@ -103,7 +103,7 @@ export function getColumns>('tags'), - cell: (prop) => renderComponent(EventTagsSummaryCell, { tags: prop.getValue() }), + cell: (prop) => renderComponent(EventTagsSummaryCell, { onTagClick: options?.onTagClick, tags: prop.getValue() }), enableSorting: false, header: 'Tags', id: 'tags', diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index a05ca3ee78..17ca4ddd60 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -16,8 +16,8 @@ getStackTrace, hasErrorOrSimpleError } from '$features/events/persistent-event'; + import TagList from '$features/shared/components/tag-list.svelte'; import ExternalLink from '@lucide/svelte/icons/external-link'; - import Filter from '@lucide/svelte/icons/filter'; import Email from '@lucide/svelte/icons/mail'; import type { PersistentEvent } from '../../models/index'; @@ -163,12 +163,7 @@ Tags - {#each event.tags as tag (tag)} - - - {tag} - - {/each} + filterChanged(new EventsFacetedFilter.TagFilter([tag]))} tags={event.tags} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte new file mode 100644 index 0000000000..4d75d14a35 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -0,0 +1,127 @@ + + +{#snippet tagBadge(tag: string)} + {tag} +{/snippet} + +{#snippet tag(tag: string)} + {#if onTagClick} + + + {#snippet child({ props })} + + {/snippet} + + + Click to filter. {copyTagShortcut} click to copy. + + + {:else} + {@render tagBadge(tag)} + {/if} +{/snippet} + + + {#if visibleTags.length > 0} +
+ {#each visibleTags as value (value)} + {@render tag(value)} + {/each} + {#if hiddenTags.length > 0} + + + {#snippet child({ props })} + +{hiddenTags.length} + {/snippet} + + +
+ {#each hiddenTags as value (value)} + {@render tag(value)} + {/each} +
+ {#if onTagClick} + Click to filter. {copyTagShortcut} click to copy. + {/if} +
+
+ {/if} +
+ {:else} + + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts new file mode 100644 index 0000000000..bc16e5ef42 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import TagList from './tag-list.svelte'; + +describe('TagList', () => { + it('renders colored tag badges without filter icons and summarizes overflow', () => { + const { container } = render(TagList, { + maxVisible: 2, + tags: ['api', 'production', 'critical', 'customer'] + }); + + expect(screen.getByText('api')).toBeTruthy(); + expect(screen.getByText('production')).toBeTruthy(); + expect(screen.getByText('+2')).toBeTruthy(); + expect(screen.queryByText('critical')).toBeNull(); + expect(screen.getByLabelText('Tags: api, production, critical, customer').getAttribute('title')).toBe('api, production, critical, customer'); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('filters when a tag is clicked', async () => { + const onTagClick = vi.fn(); + render(TagList, { onTagClick, tags: ['api'] }); + + await fireEvent.click(screen.getByRole('button', { name: 'api' })); + + expect(onTagClick).toHaveBeenCalledOnce(); + expect(onTagClick).toHaveBeenCalledWith('api'); + }); + + it('shows an empty value when there are no tags', () => { + render(TagList, { tags: [] }); + + expect(screen.getByText('—')).toBeTruthy(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index 74aba3386e..1922cf58ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -19,6 +19,7 @@ import { StringFilter } from '$features/events/components/filters'; import { getProjectQuery } from '$features/projects/api.svelte'; import * as agg from '$features/shared/api/aggregations'; + import TagList from '$features/shared/components/tag-list.svelte'; import { fillDateSeries } from '$features/shared/utils/charts'; import { getStackQuery } from '$features/stacks/api.svelte'; import { DEFAULT_OFFSET } from '$shared/api/api.svelte'; @@ -305,21 +306,9 @@ {#if stack.tags && stack.tags.length > 0} Tags - - - + - {stack.tags[0]} - {#each stack.tags.slice(1) as tag (tag)} - - - {tag} - - {/each} + filterChanged(new EventsFacetedFilter.TagFilter([tag]))} tags={stack.tags} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte index 365a89015e..5e8fa08bc6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -1,102 +1,12 @@ -{#snippet tagButton(tag: string)} - - - {#snippet child({ props })} - - {/snippet} - - - Click to filter. {copyTagShortcut} click to copy. - - -{/snippet} - -{#if tags && tags.length > 0} -
- {#each tags.slice(0, 3) as tag (tag)} - {@render tagButton(tag)} - {/each} - {#if tags.length > 3} - - - {#snippet child({ props })} - +{tags.length - 3} - {/snippet} - - -
- {#each tags.slice(3) as tag (tag)} - - {/each} -
- Click to filter. {copyTagShortcut} click to copy. -
-
- {/if} -
-{/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 768c98165e..cce4bb52f5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -661,6 +661,7 @@ columnPersistenceKey: 'events-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); }, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 24fc78759c..c858d545c2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -12,7 +12,7 @@ import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; - import { ProjectFilter, StatusFilter } from '$features/events/components/filters'; + import { ProjectFilter, StatusFilter, TagFilter } from '$features/events/components/filters'; import { buildFilterCacheKey, filterChanged, @@ -178,6 +178,7 @@ columnPersistenceKey: 'stream-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }) .filter((c) => c.id !== 'select') From 7b2ecad992a69bf4a3da19e1708973b9a21b3ff6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 11:37:00 -0500 Subject: [PATCH 04/12] Allow new columns in saved views --- .../Models/SavedView/NewSavedView.cs | 6 ++--- .../Api/Endpoints/SavedViewEndpointTests.cs | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index a0775293c1..e935d1c772 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -15,9 +15,9 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject public static readonly IReadOnlyDictionary> ValidColumnIds = new Dictionary> { - ["events"] = new HashSet { "summary", "user", "date", "message", "type", "version", "exception_type", "source", "name", "level" }, - ["stacks"] = new HashSet { "summary", "status", "users", "events", "first", "last" }, - ["stream"] = new HashSet { "summary", "user", "date", "message", "type", "version", "exception_type", "source", "name", "level" } + ["events"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" }, + ["stacks"] = new HashSet { "summary", "project", "status", "users", "events", "first", "last" }, + ["stream"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" } }; /// Union of all valid column IDs across all views. diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 7b937a084d..1cdb8759f3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -2125,6 +2125,31 @@ public Task PostAsync_ValidColumnKeys_Succeeds() ); } + [Theory] + [InlineData("events", "project")] + [InlineData("events", "tags")] + [InlineData("stacks", "project")] + [InlineData("stream", "project")] + [InlineData("stream", "tags")] + public Task PostAsync_ProjectAndTagColumnsForSupportedViews_Succeeds(string viewType, string column) + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = $"Valid {viewType} {column} Column", + ViewType = viewType, + Columns = new Dictionary { [column] = false }, + ColumnOrder = [column] + }) + .StatusCodeShouldBeCreated() + ); + } + [Theory] [InlineData("events")] [InlineData("stream")] From e030f93475d5e68aa4ae080e97a51bd853dc7edf Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 12:19:36 -0500 Subject: [PATCH 05/12] Use neutral tag styling --- .../shared/components/tag-list.svelte | 36 +++++-------------- .../shared/components/tag-list.svelte.test.ts | 8 ++++- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte index 4d75d14a35..872a89fbd4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -21,31 +21,6 @@ const hiddenTags = $derived(tags?.slice(maxVisible) ?? []); const tagList = $derived(tags?.join(', ') ?? ''); - const tagColorClasses = [ - 'border-red-300 bg-red-100 text-red-900 dark:border-red-700 dark:bg-red-950/40 dark:text-red-200', - 'border-orange-300 bg-orange-100 text-orange-900 dark:border-orange-700 dark:bg-orange-950/40 dark:text-orange-200', - 'border-amber-300 bg-amber-100 text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200', - 'border-emerald-300 bg-emerald-100 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200', - 'border-sky-300 bg-sky-100 text-sky-900 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200', - 'border-indigo-300 bg-indigo-100 text-indigo-900 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200', - 'border-violet-300 bg-violet-100 text-violet-900 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200', - 'border-fuchsia-300 bg-fuchsia-100 text-fuchsia-900 dark:border-fuchsia-700 dark:bg-fuchsia-950/40 dark:text-fuchsia-200' - ]; - - function hashTag(tag: string): number { - let hash = 0; - for (let index = 0; index < tag.length; index++) { - hash = (hash << 5) - hash + tag.charCodeAt(index); - hash |= 0; - } - - return Math.abs(hash); - } - - function getTagColorClass(tag: string): string { - return tagColorClasses[hashTag(tag) % tagColorClasses.length]!; - } - async function handleTagClick(event: MouseEvent, tag: string): Promise { event.preventDefault(); event.stopPropagation(); @@ -66,7 +41,12 @@ {#snippet tagBadge(tag: string)} - {tag} + + {tag} + {/snippet} {#snippet tag(tag: string)} @@ -105,7 +85,9 @@ {#snippet child({ props })} - +{hiddenTags.length} + + +{hiddenTags.length} + {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts index bc16e5ef42..79f5047ef3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import TagList from './tag-list.svelte'; describe('TagList', () => { - it('renders colored tag badges without filter icons and summarizes overflow', () => { + it('renders neutral tag badges without filter icons and summarizes overflow', () => { const { container } = render(TagList, { maxVisible: 2, tags: ['api', 'production', 'critical', 'customer'] @@ -16,6 +16,12 @@ describe('TagList', () => { expect(screen.queryByText('critical')).toBeNull(); expect(screen.getByLabelText('Tags: api, production, critical, customer').getAttribute('title')).toBe('api, production, critical, customer'); expect(container.querySelector('svg')).toBeNull(); + + for (const badge of container.querySelectorAll('[data-slot="badge"]')) { + expect(badge.classList).toContain('border-border'); + expect(badge.classList).toContain('bg-muted'); + expect(badge.classList).toContain('text-muted-foreground'); + } }); it('filters when a tag is clicked', async () => { From 46c32f93a6877c69419c632ca193f9d79b8569de Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 12:44:41 -0500 Subject: [PATCH 06/12] Refine tag badge outlines --- .../src/lib/features/shared/components/tag-list.svelte | 8 ++++++-- .../features/shared/components/tag-list.svelte.test.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte index 872a89fbd4..222c2659b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -43,7 +43,7 @@ {#snippet tagBadge(tag: string)} {tag} @@ -85,7 +85,11 @@ {#snippet child({ props })} - + +{hiddenTags.length} {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts index 79f5047ef3..5899d8d21b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -19,8 +19,10 @@ describe('TagList', () => { for (const badge of container.querySelectorAll('[data-slot="badge"]')) { expect(badge.classList).toContain('border-border'); + expect(badge.classList).toContain('dark:border-muted-foreground/50'); expect(badge.classList).toContain('bg-muted'); expect(badge.classList).toContain('text-muted-foreground'); + expect(badge.classList).toContain('rounded-md'); } }); From 86b902fcac529877a7e9334a982c92bf84f1832e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 13:14:45 -0500 Subject: [PATCH 07/12] Add tags column to stacks --- src/Exceptionless.Core/Models/StackSummaryModel.cs | 1 + src/Exceptionless.Web/Api/Handlers/EventHandler.cs | 1 + src/Exceptionless.Web/Api/Handlers/StackHandler.cs | 1 + .../lib/features/events/components/summary/index.ts | 1 + .../events/components/table/options.svelte.test.ts | 4 +++- .../events/components/table/options.svelte.ts | 13 ++++++++++++- .../ClientApp/src/routes/(app)/stack/+page.svelte | 1 + .../Models/SavedView/NewSavedView.cs | 2 +- .../Api/Endpoints/EventEndpointTests.cs | 1 + .../Api/Endpoints/SavedViewEndpointTests.cs | 1 + tests/http/events.http | 4 ++++ 11 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Models/StackSummaryModel.cs b/src/Exceptionless.Core/Models/StackSummaryModel.cs index 9101f059dc..6cc4d0a295 100644 --- a/src/Exceptionless.Core/Models/StackSummaryModel.cs +++ b/src/Exceptionless.Core/Models/StackSummaryModel.cs @@ -7,6 +7,7 @@ public record StackSummaryModel : SummaryData { public string ProjectId { get; init; } = null!; public string? ProjectName { get; init; } + public IReadOnlyCollection Tags { get; init; } = []; public required string Title { get; init; } public StackStatus Status { get; init; } public DateTime FirstOccurrence { get; init; } diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index 30ea271cae..6238b95681 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -935,6 +935,7 @@ private async Task> GetStackSummariesAsync(List().Order(StringComparer.OrdinalIgnoreCase).ToArray() ?? [], Title = stack.Title, Status = stack.Status, FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index 067f0d69e8..eea6114c39 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -477,6 +477,7 @@ private async Task> GetStackSummariesAsync(IColle Data = data.Data, ProjectId = stack.ProjectId, ProjectName = projectNames.GetValueOrDefault(stack.ProjectId), + Tags = stack.Tags?.OfType().Order(StringComparer.OrdinalIgnoreCase).ToArray() ?? [], Title = stack.Title, Status = stack.Status, FirstOccurrence = term.Aggregations.Min("min_date")?.Value ?? stack.FirstOccurrence, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts index 2db2c41a81..41b9b04916 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts @@ -101,6 +101,7 @@ export interface StackSummaryModel extends Summar project_id: string; project_name?: string; status: StackStatus; + tags: string[]; title: string; /** @format int64 */ total: number; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 9fe4cc080b..3cd41c3162 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -15,11 +15,13 @@ describe('event table columns', () => { expect(defaultEventColumnVisibility.tags).toBe(false); }); - it('offers project as a hidden optional stack column', () => { + it('offers project and tags as hidden optional stack columns', () => { const columns = getColumns>('stack_frequent'); const columnIds = columns.map((column) => column.id); expect(columnIds).toContain('project'); + expect(columnIds).toContain('tags'); expect(defaultStackColumnVisibility.project).toBe(false); + expect(defaultStackColumnVisibility.tags).toBe(false); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index f6cb8b16d7..8e6803f273 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -29,7 +29,8 @@ export const defaultEventColumnVisibility: ColumnVisibilityState = { }; export const defaultStackColumnVisibility: ColumnVisibilityState = { - project: false + project: false, + tags: false }; export function getColumns>( @@ -180,6 +181,16 @@ export function getColumns>('tags'), + cell: (prop) => renderComponent(EventTagsSummaryCell, { onTagClick: options?.onTagClick, tags: prop.getValue() }), + enableSorting: false, + header: 'Tags', + id: 'tags', + meta: { + class: 'w-52 min-w-52 max-w-52' + } + }, { accessorKey: nameof>('status'), cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 1544e1e8af..9389da988a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -629,6 +629,7 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); }, diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index e935d1c772..d08f29873b 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -16,7 +16,7 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject new Dictionary> { ["events"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" }, - ["stacks"] = new HashSet { "summary", "project", "status", "users", "events", "first", "last" }, + ["stacks"] = new HashSet { "summary", "project", "tags", "status", "users", "events", "first", "last" }, ["stream"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" } }; diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index c8d9777326..f46babddc4 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1244,6 +1244,7 @@ public async Task GetEvents_StackFrequentMode_DeserializesStackSummaryModelWithR // Assert Assert.NotNull(results); Assert.NotEmpty(results); + Assert.Contains(results, summary => summary.Tags.Contains("test")); Assert.All(results, summary => { Assert.False(String.IsNullOrWhiteSpace(summary.ProjectId)); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 1cdb8759f3..03cf028cc3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -2129,6 +2129,7 @@ public Task PostAsync_ValidColumnKeys_Succeeds() [InlineData("events", "project")] [InlineData("events", "tags")] [InlineData("stacks", "project")] + [InlineData("stacks", "tags")] [InlineData("stream", "project")] [InlineData("stream", "tags")] public Task PostAsync_ProjectAndTagColumnsForSupportedViews_Succeeds(string viewType, string column) diff --git a/tests/http/events.http b/tests/http/events.http index 271fdcaaa6..4e8624aa69 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -25,6 +25,10 @@ Content-Type: application/json GET {{apiUrl}}/events?limit=1 Authorization: Bearer {{token}} +### Stack summaries (includes project and tags) +GET {{apiUrl}}/events?mode=stack_frequent&limit=10 +Authorization: Bearer {{token}} + ### @eventId = {{allEvents.response.body.$[0].id}} @stackId = {{allEvents.response.body.$[0].stack_id}} From 70bf08bab9cd4b96367b96a5d9f8bb93a7e551e1 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 13:46:47 -0500 Subject: [PATCH 08/12] Refine log level badge outlines --- .../features/events/components/log-level.svelte | 2 +- .../events/components/log-level.svelte.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts 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 ce83f26d8a..5707f2d793 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 @@ -35,7 +35,7 @@ {#if normalizedLogLevel} - + {normalizedLogLevel} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts new file mode 100644 index 0000000000..0c0f727764 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import LogLevel from './log-level.svelte'; + +describe('LogLevel', () => { + it.each(['debug', 'error', 'info', 'warn'])('renders %s with the shared tag shape and a visible border', (level) => { + render(LogLevel, { level }); + + const badge = screen.getByText(level); + expect(badge.classList).toContain('rounded-md'); + expect(badge.classList).toContain('border'); + expect(badge.classList).toContain('border-current/20'); + expect(badge.classList).toContain('dark:border-current/40'); + }); +}); From ec25027e87259cbc0814ade498ee8f94143d2f7e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 13:57:42 -0500 Subject: [PATCH 09/12] Decode exception additional data --- .../lib/features/events/models/event-data.ts | 4 +- .../features/events/persistent-event.test.ts | 96 +++++++++++++++---- .../lib/features/events/persistent-event.ts | 18 +++- 3 files changed, 99 insertions(+), 19 deletions(-) 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 1da46d594b..fc7df6290f 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 @@ -22,7 +22,7 @@ export interface ErrorInfo extends InnerErrorInfo { } export interface IErrorData extends Record { - '@ext'?: Record; + '@ext'?: Record | string; '@target'?: ITargetErrorData; } @@ -48,7 +48,7 @@ export interface IRequestInfoInfoData extends Record { } export interface ISimpleErrorInfoData extends Record { - '@ext'?: Record; + '@ext'?: Record | string; } export interface ITargetErrorData extends Record { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.test.ts index 6edf175cef..f31c7a3d0b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.test.ts @@ -2,27 +2,91 @@ import type { ViewProject } from '$features/projects/models'; import { describe, expect, it } from 'vitest'; -import type { PersistentEvent } from './models'; +import type { IPersistentEventData, PersistentEvent } from './models'; -import { getExtendedDataItems } from './persistent-event'; +import { getErrorData, getExtendedDataItems } from './persistent-event'; + +function createEvent(data: IPersistentEventData): PersistentEvent { + return { + created_utc: '2026-06-04T00:00:00Z', + data, + date: '2026-06-04T00:00:00Z', + id: '507f1f77bcf86cd799439011', + is_first_occurrence: false, + organization_id: '507f1f77bcf86cd799439012', + project_id: '507f1f77bcf86cd799439013', + stack_id: '507f1f77bcf86cd799439014' + }; +} + +describe('getErrorData', () => { + it('decodes JSON-encoded exception extended data before rendering it', () => { + const event = createEvent({ + '@error': { + data: { + '@ext': '{"ErrorCode":-2147467259,"ObjectName":"System.Net.Sockets.NetworkStream"}' + }, + message: 'The operation failed.', + type: 'System.Runtime.InteropServices.COMException' + } + }); + + expect(getErrorData(event)).toEqual([ + { + data: { + ErrorCode: -2147467259, + ObjectName: 'System.Net.Sockets.NetworkStream' + }, + message: 'The operation failed.', + type: 'System.Runtime.InteropServices.COMException' + } + ]); + }); + + it('preserves structured extended data and regular exception data', () => { + const event = createEvent({ + '@simple_error': { + data: { + '@ext': { + ErrorCode: 42 + }, + source: 'worker' + }, + message: 'The operation failed.', + type: 'TestException' + } + }); + + expect(getErrorData(event)[0]?.data).toEqual({ + ErrorCode: 42, + source: 'worker' + }); + }); + + it('preserves malformed encoded extended data without enumerating its characters', () => { + const event = createEvent({ + '@error': { + data: { + '@ext': '{invalid' + }, + type: 'TestException' + } + }); + + expect(getErrorData(event)[0]?.data).toEqual({ + '@ext': '{invalid' + }); + }); +}); describe('getExtendedDataItems', () => { it('orders promoted extended data by the project promoted tab order', () => { // Arrange - const event = { - created_utc: '2026-06-04T00:00:00Z', - data: { - alpha: 'Alpha', - beta: 'Beta', - gamma: 'Gamma' - }, - date: '2026-06-04T00:00:00Z', - id: '507f1f77bcf86cd799439011', - is_first_occurrence: false, - organization_id: '507f1f77bcf86cd799439012', - project_id: '507f1f77bcf86cd799439013', - stack_id: '507f1f77bcf86cd799439014' - } as PersistentEvent; + const event = createEvent({ + alpha: 'Alpha', + beta: 'Beta', + gamma: 'Gamma' + }); const project = { promoted_tabs: ['gamma', 'alpha'] } as ViewProject; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.ts index f4bb47c205..76beeb2294 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/persistent-event.ts @@ -1,6 +1,7 @@ // TODO: Remove this reference. import type { ViewProject } from '$features/projects/models'; +import { isJSONString, isObject } from '$shared/typing'; import { buildUrl } from '$shared/url'; import type { PersistentEvent } from './models'; @@ -27,7 +28,22 @@ export function getErrorData(event: PersistentEvent): ErrorData[] { return; } - const additionalData = { ...error.data['@ext'] }; + const extendedData = error.data['@ext']; + let additionalData: Record = {}; + + if (isObject(extendedData)) { + additionalData = { ...extendedData }; + } else if (isJSONString(extendedData)) { + const parsedExtendedData: unknown = JSON.parse(extendedData); + if (isObject(parsedExtendedData)) { + additionalData = { ...parsedExtendedData }; + } else { + additionalData['@ext'] = parsedExtendedData; + } + } else if (extendedData !== undefined && extendedData !== null) { + additionalData['@ext'] = extendedData; + } + Object.entries(error.data).forEach(([key, value]) => { if (!key.startsWith('@')) { additionalData[key] = value; From 6b620882c9b88de30064922a344278062cb39a1b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 15:19:19 -0500 Subject: [PATCH 10/12] Refine dark log level badges --- .../events/components/log-level.svelte | 21 +++++++++++++++++-- .../components/log-level.svelte.test.ts | 11 ++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) 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 5707f2d793..8b9714a7e3 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 @@ -23,19 +23,36 @@ return 'yellow'; } - if (level === 'error') { + if (level === 'error' || level === 'fatal') { return 'destructive'; } return 'default'; } + function getDarkThemeClasses(level: LogLevel | null): string { + if (level === 'error' || level === 'fatal') { + return 'dark:border-red-400/30 dark:bg-red-500/10 dark:text-red-300'; + } + + if (level === 'info') { + return 'dark:border-green-400/30 dark:bg-green-500/10 dark:text-green-200'; + } + + if (level === 'warn') { + return 'dark:border-yellow-400/30 dark:bg-yellow-500/10 dark:text-yellow-200'; + } + + return 'dark:border-white/20 dark:bg-white/5 dark:text-zinc-200'; + } + const normalizedLogLevel = $derived(getLogLevel(level)); + const darkThemeClasses = $derived(getDarkThemeClasses(normalizedLogLevel)); const variant = $derived(getLogLevelVariant(normalizedLogLevel)); {#if normalizedLogLevel} - + {normalizedLogLevel} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts index 0c0f727764..d0365cd161 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts @@ -4,13 +4,20 @@ import { describe, expect, it } from 'vitest'; import LogLevel from './log-level.svelte'; describe('LogLevel', () => { - it.each(['debug', 'error', 'info', 'warn'])('renders %s with the shared tag shape and a visible border', (level) => { + it.each([ + ['debug', ['dark:border-white/20', 'dark:bg-white/5', 'dark:text-zinc-200']], + ['error', ['dark:border-red-400/30', 'dark:bg-red-500/10', 'dark:text-red-300']], + ['fatal', ['dark:border-red-400/30', 'dark:bg-red-500/10', 'dark:text-red-300']], + ['info', ['dark:border-green-400/30', 'dark:bg-green-500/10', 'dark:text-green-200']], + ['warn', ['dark:border-yellow-400/30', 'dark:bg-yellow-500/10', 'dark:text-yellow-200']] + ])('renders %s as a compact tag with restrained dark-theme colors', (level, darkThemeClasses) => { render(LogLevel, { level }); const badge = screen.getByText(level); + expect(badge.classList).toContain('w-12'); expect(badge.classList).toContain('rounded-md'); expect(badge.classList).toContain('border'); expect(badge.classList).toContain('border-current/20'); - expect(badge.classList).toContain('dark:border-current/40'); + darkThemeClasses.forEach((className) => expect(badge.classList).toContain(className)); }); }); From f8eec422d0c29084dd4107ce769b7c0494ad8299 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 15:41:53 -0500 Subject: [PATCH 11/12] Blend dark log level badges into page --- .../lib/features/events/components/log-level.svelte | 8 ++++---- .../events/components/log-level.svelte.test.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) 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 8b9714a7e3..3d39161271 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 @@ -32,18 +32,18 @@ function getDarkThemeClasses(level: LogLevel | null): string { if (level === 'error' || level === 'fatal') { - return 'dark:border-red-400/30 dark:bg-red-500/10 dark:text-red-300'; + return 'dark:border-red-400/25 dark:bg-background dark:text-red-300'; } if (level === 'info') { - return 'dark:border-green-400/30 dark:bg-green-500/10 dark:text-green-200'; + return 'dark:border-green-400/25 dark:bg-background dark:text-green-200'; } if (level === 'warn') { - return 'dark:border-yellow-400/30 dark:bg-yellow-500/10 dark:text-yellow-200'; + return 'dark:border-yellow-400/25 dark:bg-background dark:text-yellow-200'; } - return 'dark:border-white/20 dark:bg-white/5 dark:text-zinc-200'; + return 'dark:border-white/20 dark:bg-background dark:text-zinc-300'; } const normalizedLogLevel = $derived(getLogLevel(level)); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts index d0365cd161..bdba02f1cc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts @@ -5,11 +5,11 @@ import LogLevel from './log-level.svelte'; describe('LogLevel', () => { it.each([ - ['debug', ['dark:border-white/20', 'dark:bg-white/5', 'dark:text-zinc-200']], - ['error', ['dark:border-red-400/30', 'dark:bg-red-500/10', 'dark:text-red-300']], - ['fatal', ['dark:border-red-400/30', 'dark:bg-red-500/10', 'dark:text-red-300']], - ['info', ['dark:border-green-400/30', 'dark:bg-green-500/10', 'dark:text-green-200']], - ['warn', ['dark:border-yellow-400/30', 'dark:bg-yellow-500/10', 'dark:text-yellow-200']] + ['debug', ['dark:border-white/20', 'dark:bg-background', 'dark:text-zinc-300']], + ['error', ['dark:border-red-400/25', 'dark:bg-background', 'dark:text-red-300']], + ['fatal', ['dark:border-red-400/25', 'dark:bg-background', 'dark:text-red-300']], + ['info', ['dark:border-green-400/25', 'dark:bg-background', 'dark:text-green-200']], + ['warn', ['dark:border-yellow-400/25', 'dark:bg-background', 'dark:text-yellow-200']] ])('renders %s as a compact tag with restrained dark-theme colors', (level, darkThemeClasses) => { render(LogLevel, { level }); From b6f47d54f2756058ff7aff8bf0a16c780a6c091b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 24 Jul 2026 16:33:15 -0500 Subject: [PATCH 12/12] Refine tag and log badge styling --- .../lib/features/events/components/log-level.svelte | 8 ++++---- .../events/components/log-level.svelte.test.ts | 10 +++++----- .../lib/features/shared/components/tag-list.svelte | 12 ++++++------ 3 files changed, 15 insertions(+), 15 deletions(-) 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 3d39161271..15f6b342a2 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 @@ -32,18 +32,18 @@ function getDarkThemeClasses(level: LogLevel | null): string { if (level === 'error' || level === 'fatal') { - return 'dark:border-red-400/25 dark:bg-background dark:text-red-300'; + return 'dark:border-red-400/25 dark:bg-red-400/15 dark:text-red-300'; } if (level === 'info') { - return 'dark:border-green-400/25 dark:bg-background dark:text-green-200'; + return 'dark:border-green-400/25 dark:bg-green-400/15 dark:text-green-200'; } if (level === 'warn') { - return 'dark:border-yellow-400/25 dark:bg-background dark:text-yellow-200'; + return 'dark:border-yellow-400/25 dark:bg-yellow-400/15 dark:text-yellow-200'; } - return 'dark:border-white/20 dark:bg-background dark:text-zinc-300'; + return 'dark:border-white/20 dark:bg-zinc-300/15 dark:text-zinc-300'; } const normalizedLogLevel = $derived(getLogLevel(level)); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts index bdba02f1cc..e0d614ec7a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts @@ -5,11 +5,11 @@ import LogLevel from './log-level.svelte'; describe('LogLevel', () => { it.each([ - ['debug', ['dark:border-white/20', 'dark:bg-background', 'dark:text-zinc-300']], - ['error', ['dark:border-red-400/25', 'dark:bg-background', 'dark:text-red-300']], - ['fatal', ['dark:border-red-400/25', 'dark:bg-background', 'dark:text-red-300']], - ['info', ['dark:border-green-400/25', 'dark:bg-background', 'dark:text-green-200']], - ['warn', ['dark:border-yellow-400/25', 'dark:bg-background', 'dark:text-yellow-200']] + ['debug', ['dark:border-white/20', 'dark:bg-zinc-300/15', 'dark:text-zinc-300']], + ['error', ['dark:border-red-400/25', 'dark:bg-red-400/15', 'dark:text-red-300']], + ['fatal', ['dark:border-red-400/25', 'dark:bg-red-400/15', 'dark:text-red-300']], + ['info', ['dark:border-green-400/25', 'dark:bg-green-400/15', 'dark:text-green-200']], + ['warn', ['dark:border-yellow-400/25', 'dark:bg-yellow-400/15', 'dark:text-yellow-200']] ])('renders %s as a compact tag with restrained dark-theme colors', (level, darkThemeClasses) => { render(LogLevel, { level }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte index 222c2659b4..a83a62c203 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -1,5 +1,4 @@