Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1133222
feat: Add stacks dashboard page route and API query
niemyjski May 16, 2026
b541420
build: Simplify stacks page to avoid table library issues
niemyjski May 16, 2026
f587e25
feat: Add stacks bulk actions button with all operations
niemyjski May 16, 2026
4e22019
feat: Add stacks dashboard to navigation menu
niemyjski May 16, 2026
bc49afe
docs: Add stacks filter and bulk operation examples to HTTP tests
niemyjski May 16, 2026
9236b82
feat: rewrite stacks dashboard using proper patterns
niemyjski May 16, 2026
398f00f
fix: lint errors in stacks page
niemyjski May 16, 2026
add4340
fix: clear stale organization selection state
niemyjski May 16, 2026
417c27c
fix: preserve impersonation and stabilize stack refresh handling
niemyjski May 16, 2026
3f3e669
feat: Add stacks dashboard page route and API query
niemyjski May 16, 2026
92ee156
build: Simplify stacks page to avoid table library issues
niemyjski May 16, 2026
c407206
feat: Add stacks bulk actions button with all operations
niemyjski May 16, 2026
a5dd803
feat: Add stacks dashboard to navigation menu
niemyjski May 16, 2026
ef3f3cd
docs: Add stacks filter and bulk operation examples to HTTP tests
niemyjski May 16, 2026
418c941
feat: rewrite stacks dashboard using proper patterns
niemyjski May 16, 2026
ca855f7
fix: lint errors in stacks page
niemyjski May 16, 2026
22b88c2
fix: clear stale organization selection state
niemyjski May 16, 2026
dd3d84a
fix: preserve impersonation and stabilize stack refresh handling
niemyjski May 16, 2026
ea81763
feat: align issue management and settings navigation UX
niemyjski May 17, 2026
c88bfa0
Merge remote-tracking branch 'origin/main' into niemyjski/feature-sta…
niemyjski May 18, 2026
be4d604
fix: polish row interactions and issue detail links
niemyjski May 18, 2026
0e4b275
Merge remote-tracking branch 'origin/niemyjski/feature-stacks-dashboa…
niemyjski May 18, 2026
369940d
fix: clean up settings sidebar context
niemyjski May 18, 2026
bcd83d4
fix: nest project settings under projects nav
niemyjski May 18, 2026
04d6e2c
fix: show project submenu hierarchy in settings
niemyjski May 18, 2026
7328979
fix: render project settings as nested sidebar tree
niemyjski May 18, 2026
b33192f
fix: add settings separator after organizations
niemyjski May 18, 2026
78cf97e
Polish sidebar hierarchy and issue management framing
niemyjski May 18, 2026
d3fcb70
Move sessions into dashboards navigation
niemyjski May 18, 2026
57b24a8
Add collapsed sidebar hover flyouts for child nav
niemyjski May 18, 2026
e5588e9
Revert out-of-scope changes per PR review
niemyjski May 18, 2026
ea96bea
Remove stacks page and navigation link per PR review
niemyjski May 18, 2026
3f35fd9
Improve stacks table: rename severity to critical, fix tags cell UX
niemyjski May 18, 2026
57e8e8b
Refactor project issues page to use TanStack Query
niemyjski May 18, 2026
df8aa15
Cosmetic fixes: remove double newlines, simplify margin, extract \
niemyjski May 18, 2026
f05ae77
Apply code formatting (npm run format)
niemyjski May 18, 2026
686b480
Merge branch 'feature-stacks-dashboard-fix' into niemyjski/feature-st…
niemyjski May 18, 2026
182992a
Revert stacks view type additions from saved view models
niemyjski May 18, 2026
76dc723
Fix data-table click handling and stacks.http filter syntax
niemyjski May 18, 2026
3db88a9
Make org settings routes consistent, fix stacks viewType doc
niemyjski May 18, 2026
c4f4d75
Revert tag filter from AND back to OR semantics
niemyjski May 18, 2026
206992f
Fix data-table keyboard a11y and stacks.http date format
niemyjski May 18, 2026
5acd81a
Fix CI: prettier formatting and remove obsolete feature-gate tests
niemyjski May 18, 2026
d7042e0
Fix ESLint curly rule: wrap if body in braces
niemyjski May 18, 2026
e39a76b
Merge origin/main: adopt visual polish, rename isRouteActive to isPat…
niemyjski May 18, 2026
216a393
Refactor feature flag documentation: remove specific feature referenc…
niemyjski May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions src/Exceptionless.Core/Models/Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public Organization()
public bool HasPremiumFeatures { get; set; }

