diff --git a/.github/workflows/build-arm64.yml b/.github/workflows/build-arm64.yml index ee998eb810..e946daab18 100644 --- a/.github/workflows/build-arm64.yml +++ b/.github/workflows/build-arm64.yml @@ -25,8 +25,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.* - dotnet-quality: ga + dotnet-version: 10.0.300 - name: Build Reason env: GITHUB_EVENT: ${{ toJson(github) }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 88dad7b377..e48814731d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -47,8 +47,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.* - dotnet-quality: ga + dotnet-version: 10.0.300 - name: Version id: version @@ -78,8 +77,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.* - dotnet-quality: ga + dotnet-version: 10.0.300 - uses: actions/cache@v5 with: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 8cbdf4a04a..d3fd5a287a 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -25,16 +25,15 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.* - dotnet-quality: ga + dotnet-version: 10.0.300 - name: Install Aspire CLI run: | export PATH="$HOME/.dotnet/tools:$PATH" if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then - dotnet tool update --global Aspire.Cli --version 13.3.3 + dotnet tool update --global Aspire.Cli --version 13.3.4 else - dotnet tool install --global Aspire.Cli --version 13.3.3 + dotnet tool install --global Aspire.Cli --version 13.3.4 fi echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/.github/workflows/elasticsearch-docker-8.yml b/.github/workflows/elasticsearch-docker-8.yml index 87bfbb6152..80ffa2d4be 100644 --- a/.github/workflows/elasticsearch-docker-8.yml +++ b/.github/workflows/elasticsearch-docker-8.yml @@ -16,8 +16,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.* - dotnet-quality: ga + dotnet-version: 10.0.300 - name: Build Reason env: GITHUB_EVENT: ${{ toJson(github) }} diff --git a/Dockerfile b/Dockerfile index fbf579940d..78bbbb2cb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build ARG MinVerVersionOverride WORKDIR /app @@ -40,7 +40,7 @@ RUN dotnet publish -c Release -o out --no-build # job -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS job +FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS job WORKDIR /app COPY --from=job-publish /app/src/Exceptionless.Job/out ./ @@ -57,7 +57,7 @@ RUN dotnet publish -c Release -o out --no-build /p:SkipSpaPublish=true # api -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api +FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS api WORKDIR /app COPY --from=api-publish /app/src/Exceptionless.Web/out ./ @@ -73,7 +73,7 @@ RUN dotnet publish -c Release -o out --no-build # app -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS app +FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS app WORKDIR /app COPY --from=app-publish /app/src/Exceptionless.Web/out ./ @@ -147,7 +147,7 @@ USER elasticsearch RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh && \ chmod +x dotnet-install.sh && \ - ./dotnet-install.sh --channel 10.0 --runtime aspnetcore && \ + ./dotnet-install.sh --version 10.0.8 --runtime aspnetcore && \ rm dotnet-install.sh EXPOSE 8080 9200 diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index 2644129894..3589e46ecf 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -8,10 +8,10 @@ $(NoWarn);ASPIRECERTIFICATES001 - - - - + + + + diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IOrganizationRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IOrganizationRepository.cs index 0fd176d691..3786f776f8 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IOrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IOrganizationRepository.cs @@ -1,5 +1,6 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; +using Exceptionless.Core.Repositories.Queries; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -9,6 +10,7 @@ public interface IOrganizationRepository : ISearchableRepository { Task GetByInviteTokenAsync(string token); Task GetByStripeCustomerIdAsync(string customerId); + Task> GetByFilterAsync(AppFilter systemFilter, string? userFilter, string? sort, CommandOptionsDescriptor? options = null); Task> GetByCriteriaAsync(string? criteria, CommandOptionsDescriptor options, OrganizationSortBy sortBy, bool? paid = null, bool? suspended = null); Task GetBillingPlanStatsAsync(); } diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs index 49fff6a797..f41d80bc52 100644 --- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Validation; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -47,6 +48,16 @@ private void OnDocumentsChanging(object sender, DocumentsChangeEventArgs> GetByFilterAsync(AppFilter systemFilter, string? userFilter, string? sort, CommandOptionsDescriptor? options = null) + { + IRepositoryQuery query = new RepositoryQuery() + .AppFilter(systemFilter) + .FilterExpression(userFilter); + + query = !String.IsNullOrEmpty(sort) ? query.SortExpression(sort) : query.SortAscending(o => o.Name.Suffix("keyword")); + return FindAsync(q => query, options); + } + public Task> GetByCriteriaAsync(string? criteria, CommandOptionsDescriptor options, OrganizationSortBy sortBy, bool? paid = null, bool? suspended = null) { var filter = Query.MatchAll(); diff --git a/src/Exceptionless.Core/Repositories/Queries/AppFilterQuery.cs b/src/Exceptionless.Core/Repositories/Queries/AppFilterQuery.cs index d6fba410e0..4af8b8ebb1 100644 --- a/src/Exceptionless.Core/Repositories/Queries/AppFilterQuery.cs +++ b/src/Exceptionless.Core/Repositories/Queries/AppFilterQuery.cs @@ -160,12 +160,13 @@ public AppFilterQueryBuilder(AppOptions options, TimeProvider timeProvider) return Task.CompletedTask; } + string organizationIdFieldName = typeof(T) == typeof(Organization) ? "id" : _organizationIdFieldName; foreach (var organization in allowedOrganizations) { if (shouldApplyRetentionFilter) - container |= (Query.Term(_organizationIdFieldName, organization.Id) && GetRetentionFilter(field, organization, _options.MaximumRetentionDays)); + container |= (Query.Term(organizationIdFieldName, organization.Id) && GetRetentionFilter(field, organization, _options.MaximumRetentionDays)); else - container |= Query.Term(_organizationIdFieldName, organization.Id); + container |= Query.Term(organizationIdFieldName, organization.Id); } ctx.Filter &= container; diff --git a/src/Exceptionless.Web/ClientApp/src/app.css b/src/Exceptionless.Web/ClientApp/src/app.css index 2194bb62d5..04ba110d8c 100644 --- a/src/Exceptionless.Web/ClientApp/src/app.css +++ b/src/Exceptionless.Web/ClientApp/src/app.css @@ -164,6 +164,15 @@ } } +@utility no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + @keyframes accordion-down { from { height: 0; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte index a451c9a600..bb4d7fa3fe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-dashboard-chart.svelte @@ -49,7 +49,7 @@ ]; -
+
{#if isLoading} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 0e093f0c52..28099118d7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -5,6 +5,7 @@ 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'; @@ -14,6 +15,9 @@ 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'; @@ -113,6 +117,29 @@ let activeTab = $state('Overview'); let tabs = $derived(getTabs(eventQuery.data, projectQuery.data)); + let tabsListRef = $state(null); + let canScrollTabsLeft = $state(false); + let canScrollTabsRight = $state(false); + + function updateTabsOverflow(): void { + if (!tabsListRef) { + canScrollTabsLeft = false; + canScrollTabsRight = false; + return; + } + + const maxScrollLeft = tabsListRef.scrollWidth - tabsListRef.clientWidth; + canScrollTabsLeft = tabsListRef.scrollLeft > 1; + canScrollTabsRight = tabsListRef.scrollLeft < maxScrollLeft - 1; + } + + function scrollTabs(direction: 'left' | 'right'): void { + if (!tabsListRef) { + return; + } + + tabsListRef.scrollBy({ behavior: 'smooth', left: direction === 'left' ? -tabsListRef.clientWidth / 2 : tabsListRef.clientWidth / 2 }); + } function onPromoted(title: string): void { activeTab = title; @@ -131,6 +158,31 @@ handleError(eventQuery.error); } }); + + $effect(() => { + const tabCount = tabs.length; + void tick().then(() => { + if (tabCount === tabs.length) { + updateTabsOverflow(); + } + }); + }); + + onMount(() => { + updateTabsOverflow(); + + const resizeObserver = new ResizeObserver(updateTabsOverflow); + if (tabsListRef) { + resizeObserver.observe(tabsListRef); + } + + window.addEventListener('resize', updateTabsOverflow); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateTabsOverflow); + }; + }); @@ -167,11 +219,39 @@ {#if eventQuery.isSuccess} - - {#each tabs as tab (tab)} - {tab} - {/each} - +
+ {#if canScrollTabsLeft} + + {/if} + + {#each tabs as tab (tab)} + {tab} + {/each} + + {#if canScrollTabsRight} + + {/if} +
{#each tabs as tab (tab)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte index 5902fd1bc2..49aff3c6e9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-stack-chart.svelte @@ -37,7 +37,7 @@ ]; -
+
{#if isLoading} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index a15b314cf5..96d51972c1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -113,6 +113,7 @@ export interface GetOrganizationRequest { export type GetOrganizationsMode = 'stats' | null; export interface GetOrganizationsParams { + filter?: string; mode: GetOrganizationsMode; } @@ -363,7 +364,7 @@ export function getOrganizationsQuery(request: GetOrganizationsRequest) { return response; }, - queryKey: [...queryKeys.list(request.params?.mode ?? undefined), { params: request.params }] + queryKey: [...queryKeys.list(request.params?.mode ?? undefined), { params: { ...request.params } }] })); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts index 2412c353dd..6816eebfd7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts @@ -31,7 +31,7 @@ export function getColumns(mode: GetOrg enableSorting: false, header: 'Plan', meta: { - class: 'w-[200px]' + class: 'w-28' } }, { @@ -111,10 +111,10 @@ export function getColumns(mode: GetOrg cell: (info) => renderComponent(OrganizationsActionsCell, { organization: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/config-options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/config-options.svelte.ts index b2a19e62c4..276c7fb7f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/config-options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/config-options.svelte.ts @@ -36,10 +36,10 @@ export function getColumns renderComponent(ProjectConfigActionsCell, { projectId: params.projectId, setting: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/options.svelte.ts index 5a3b368ca7..33cc397d04 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/table/options.svelte.ts @@ -9,7 +9,14 @@ import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/s import type { GetOrganizationProjectsParams, GetProjectsMode } from '../../api.svelte'; -export function getColumns(mode: GetProjectsMode = 'stats'): ColumnDef[] { +interface ProjectTableOptions { + includeOrganizationColumn?: boolean; +} + +export function getColumns( + mode: GetProjectsMode = 'stats', + options: ProjectTableOptions = {} +): ColumnDef[] { const columns: ColumnDef[] = [ { accessorKey: 'name', @@ -22,6 +29,18 @@ export function getColumns(mode: GetProjectsMode = } ]; + if (options.includeOrganizationColumn) { + columns.push({ + accessorKey: 'organization_name', + cell: (info) => info.getValue(), + enableSorting: false, + header: 'Organization', + meta: { + class: 'w-50' + } + }); + } + const isStatsMode = mode === 'stats'; if (isStatsMode) { columns.push( @@ -50,10 +69,10 @@ export function getColumns(mode: GetProjectsMode = cell: (info) => renderComponent(ProjectActionsCell, { project: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } }); @@ -62,12 +81,13 @@ export function getColumns(mode: GetProjectsMode = export function getTableOptions( queryParameters: GetOrganizationProjectsParams, - queryResponse: CreateQueryResult, ProblemDetails> + queryResponse: CreateQueryResult, ProblemDetails>, + options: ProjectTableOptions = {} ) { return getSharedTableOptions({ columnPersistenceKey: 'projects-column-visibility', get columns() { - return getColumns(queryParameters.mode); + return getColumns(queryParameters.mode, options); }, paginationStrategy: 'offset', get queryData() { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index fabf5aaa6a..8ebb4f4df2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -18,11 +18,8 @@ import Plus from '@lucide/svelte/icons/plus'; import Save from '@lucide/svelte/icons/save'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; - import Star from '@lucide/svelte/icons/star'; - import StarOff from '@lucide/svelte/icons/star-off'; import Trash2 from '@lucide/svelte/icons/trash-2'; import Undo2 from '@lucide/svelte/icons/undo-2'; - import X from '@lucide/svelte/icons/x'; import { tick } from 'svelte'; import { toast } from 'svelte-sonner'; @@ -179,20 +176,6 @@ } } - async function handleToggleDefault() { - if (!activeView || !organizationId) { - return; - } - - const willBeDefault = !activeView.is_default; - try { - await updateMutation.mutateAsync({ is_default: willBeDefault }); - toast.success(willBeDefault ? 'Set as default for everyone.' : 'Default removed.'); - } catch (error) { - toast.error(getErrorMessage(error, 'Failed to update default setting.')); - } - } - async function handleUpdate() { if (!activeView || !organizationId) { return; @@ -264,25 +247,10 @@
- Click to filter. click to copy. + Click to filter. {copyTagShortcut} click to copy. {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts index 4222a1149b..8142bc8593 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts @@ -35,10 +35,10 @@ export function getColumns(): ColumnDef renderComponent(TokenActionsCell, { token: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts index 8f44b1d0fc..bc84d00a68 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts @@ -47,10 +47,10 @@ export function getColumns(organizationId: string): Colu cell: (info) => renderComponent(UserActionsCell, { organizationId, user: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts index c04b265c95..cdf363c490 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/webhooks/components/table/options.svelte.ts @@ -34,10 +34,10 @@ export function getColumns(): ColumnDef renderComponent(WebhookActionsCell, { webhook: info.row.original }), enableHiding: false, enableSorting: false, - header: 'Actions', + header: '', id: 'actions', meta: { - class: 'w-16' + class: 'w-12 min-w-12 max-w-12 text-right' } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index acb2ccbab4..c35d0c38df 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -34,10 +34,9 @@
- diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 28abbe8b7a..e834a76f2a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -103,7 +103,7 @@ > Organizations {#if organizations.length > 0} - {#each organizations as organization, index (organization.name)} + {#each organizations as organization (organization.name)} onOrganizationSelected(organization)} data-active={organization.id === currentOrganizationId && !isImpersonating} @@ -113,7 +113,6 @@ {getInitials(organization.name)} {organization.name} - ⌘{index + 1} {/each} {:else} @@ -135,8 +134,7 @@
- Manage organization - ⇧⌘go + Manage Organization {/if} @@ -146,7 +144,6 @@
Add organization - ⇧⌘gn diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index c04f31b43b..03264e27eb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -3,7 +3,6 @@ import type { Gravatar } from '$features/users/gravatar.svelte'; import type { ViewCurrentUser } from '$features/users/models'; - import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import GitHubIcon from '$comp/icons/GitHubIcon.svelte'; import { A } from '$comp/typography'; @@ -13,10 +12,8 @@ import * as Sidebar from '$comp/ui/sidebar/index'; import { useSidebar } from '$comp/ui/sidebar/index'; import { Skeleton } from '$comp/ui/skeleton'; - import ImpersonateOrganizationDialog from '$features/organizations/components/dialogs/impersonate-organization-dialog.svelte'; import { organization } from '$features/organizations/context.svelte'; import { apiReferenceHref, documentationHref, githubRepositoryHref, supportIssuesHref } from '$features/shared/help-links'; - import GlobalUser from '$features/users/components/global-user.svelte'; import BadgeCheck from '@lucide/svelte/icons/badge-check'; import Bell from '@lucide/svelte/icons/bell'; import BookOpen from '@lucide/svelte/icons/book-open'; @@ -24,32 +21,23 @@ import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; import Help from '@lucide/svelte/icons/circle-help'; import CreditCard from '@lucide/svelte/icons/credit-card'; - import Database from '@lucide/svelte/icons/database'; - import DatabaseZap from '@lucide/svelte/icons/database-zap'; - import Eye from '@lucide/svelte/icons/eye'; - import EyeOff from '@lucide/svelte/icons/eye-off'; - import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import LogOut from '@lucide/svelte/icons/log-out'; - import Play from '@lucide/svelte/icons/play'; import Plus from '@lucide/svelte/icons/plus'; import Settings from '@lucide/svelte/icons/settings'; - import Wrench from '@lucide/svelte/icons/wrench'; interface Props { gravatar: Gravatar; intercomUnreadCount: number; isChatEnabled: boolean; - isImpersonating?: boolean; isLoading: boolean; openChat: () => void; organizations?: ViewOrganization[]; user: undefined | ViewCurrentUser; } - let { gravatar, intercomUnreadCount = 0, isChatEnabled, isImpersonating = false, isLoading, openChat, organizations = [], user }: Props = $props(); + let { gravatar, intercomUnreadCount = 0, isChatEnabled, isLoading, openChat, organizations = [], user }: Props = $props(); const sidebar = useSidebar(); const currentOrganizationId = $derived(organizations.find((organizationItem) => organizationItem.id === organization.current)?.id); - let openImpersonateDialog = $state(false); function getUnreadCountLabel(unreadCount: number): string { return unreadCount > 99 ? '99+' : unreadCount.toString(); @@ -65,16 +53,6 @@ onMenuClick(); openChat(); } - - async function impersonateOrganization(vo: ViewOrganization): Promise { - await goto(resolve('/(app)')); - organization.current = vo.id; - } - - async function stopImpersonating(): Promise { - await goto(resolve('/(app)')); - organization.current = organizations[0]?.id; - } {#if isLoading} @@ -159,12 +137,10 @@ Account - ⇧⌘ga Notifications - ⇧⌘gn {#if currentOrganizationId} @@ -175,8 +151,7 @@ class="flex w-full items-center gap-2" onclick={onMenuClick} > - Manage organization - ⇧⌘go + Manage Organization @@ -188,7 +163,6 @@ onclick={onMenuClick} > Billing - ⇧⌘gb {:else} @@ -196,7 +170,6 @@ Add organization - ⇧⌘gn {/if} @@ -219,17 +192,14 @@ Documentation - ⌘gw Support - ⌘gs GitHub - ⌘gg @@ -238,65 +208,16 @@ Keyboard shortcuts - ⌘K - - - - - - System - - - - - Overview - - - - Elasticsearch - - - - Actions - - - - Migrations - - - - {#if isImpersonating} - - - Stop Impersonating - - {:else} - (openImpersonateDialog = true)}> - - Impersonate Organization - - {/if} - Log out - ⇧⌘Q {/if} - -{#if openImpersonateDialog} - o.id)} /> -{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index 3ad3054a5a..97223c44de 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte @@ -8,11 +8,8 @@ import * as DropdownMenu from '$comp/ui/dropdown-menu'; import * as Sidebar from '$comp/ui/sidebar'; import { useSidebar } from '$comp/ui/sidebar'; - import { getProjectQuery } from '$features/projects/api.svelte'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import Settings from '@lucide/svelte/icons/settings-2'; - import User from '@lucide/svelte/icons/user'; import Wrench from '@lucide/svelte/icons/wrench'; import { onDestroy } from 'svelte'; @@ -45,6 +42,15 @@ return isPathActive(childUrl.pathname); } + function isRouteActive(route: NavigationItem): boolean { + const routeHref = String(route.href); + if (isPathActive(routeHref)) { + return true; + } + + return route.children?.some((childItem) => isChildItemActive(childItem, routeHref)) ?? false; + } + type Props = ComponentProps & { footer?: Snippet; header?: Snippet; @@ -53,26 +59,28 @@ let { footer, header, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); - const dashboardsIsActive = $derived(dashboardRoutes.some((route) => isPathActive(String(route.href)))); const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); + const organizationSettingsRoutes = $derived(routes.filter((route) => route.group === 'Organization Settings')); const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); - const settingsIsActive = $derived(routes.some((route) => isSettingsGroup(route.group) && isPathActive(route.href))); - const currentProjectId = $derived(page.params.projectId); - const currentProjectQuery = getProjectQuery({ - route: { - get id() { - return currentProjectId; - } - } - }); - const currentProjectName = $derived(currentProjectQuery.data?.name ?? currentProjectId ?? 'Project'); const systemRoutes = $derived(routes.filter((route) => route.group === 'System')); + const systemRoute = $derived(systemRoutes[0]); const systemBasePath = resolve('/(app)/system'); const systemIsActive = $derived(page.url.pathname === systemBasePath || page.url.pathname.startsWith(systemBasePath + '/')); - const accountRoutes = $derived(routes.filter((route) => route.group === 'My Account')); - const accountIsActive = $derived(accountRoutes.some((route) => isPathActive(String(route.href)))); + const settingsIsActive = $derived(routes.some((route) => isSettingsGroup(route.group) && isPathActive(route.href)) || systemIsActive); + + function isSettingsRouteActive(route: NavigationItem): boolean { + if (isPathActive(String(route.href))) { + return true; + } + + if (route.title === 'Organizations') { + return organizationSettingsRoutes.some((organizationSettingsRoute) => isPathActive(String(organizationSettingsRoute.href))); + } + + return route.title === 'Projects' && projectSettingsRoutes.some((projectSettingsRoute) => isPathActive(String(projectSettingsRoute.href))); + } const sidebar = useSidebar(); const isIconCollapsed = $derived(sidebar.state === 'collapsed' && !sidebar.isMobile); @@ -155,156 +163,119 @@ - {#if isIconCollapsed} - {@const menuId = 'section:dashboards'} - onHoverMenuOpenChange(menuId, open)}> - - {#snippet child({ props })} - openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> - - - Dashboards - - - {/snippet} - - openHoverMenu(menuId)} - onmouseleave={() => closeHoverMenu(menuId)} - > - {#each dashboardRoutes as route (route.href)} - {#if route.children?.length} - - openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> - {route.title} - - openHoverMenu(menuId)} - onmouseleave={() => closeHoverMenu(menuId)} - > - - - {route.title} - - - - {#each route.children as savedItem (savedItem.href)} - - - {savedItem.title} - - - {/each} - - - {:else} + {#each dashboardRoutes as route (route.href)} + {@const Icon = route.icon} + {#if isIconCollapsed} + {#if route.children?.length} + {@const menuId = `route:${route.href}`} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> + + + {route.title} + + + {/snippet} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > {route.title} - {/if} - {/each} - - - {:else} - - {#snippet child({ props })} - - + + {#each route.children as savedItem (savedItem.href)} + + + {savedItem.title} + + + {/each} + + + {:else} + + {#snippet child({ props })} - - - Dashboards - - + + + {route.title} + {/snippet} - - - - {#each dashboardRoutes as route (route.href)} - {@const Icon = route.icon} - {#if route.children?.length} - {@const isChildActive = - route.href === page.url.pathname || - route.children.some((childItem) => isChildItemActive(childItem, route.href))} - - {#snippet child({ props: collapsibleProps })} - - - {#snippet child({ props: triggerProps })} - - {#snippet child({ props: buttonProps })} - - - {route.title} - - - {/snippet} - - {/snippet} - - - - {#each route.children as savedItem (savedItem.href)} - - - {#snippet child({ props: subProps })} - - {savedItem.title} - - {/snippet} - - - {/each} - - - - {/snippet} - - {:else} + + + {/if} + {:else if route.children?.length} + + {#snippet child({ props: collapsibleProps })} + + + {#snippet child({ props: triggerProps })} + + {#snippet child({ props: buttonProps })} + + + {route.title} + + + {/snippet} + + {/snippet} + + + + {#each route.children as savedItem (savedItem.href)} - - {#snippet child({ props })} - - - {route.title} + + {#snippet child({ props: subProps })} + + {savedItem.title} {/snippet} - {/if} - {/each} - - - - {/snippet} - - {/if} - - - - - + {/each} + + + + {/snippet} + + {:else} + + + {#snippet child({ props })} + + + {route.title} + + {/snippet} + + + {/if} + {/each} {#if isIconCollapsed} {@const menuId = 'section:settings'} onHoverMenuOpenChange(menuId, open)}> @@ -331,18 +302,6 @@ {subItem.title} - {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - - {currentProjectName} - {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} - - - {projectSubItem.title} - - - {/each} - - {/if} {/each} @@ -361,14 +320,9 @@ - {#each settingsRoutes as subItem, index (subItem.href)} - {#if index > 0 && settingsRoutes[index - 1]?.title === 'Organizations'} - -
-
- {/if} + {#each settingsRoutes as subItem (subItem.href)} - + {#snippet child({ props })} {#if subItem.icon} @@ -380,42 +334,19 @@ {/snippet} - {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - -
- {currentProjectName} -
-
- {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} - - - {#snippet child({ props })} -
- {#if projectSubItem.icon} - {@const Icon = projectSubItem.icon} - - {/if} - {projectSubItem.title} - - {/snippet} - - - {/each} - -
-
- {/if} {/each} + {#if systemRoute} + + + {#snippet child({ props })} + + + System + + {/snippet} + + + {/if}
@@ -424,150 +355,6 @@ {/if} - - {#if accountRoutes.length > 0} - - - {#if isIconCollapsed} - {@const menuId = 'section:account'} - onHoverMenuOpenChange(menuId, open)}> - - {#snippet child({ props })} - openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> - - - Account - - - {/snippet} - - openHoverMenu(menuId)} - onmouseleave={() => closeHoverMenu(menuId)} - > - {#each accountRoutes as subItem (subItem.href)} - - - {subItem.title} - - - {/each} - - - {:else} - - {#snippet child({ props })} - - - {#snippet child({ props })} - - - Account - - - {/snippet} - - - - {#each accountRoutes as subItem (subItem.href)} - - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - - - {/each} - - - - {/snippet} - - {/if} - - - {/if} - - {#if systemRoutes.length > 0} - - - {#if isIconCollapsed} - {@const menuId = 'section:system'} - onHoverMenuOpenChange(menuId, open)}> - - {#snippet child({ props })} - openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> - - - System - - - {/snippet} - - openHoverMenu(menuId)} - onmouseleave={() => closeHoverMenu(menuId)} - > - {#each systemRoutes as subItem (subItem.href)} - - - {subItem.title} - - - {/each} - - - {:else} - - {#snippet child({ props })} - - - {#snippet child({ props })} - - - System - - - {/snippet} - - - - {#each systemRoutes as subItem (subItem.href)} - - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - - - {/each} - - - - {/snippet} - - {/if} - - - {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte index 106a027de3..dc0f06eaa0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte @@ -1,13 +1,62 @@ - - - - No results found. - {#each Object.entries(groupedRoutes) as [group, items], index (group)} - - {#each items as route (route.href)} - - +{#key resetKey} + + + + No results found. + {#each Object.entries(groupedRoutes) as [group, items], index (group)} + + {#each items as route (route.href)} + {#if route.icon} {@const Icon = route.icon} {/if} -
{route.title}
-
-
- {/each} -
- {#if index !== Object.keys(groupedRoutes).length - 1} - - {/if} - {/each} -
-
+
+ {route.title} + {#if route.parentTitle} + {route.parentTitle} + {/if} +
+ + {/each} + + {#if index !== Object.keys(groupedRoutes).length - 1} + + {/if} + {/each} + + +{/key} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 13f22e3ec7..68bc35eea2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -49,6 +49,7 @@ let requiresPremium = $derived(premiumPage.requiresPremium || filterUsesPremiumFeatures(page.url.searchParams.get('filter'))); const sidebar = useSidebar(); let isCommandOpen = $state(false); + let commandResetKey = $state(0); // Auto-reset premium page state on navigation so pages don't need cleanup beforeNavigate(() => { @@ -56,6 +57,7 @@ }); function openCommandPalette(): void { + commandResetKey += 1; isCommandOpen = true; } @@ -157,9 +159,13 @@ const currentToken = accessToken.current; function handleKeydown(e: KeyboardEvent) { - if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isEditableElement(e.target)) { + return; + } + + if (e.key === '/') { e.preventDefault(); - isCommandOpen = !isCommandOpen; + openCommandPalette(); } } @@ -189,6 +195,14 @@ }; }); + function isEditableElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + return target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName); + } + const meQuery = getMeQuery(); const gravatar = getGravatarFromCurrentUser(meQuery); const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); @@ -403,22 +417,13 @@ {/snippet} {#snippet footer()} - + {/snippet}
- + {#if showOrganizationNotifications.current} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte index cf517798b9..9ff0f8abf9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte @@ -1,13 +1,12 @@ -

Settings

-Manage your account settings and set e-mail preferences. - - - - - - - -{@render children()} +
+

Account

+
+ + {@render children()} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/+page.svelte index 5981068b2b..11e3b28109 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/+page.svelte @@ -1,8 +1,7 @@
-
-

Account

- Manage your account settings and set e-mail preferences. -
- - {#await gravatar.src} {gravatar.initials} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index 4e4376ee6c..5c660266e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -4,7 +4,6 @@ import { A, H3, Muted } from '$comp/typography'; import { Badge } from '$comp/ui/badge'; import * as Select from '$comp/ui/select'; - import { Separator } from '$comp/ui/separator'; import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; import { showUpgradeDialog } from '$features/billing/upgrade-required.svelte'; @@ -128,10 +127,8 @@
-

Notifications

- Configure how you receive notifications. + Configure how you receive notifications
- {#if meQuery.isSuccess && (!isEmailAddressVerified || !emailNotificationsEnabled)} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte index 4896fcfc1f..524c400057 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte @@ -1,10 +1,8 @@
-
-

Change Password

- {hasLocalAccount ? 'Change your password.' : 'Set a password to enable password-based sign in.'} -
- -
{ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/sessions/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/sessions/+page.svelte index 08632e98b6..4583f3f421 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/sessions/+page.svelte @@ -10,7 +10,7 @@

Web sessions

- This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize. + This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 988ff6e900..206d7349fb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -1,8 +1,7 @@ - -
- View and manage your projects. Click on a project to view its details - - {#snippet toolbarChildren()} - - - - {/snippet} - -
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index efdb9a7389..df4dd437d8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -3,7 +3,6 @@ import { page } from '$app/state'; import { organization } from '$features/organizations/context.svelte'; import Usage from '@lucide/svelte/icons/bar-chart'; import Billing from '@lucide/svelte/icons/credit-card'; -import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings'; import Users from '@lucide/svelte/icons/users'; import Zap from '@lucide/svelte/icons/zap'; @@ -30,12 +29,6 @@ export function routes(): NavigationItem[] { icon: Usage, title: 'Usage' }, - { - group: 'Organization Settings', - href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), - icon: Folder, - title: 'Projects' - }, { group: 'Organization Settings', href: resolve('/(app)/organization/[organizationId]/users', { organizationId }), @@ -54,43 +47,6 @@ export function routes(): NavigationItem[] { icon: Zap, show: (ctx) => !!ctx.user?.roles?.includes('global'), title: 'Features' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/manage', { organizationId }), - icon: Settings, - title: 'General' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/usage', { organizationId }), - icon: Usage, - title: 'Usage' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), - icon: Folder, - title: 'Projects' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/users', { organizationId }), - icon: Users, - title: 'Users' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/billing', { organizationId }), - icon: Billing, - title: 'Billing' - }, - { - group: 'Settings', - href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), - icon: Zap, - show: (ctx) => !!ctx.user?.roles?.includes('global'), - title: 'Features' } ]; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index 5bebd88af8..9cd12be15f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -59,7 +59,7 @@

Add Organization

- Add a new organization to start tracking errors and events. + Add a new organization to start tracking errors and events
{ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/list/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/list/+page.svelte index c2817cdee9..04aff0dd80 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/list/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/list/+page.svelte @@ -7,6 +7,7 @@ import DataTableViewOptions from '$comp/data-table/data-table-view-options.svelte'; import { H3 } from '$comp/typography'; import { Button } from '$comp/ui/button'; + import { Input } from '$comp/ui/input'; import { type GetOrganizationsParams, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { getTableOptions } from '$features/organizations/components/table/options.svelte'; import OrganizationsDataTable from '$features/organizations/components/table/organizations-data-table.svelte'; @@ -14,8 +15,27 @@ import { useHideOrganizationNotifications } from '$features/organizations/hooks/use-hide-organization-notifications.svelte'; import Plus from '@lucide/svelte/icons/plus'; import { createTable } from '@tanstack/svelte-table'; + import { queryParamsState } from 'kit-query-params'; + + const DEFAULT_PARAMS = { + filter: '' + }; + + const queryParams = queryParamsState({ + default: DEFAULT_PARAMS, + pushHistory: true, + schema: { + filter: 'string' + } + }); const organizationsQueryParameters: GetOrganizationsParams & TableMemoryPagingParameters = $state({ + get filter() { + return queryParams.filter!; + }, + set filter(value) { + queryParams.filter = value; + }, mode: 'stats' }); @@ -48,12 +68,12 @@
-

My Organizations

+

Organizations

{#snippet toolbarChildren()} -
+ + {/snippet} + +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/routes.svelte.ts index 1b12cf17c5..e6971d6177 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/routes.svelte.ts @@ -1,4 +1,5 @@ import { resolve } from '$app/paths'; +import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings'; import type { NavigationItem } from '../../routes.svelte'; @@ -7,6 +8,12 @@ import { routes as projectSettingsRoutes } from './[projectId]/routes.svelte'; export function routes(): NavigationItem[] { return [ + { + group: 'Settings', + href: resolve('/(app)/project/list'), + icon: Folder, + title: 'Projects' + }, { group: 'Projects', href: resolve('/(app)/project/add'), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index 17c33d13ef..b66e82ed90 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -4,7 +4,6 @@ import { apiReferenceHref, documentationHref, githubRepositoryHref, supportIssue import Documentation from '@lucide/svelte/icons/book-open'; import ApiDocumentations from '@lucide/svelte/icons/braces'; import Issues from '@lucide/svelte/icons/bug'; -import EventStream from '@lucide/svelte/icons/calendar-arrow-down'; import Events from '@lucide/svelte/icons/calendar-days'; import Support from '@lucide/svelte/icons/circle-help'; import Sessions from '@lucide/svelte/icons/timer'; @@ -31,12 +30,6 @@ export function routes(): NavigationItem[] { icon: Issues, title: 'Issues' }, - { - group: 'Dashboards', - href: resolve('/(app)/stream'), - icon: EventStream, - title: 'Event Stream' - }, { group: 'Dashboards', href: resolve('/(app)/sessions'), diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 2c8157d86e..31ae0f78b3 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -83,11 +83,19 @@ public OrganizationController( /// /// Get all /// + /// A filter that controls what data is returned from the server. /// If no mode is set then a lightweight organization object will be returned. If the mode is set to stats than the fully populated object will be returned. [HttpGet] - public async Task>> GetAllAsync(string? mode = null) + public async Task>> GetAllAsync(string? filter = null, string? mode = null) { var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); + if (organizations.Count == 0) + return Ok(EmptyModels); + + var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; + organizations = String.IsNullOrWhiteSpace(filter) + ? organizations + : (await _repository.GetByFilterAsync(sf, filter, null, o => o.PageLimit(1000))).Documents; var viewOrganizations = MapToViewModels(organizations); await AfterResultMapAsync(viewOrganizations); diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json index 9d89be4cef..f6ee5af089 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json @@ -24,7 +24,7 @@ }, "@submission_client": { "user_agent": "fluentrest", - "version": "11.0.0.0" + "version": "1.0.0" }, "@environment": { "processor_count": 8, diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 797db2520c..7653a8de78 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -4276,6 +4276,14 @@ ], "summary": "Get all", "parameters": [ + { + "name": "filter", + "in": "query", + "description": "A filter that controls what data is returned from the server.", + "schema": { + "type": "string" + } + }, { "name": "mode", "in": "query", diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 0dc05cce55..d955653fe8 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -1630,6 +1630,7 @@ public async Task PostEvent_WithEnvironmentAndRequestInfo_ReturnsCorrectSnakeCas await SendRequestAsync(r => r .Post() .AsTestOrganizationClientUser() + .UserAgent("fluentrest/1.0.0") .AppendPath("events") .Content(eventJson, "application/json") .StatusCodeShouldBeAccepted() diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 24a7fc1cf4..aeb17aeed2 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -121,6 +121,39 @@ public async Task GetAllAsync_ReturnsViewOrganizationCollection() }); } + [Fact] + public async Task GetAllAsync_WithFilter_ReturnsMatchingViewOrganizations() + { + // Act + var viewOrgs = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("organizations") + .QueryString("filter", "Acme") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewOrgs); + var viewOrg = Assert.Single(viewOrgs); + Assert.Equal("Acme", viewOrg.Name); + } + + [Fact] + public async Task GetAllAsync_WithFilter_ReturnsOnlyAssociatedOrganizations() + { + // Act + var viewOrgs = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("organizations") + .QueryString("filter", "Free") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewOrgs); + Assert.Empty(viewOrgs); + } + [Fact] public async Task PostAsync_NewOrganization_AssignsDefaultPlan() { diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 4d38a8d97f..56d9f37f89 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/Exceptionless.Tests/Utility/AppSendBuilder.cs b/tests/Exceptionless.Tests/Utility/AppSendBuilder.cs index ce61cab2ad..228883e69b 100644 --- a/tests/Exceptionless.Tests/Utility/AppSendBuilder.cs +++ b/tests/Exceptionless.Tests/Utility/AppSendBuilder.cs @@ -83,6 +83,12 @@ public AppSendBuilder AsFreeOrganizationClientUser() return this.BearerToken(SampleDataService.FREE_API_KEY); } + public AppSendBuilder UserAgent(string value) + { + RequestMessage.Headers.UserAgent.ParseAdd(value); + return this; + } + public bool IsAnonymous { get; private set; } public AppSendBuilder AsAnonymousUser() { diff --git a/tests/http/organizations.http b/tests/http/organizations.http index d6e892fb13..e084fc1b58 100644 --- a/tests/http/organizations.http +++ b/tests/http/organizations.http @@ -21,6 +21,10 @@ Content-Type: application/json GET {{apiUrl}}/organizations Authorization: Bearer {{token}} +### Filtered +GET {{apiUrl}}/organizations?filter=Acme +Authorization: Bearer {{token}} + ### By Id GET {{apiUrl}}/organizations/{{organizationId}} Authorization: Bearer {{token}}