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.Core/Models/StackSummaryModel.cs b/src/Exceptionless.Core/Models/StackSummaryModel.cs index 197065fc71..6cc4d0a295 100644 --- a/src/Exceptionless.Core/Models/StackSummaryModel.cs +++ b/src/Exceptionless.Core/Models/StackSummaryModel.cs @@ -5,6 +5,9 @@ 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 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 1d5cdd7105..6238b95681 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 @@ -917,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) => { @@ -926,6 +933,9 @@ 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 b61206d1d1..eea6114c39 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,9 @@ private async Task> GetStackSummariesAsync(IColle Id = data.Id, TemplateKey = data.TemplateKey, 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/log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte index ce83f26d8a..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 @@ -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/25 dark:bg-red-400/15 dark:text-red-300'; + } + + if (level === 'info') { + 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-yellow-400/15 dark:text-yellow-200'; + } + + return 'dark:border-white/20 dark:bg-zinc-300/15 dark:text-zinc-300'; + } + 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 new file mode 100644 index 0000000000..e0d614ec7a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte.test.ts @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import LogLevel from './log-level.svelte'; + +describe('LogLevel', () => { + it.each([ + ['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 }); + + 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'); + darkThemeClasses.forEach((className) => expect(badge.classList).toContain(className)); + }); +}); 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..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 @@ -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; } @@ -95,7 +98,10 @@ export interface StackSummaryModel extends Summar first_occurrence: string; /** @format date-time */ last_occurrence: string; + 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/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..81711f8886 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-tags-summary-cell.svelte @@ -0,0 +1,12 @@ + + + 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..3cd41c3162 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import type { EventSummaryModel, StackSummaryModel, SummaryTemplateKeys } from '../summary'; + +import { defaultEventColumnVisibility, defaultStackColumnVisibility, 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); + }); + + 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 a1d39b5f98..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 @@ -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,14 +21,21 @@ export const defaultEventColumnVisibility: ColumnVisibilityState = { level: false, message: false, name: false, + project: false, source: false, + tags: false, type: false, version: false }; +export const defaultStackColumnVisibility: ColumnVisibilityState = { + project: false, + tags: false +}; + 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[] = [ @@ -61,6 +69,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' + } } ]; @@ -84,6 +102,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' + } + }, { accessorFn: (row) => getSummaryDataValue(row, 'Message'), cell: (prop) => formatTextColumn(prop.getValue()), @@ -153,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() }), @@ -212,6 +250,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/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/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; 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/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..a83a62c203 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte @@ -0,0 +1,113 @@ + + +{#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} +
+ {: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..5899d8d21b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -0,0 +1,44 @@ +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 neutral 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(); + + 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'); + } + }); + + 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)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index cbb73914c0..9389da988a 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'; @@ -629,9 +629,11 @@ columnPersistenceKey: 'stacks-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); }, + defaultColumnVisibility: defaultStackColumnVisibility, paginationStrategy: 'offset', get queryData() { return clientResponse?.data ?? []; 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') diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index a0775293c1..d08f29873b 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", "tags", "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/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 00be49ac24..f46babddc4 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() { @@ -1220,8 +1244,11 @@ 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)); + Assert.False(String.IsNullOrWhiteSpace(summary.ProjectName)); Assert.False(String.IsNullOrWhiteSpace(summary.Title)); Assert.NotEqual(default, summary.FirstOccurrence); Assert.NotEqual(default, summary.LastOccurrence); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 7b937a084d..03cf028cc3 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -2125,6 +2125,32 @@ public Task PostAsync_ValidColumnKeys_Succeeds() ); } + [Theory] + [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) + { + // 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")] 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}}