/// <summary>
/// Set of enabled feature flags for this organization (e.g., "feature-saved-views").
/// Set of enabled feature flags for this organization.
/// Feature identifiers are always stored in lowercase.
/// </summary>
public ISet<string> Features { get; set; } = new HashSet<string>();
Expand Down Expand Up @@ -277,11 +277,4 @@ public enum BillingStatus
Unpaid = 4
}

/// <summary>
/// Well-known organization feature flag identifiers.
/// </summary>
public static class OrganizationFeatures
{
/// <summary>Enables the Saved Views feature for the organization.</summary>
public const string SavedViews = "feature-saved-views";
}

Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const queryKeys = {
sessionEvents: (id: string | undefined, params?: GetSessionEventsRequest['params']) => [...queryKeys.type, 'sessions', 'session', id, params] as const,
sessions: (id: string | undefined) => [...queryKeys.type, 'sessions', 'organizations', id] as const,
sessionsCount: (id: string | undefined, params?: GetOrganizationSessionsCountRequest['params']) => [...queryKeys.sessions(id), 'count', params] as const,
stackEvents: (id: string | undefined, params?: GetStackEventsRequest['params']) => [...queryKeys.stacks(id), 'events', params] as const,
stacks: (id: string | undefined) => [...queryKeys.type, 'stacks', id] as const,
stacksCount: (id: string | undefined, params?: GetStackCountRequest['params']) => [...queryKeys.stacks(id), 'count', params] as const,
type: ['PersistentEvent'] as const
Expand Down Expand Up @@ -342,6 +343,6 @@ export function getStackEventsQuery(request: GetStackEventsRequest) {

return response.data!;
},
queryKey: queryKeys.stacks(request.route.stackId)
queryKey: queryKeys.stackEvents(request.route.stackId, request.params)
}));
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
import EventsOverview from './events-overview.svelte';

interface Props {
detailsHref?: string;
eventId: null | string;
filterChanged: (filter: IFilter) => void;
onClose: () => void;
onError?: (problem: ProblemDetails) => void;
}

let { eventId = $bindable(), filterChanged, onClose, onError }: Props = $props();
let { detailsHref, eventId = $bindable(), filterChanged, onClose, onError }: Props = $props();

const resolvedHref = $derived(detailsHref ?? (eventId ? resolve('/(app)/event/[eventId]', { eventId }) : '#'));

function handleOpenChange() {
onClose();
Expand All @@ -34,18 +37,11 @@
<Sheet.Root onOpenChange={handleOpenChange} open={!!eventId}>
<Sheet.Content class="w-full overflow-y-scroll sm:max-w-full! md:w-5/6!">
<Sheet.Header>
<Sheet.Title
>Event Details <Button
href={eventId ? resolve('/(app)/event/[eventId]', { eventId }) : '#'}
size="sm"
title="Open in new window"
variant="ghost"><ExternalLink /></Button
></Sheet.Title
>
<Sheet.Title>Event Details <Button href={resolvedHref} size="sm" title="Open in new window" variant="ghost"><ExternalLink /></Button></Sheet.Title>
</Sheet.Header>
<div class="px-4">
{#if eventId}
<EventsOverview {filterChanged} id={eventId} {handleError} onSessionFilter={onClose} />
<EventsOverview {filterChanged} id={eventId} {handleError} />
{/if}
Comment thread
niemyjski marked this conversation as resolved.
</div>
</Sheet.Content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import DateTime from '$comp/formatters/date-time.svelte';
import TimeAgo from '$comp/formatters/time-ago.svelte';
import { Button } from '$comp/ui/button';
import { Skeleton } from '$comp/ui/skeleton';
import * as Table from '$comp/ui/table';
import * as Tabs from '$comp/ui/tabs';
Expand All @@ -15,9 +14,6 @@
import { getOrganizationQuery } from '$features/organizations/api.svelte';
import { getProjectQuery } from '$features/projects/api.svelte';
import StackCard from '$features/stacks/components/stack-card.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { onMount, tick } from 'svelte';

import type { PersistentEvent } from '../models/index';

Expand All @@ -35,10 +31,9 @@
filterChanged: (filter: IFilter) => void;
handleError: (problem: ProblemDetails) => void;
id: string;
onSessionFilter?: () => void;
}

let { filterChanged, handleError, id, onSessionFilter }: Props = $props();
let { filterChanged, handleError, id }: Props = $props();

function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] {
if (!event) {
Expand All @@ -63,7 +58,7 @@
}

if (getSessionId(event)) {
tabs.push('Session');
tabs.push('Session Events');
}

if (!project) {
Expand Down Expand Up @@ -117,60 +112,8 @@
type TabType = 'Environment' | 'Exception' | 'Extended Data' | 'Overview' | 'Request' | 'Trace Log' | string;

let activeTab = $state<TabType>('Overview');
let areTabsScrollable = $state(false);
let canScrollTabsLeft = $state(false);
let canScrollTabsRight = $state(false);
let shouldRoundLastTab = $state(false);
let tabsListElement = $state<HTMLElement | null>(null);
let tabs = $derived<TabType[]>(getTabs(eventQuery.data, projectQuery.data));

function updateTabScrollState(): void {
if (!tabsListElement) {
areTabsScrollable = false;
canScrollTabsLeft = false;
canScrollTabsRight = false;
shouldRoundLastTab = false;
return;
}

const maxScrollLeft = tabsListElement.scrollWidth - tabsListElement.clientWidth;
const lastTabElement = tabsListElement.querySelector('[data-slot="tabs-trigger"]:last-child');
areTabsScrollable = maxScrollLeft > 1;
canScrollTabsLeft = tabsListElement.scrollLeft > 1;
canScrollTabsRight = tabsListElement.scrollLeft < maxScrollLeft - 1;
shouldRoundLastTab = areTabsScrollable || (lastTabElement?.getBoundingClientRect().right ?? 0) >= tabsListElement.getBoundingClientRect().right - 1;
}

function scrollTabs(direction: -1 | 1): void {
if (!tabsListElement) {
return;
}

tabsListElement.scrollBy({
behavior: 'smooth',
left: direction * Math.max(tabsListElement.clientWidth * 0.75, 160)
});
}

async function refreshTabScrollState(currentTabs: TabType[]): Promise<void> {
await tick();
if (currentTabs !== tabs) {
return;
}

updateTabScrollState();
}

async function scrollActiveTabIntoView(currentTab: TabType): Promise<void> {
await tick();
if (currentTab !== activeTab) {
return;
}

tabsListElement?.querySelector('[data-state="active"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
updateTabScrollState();
}

function onPromoted(title: string): void {
activeTab = title;
}
Expand All @@ -188,23 +131,6 @@
handleError(eventQuery.error);
}
});

$effect(() => {
void refreshTabScrollState(tabs);
});

$effect(() => {
void scrollActiveTabIntoView(activeTab);
});

onMount(() => {
updateTabScrollState();
window.addEventListener('resize', updateTabScrollState);

return () => {
window.removeEventListener('resize', updateTabScrollState);
};
});
</script>

<StackCard {filterChanged} id={eventQuery.data?.stack_id}></StackCard>
Expand Down Expand Up @@ -241,53 +167,11 @@

{#if eventQuery.isSuccess}
<Tabs.Root class="mt-4 mb-4" value={activeTab}>
<div class="flex min-w-0 items-center gap-1">
{#if areTabsScrollable}
<Button
aria-label="Scroll tabs left"
class="flex-none"
disabled={!canScrollTabsLeft}
onclick={() => scrollTabs(-1)}
size="icon-sm"
title="Scroll tabs left"
variant="ghost"
>
<ChevronLeft />
</Button>
{/if}

<Tabs.List
bind:ref={tabsListElement}
class="divide-border bg-background h-8 min-w-0 flex-1 justify-normal divide-x overflow-x-auto overflow-y-hidden rounded-lg border p-0 shadow-xs [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onscroll={updateTabScrollState}
>
{#each tabs as tab (tab)}
<Tabs.Trigger
class={[
'data-[state=active]:bg-muted flex-none rounded-none border-0 px-4 shadow-none first:rounded-l-lg data-[state=active]:shadow-none',
shouldRoundLastTab && 'last:rounded-r-lg'
]}
value={tab}
>
{tab}
</Tabs.Trigger>
{/each}
</Tabs.List>

{#if areTabsScrollable}
<Button
aria-label="Scroll tabs right"
class="flex-none"
disabled={!canScrollTabsRight}
onclick={() => scrollTabs(1)}
size="icon-sm"
title="Scroll tabs right"
variant="ghost"
>
<ChevronRight />
</Button>
{/if}
</div>
<Tabs.List class="w-full justify-normal overflow-scroll">
{#each tabs as tab (tab)}
<Tabs.Trigger value={tab}>{tab}</Tabs.Trigger>
{/each}
</Tabs.List>

{#each tabs as tab (tab)}
<Tabs.Content value={tab}>
Expand All @@ -301,8 +185,8 @@
<Request {filterChanged} event={eventQuery.data}></Request>
{:else if tab === 'Trace Log'}
<TraceLog logs={eventQuery.data.data?.['@trace']}></TraceLog>
{:else if tab === 'Session'}
<SessionEvents event={eventQuery.data} {hasPremiumFeatures} {onSessionFilter}></SessionEvents>
{:else if tab === 'Session Events'}
<SessionEvents event={eventQuery.data} {hasPremiumFeatures}></SessionEvents>
{:else if tab === 'Extended Data'}
<ExtendedData event={eventQuery.data} project={projectQuery.data} promoted={onPromoted}></ExtendedData>
{:else}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@ import type { IFilter } from '$comp/faceted-filter';
import type { ColumnVisibilityState } from '@tanstack/svelte-table';

import { deserializeFilters } from '$features/events/components/filters/helpers.svelte';
import { getOrganizationQuery } from '$features/organizations/api.svelte';
import { organization } from '$features/organizations/context.svelte';
import { untrack } from 'svelte';

import type { SavedView } from './models';

import { getSavedViewsByViewQuery } from './api.svelte';

const SAVED_VIEWS_FEATURE = 'feature-saved-views';

export interface SavedViewQueryParams {
filter: null | string;
saved: null | string | undefined;
Expand All @@ -35,6 +32,7 @@ export interface UseSavedViewsReturn {
handleLoadView: (id: string) => void;
handleResetToSaved: () => void;
isEnabled: boolean;
isLoading: boolean;
isModified: boolean;
savedViews: SavedView[];
}
Expand All @@ -60,16 +58,7 @@ export function supportsTimeQueryParam(queryParams: SavedViewQueryParams): query
}

export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn {
const organizationQuery = getOrganizationQuery({
route: {
get id() {
return organization.current;
}
}
});

// Feature flag gate: only enable saved views if the organization has the feature
const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false);
const isEnabled = $derived(!!organization.current);

// Some routes, such as stream, do not declare every saved-view query parameter.
const supportsSort = supportsSortQueryParam(options.queryParams);
Expand All @@ -78,7 +67,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
const savedViewsListQuery = getSavedViewsByViewQuery({
route: {
get organizationId() {
return isEnabled ? organization.current : undefined;
return organization.current;
},
get view() {
return options.view;
Expand Down Expand Up @@ -269,6 +258,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
get isEnabled() {
return isEnabled;
},
get isLoading() {
return savedViewsListQuery.isLoading;
},
get isModified() {
return isModified;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@
return;
}

const target = event.target as HTMLElement | null;

// Don't intercept clicks on interactive elements (links, buttons)
if (target?.closest('a, button')) {
return;
}

// For regular clicks with href, prevent default navigation
if (rowHref) {
event.preventDefault();
Expand Down Expand Up @@ -86,7 +93,20 @@
{@render children()}
{/if}
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
<Table.Row
tabindex={rowClick ? 0 : undefined}
onkeydown={rowClick
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const firstCell = row.getVisibleCells()[0];
if (firstCell) {
rowClick(firstCell.row.original);
}
}
}
: undefined}
>
{#each row.getVisibleCells() as cell (cell.id)}
{#if rowHref && cell.row.original}
{@const href = rowHref(cell.row.original)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@
}

let { table }: Props = $props();
let page = $state(1);
const page = $derived(table.store.state.pagination.pageIndex + 1);

const pageCount = $derived(Math.max(1, table.getPageCount() || 1));

function handlePageChange(nextPage: number) {
page = nextPage;
table.setPageIndex(nextPage - 1);
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,12 @@ export function getSharedTableOptions<TData extends RowData, TPaginationStrategy
const setMetaImpl = (meta: QueryMeta | undefined) => {
setMeta(meta);
const limit = configuration.queryParameters.limit ?? DEFAULT_LIMIT;

const total = isMemoryPaging ? allData().length : ((meta?.total as number) ?? 0);
const totalPages = Math.ceil(total / limit);
const currentPage =
(configuration.paginationStrategy === 'offset'
? (configuration.queryParameters as TableOffsetPagingParameters).page
: (configuration.queryParameters as TableMemoryPagingParameters).page) ?? 1;
const total = isMemoryPaging ? allData().length : (meta?.total as number | undefined);
const totalPages = total != null ? Math.ceil(total / limit) : meta?.links?.next ? currentPage + 1 : currentPage;
setPageCount(totalPages);

// // Only adjust pagination for offset pagination here
Expand Down
Loading