Skip to content

Commit 6eac2bd

Browse files
committed
next: WIP - Project Settings Log Level Dropdown
1 parent bbf373c commit 6eac2bd

File tree

4 files changed

+254
-6
lines changed

4 files changed

+254
-6
lines changed

src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { NewProject, UpdateProject, ViewProject } from '$features/projects/models';
1+
import type { ClientConfiguration, NewProject, UpdateProject, ViewProject } from '$features/projects/models';
22
import type { WebSocketMessageValue } from '$features/websockets/models';
33

44
import { accessToken } from '$features/auth/index.svelte';
5+
import { ValueFromBody } from '$features/shared/models';
56
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
67
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';
78

8-
99
export async function invalidateProjectQueries(queryClient: QueryClient, message: WebSocketMessageValue<'ProjectChanged'>) {
1010
const { id, organization_id } = message;
1111
if (id) {
@@ -23,15 +23,27 @@ export async function invalidateProjectQueries(queryClient: QueryClient, message
2323

2424
// TODO: Do we need to scope these all by organization?
2525
export const queryKeys = {
26+
config: (id: string | undefined) => [...queryKeys.id(id), 'config'] as const,
27+
deleteConfig: (id: string | undefined) => [...queryKeys.id(id), 'delete-config'] as const,
2628
deleteProject: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
2729
deletePromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'demote-tab'] as const,
2830
id: (id: string | undefined) => [...queryKeys.type, id] as const,
2931
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
3032
organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const,
33+
postConfig: (id: string | undefined) => [...queryKeys.id(id), 'post-config'] as const,
3134
postPromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'promote-tab'] as const,
3235
resetData: (id: string | undefined) => [...queryKeys.id(id), 'reset-data'] as const,
3336
type: ['Project'] as const
3437
};
38+
export interface DeleteConfigParams {
39+
key: string;
40+
}
41+
42+
export interface DeleteConfigRequest {
43+
route: {
44+
id: string | undefined;
45+
};
46+
}
3547

3648
export interface DeleteProjectRequest {
3749
route: {
@@ -64,6 +76,12 @@ export interface GetOrganizationProjectsRequest {
6476
};
6577
}
6678

79+
export interface GetProjectConfigRequest {
80+
route: {
81+
id: string | undefined;
82+
};
83+
}
84+
6785
export interface GetProjectRequest {
6886
route: {
6987
id: string | undefined;
@@ -72,8 +90,15 @@ export interface GetProjectRequest {
7290

7391
export type GetProjectsMode = 'stats' | null;
7492

75-
export interface GetProjectsParams {
76-
filter?: string;
93+
export interface PostConfigParams {
94+
key: string;
95+
value: string;
96+
}
97+
98+
export interface PostConfigRequest {
99+
route: {
100+
id: string | undefined;
101+
};
77102
}
78103

79104
export interface PostPromotedTabParams {
@@ -85,6 +110,7 @@ export interface PostPromotedTabRequest {
85110
id: string | undefined;
86111
};
87112
}
113+
88114
export interface ResetDataRequest {
89115
route: {
90116
id: string;
@@ -97,7 +123,6 @@ export interface UpdateProjectRequest {
97123
};
98124
}
99125

100-
101126
export function deleteProject(request: DeleteProjectRequest) {
102127
const queryClient = useQueryClient();
103128

@@ -121,6 +146,26 @@ export function deleteProject(request: DeleteProjectRequest) {
121146
}));
122147
}
123148

149+
export function deleteProjectConfig(request: DeleteConfigRequest) {
150+
const queryClient = useQueryClient();
151+
152+
return createMutation<boolean, ProblemDetails, DeleteConfigParams>(() => ({
153+
enabled: () => !!accessToken.current,
154+
mutationFn: async (params: DeleteConfigParams) => {
155+
const client = useFetchClient();
156+
const response = await client.delete(`projects/${request.route.id}/config`, {
157+
params: { key: params.key }
158+
});
159+
160+
return response.ok;
161+
},
162+
mutationKey: queryKeys.deleteConfig(request.route.id),
163+
onSuccess: () => {
164+
queryClient.invalidateQueries({ queryKey: queryKeys.config(request.route.id) });
165+
}
166+
}));
167+
}
168+
124169
export function deletePromotedTab(request: DeletePromotedTabRequest) {
125170
const queryClient = useQueryClient();
126171
return createMutation<boolean, ProblemDetails, DeletePromotedTabParams>(() => ({
@@ -176,6 +221,21 @@ export function getOrganizationProjectsQuery(request: GetOrganizationProjectsReq
176221
}));
177222
}
178223

224+
export function getProjectConfig(request: GetProjectConfigRequest) {
225+
return createQuery<ClientConfiguration, ProblemDetails>(() => ({
226+
enabled: () => !!accessToken.current && !!request.route.id,
227+
queryFn: async ({ signal }: { signal: AbortSignal }) => {
228+
const client = useFetchClient();
229+
const response = await client.getJSON<ClientConfiguration>(`projects/${request.route.id}/config`, {
230+
signal
231+
});
232+
233+
return response.data!;
234+
},
235+
queryKey: queryKeys.config(request.route.id)
236+
}));
237+
}
238+
179239
export function getProjectQuery(request: GetProjectRequest) {
180240
return createQuery<ViewProject, ProblemDetails>(() => ({
181241
enabled: () => !!accessToken.current && !!request.route.id,
@@ -190,6 +250,7 @@ export function getProjectQuery(request: GetProjectRequest) {
190250
queryKey: queryKeys.id(request.route.id)
191251
}));
192252
}
253+
193254
export function postProject() {
194255
const queryClient = useQueryClient();
195256

@@ -206,6 +267,26 @@ export function postProject() {
206267
}));
207268
}
208269

270+
export function postProjectConfig(request: PostConfigRequest) {
271+
const queryClient = useQueryClient();
272+
273+
return createMutation<boolean, ProblemDetails, PostConfigParams>(() => ({
274+
enabled: () => !!accessToken.current,
275+
mutationFn: async (params: PostConfigParams) => {
276+
const client = useFetchClient();
277+
const response = await client.post(`projects/${request.route.id}/config`, new ValueFromBody(params.value), {
278+
params: { key: params.key }
279+
});
280+
281+
return response.ok;
282+
},
283+
mutationKey: queryKeys.postConfig(request.route.id),
284+
onSuccess: () => {
285+
queryClient.invalidateQueries({ queryKey: queryKeys.config(request.route.id) });
286+
}
287+
}));
288+
}
289+
209290
export function postPromotedTab(request: PostPromotedTabRequest) {
210291
const queryClient = useQueryClient();
211292
return createMutation<boolean, ProblemDetails, PostPromotedTabParams>(() => ({
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<script lang="ts">
2+
import * as DropdownMenu from '$comp/ui/dropdown-menu';
3+
import { Skeleton } from '$comp/ui/skeleton';
4+
import { getLogLevel, type LogLevel } from '$features/events/models/event-data';
5+
import { logLevels } from '$features/events/options';
6+
import { deleteProjectConfig, getProjectConfig, postProjectConfig } from '$features/projects/api.svelte';
7+
import { Button } from '$features/shared/components/ui/button';
8+
import ChevronDown from 'lucide-svelte/icons/chevron-down';
9+
import { toast } from 'svelte-sonner';
10+
11+
interface Props {
12+
projectId: string;
13+
source: string;
14+
}
15+
16+
let { projectId, source }: Props = $props();
17+
const projectConfigResponse = getProjectConfig({
18+
route: {
19+
get id() {
20+
return projectId;
21+
}
22+
}
23+
});
24+
25+
const updateProjectConfig = postProjectConfig({
26+
route: {
27+
get id() {
28+
return projectId;
29+
}
30+
}
31+
});
32+
33+
const removeProjectConfig = deleteProjectConfig({
34+
route: {
35+
get id() {
36+
return projectId;
37+
}
38+
}
39+
});
40+
41+
async function setLogLevel(level: LogLevel) {
42+
await updateProjectConfig.mutateAsync({
43+
key: `@@log:${source}`,
44+
value: level
45+
});
46+
47+
toast.success(`Successfully updated Log level to ${level}`);
48+
}
49+
50+
async function revertToDefaultLogLevel() {
51+
removeProjectConfig.mutateAsync({
52+
key: `@@log:${source}`
53+
});
54+
toast.success(`Successfully reverted to default (${defaultLevel}) log level`);
55+
}
56+
57+
const configSettings = $derived(projectConfigResponse.data?.settings ?? {});
58+
const level = $derived(getLogLevel(configSettings[`@@log:${source ?? ''}`]));
59+
const defaultLevel = $derived(getDefaultLogLevel(configSettings, source ?? ''));
60+
61+
function getDefaultLogLevel(configSettings: Record<string, string>, source: string): LogLevel | null {
62+
const sourcePrefix = '@@log:';
63+
64+
// sort object keys longest first, then alphabetically.
65+
const sortedKeys = Object.keys(configSettings).sort(function (a, b) {
66+
return b.length - a.length || a.localeCompare(b);
67+
});
68+
69+
for (const index in sortedKeys) {
70+
const key = sortedKeys[index];
71+
if (!key) {
72+
continue;
73+
}
74+
75+
if (!key.toLowerCase().startsWith(sourcePrefix)) {
76+
continue;
77+
}
78+
79+
const cleanKey = key.substring(sourcePrefix.length);
80+
if (cleanKey.toLowerCase() === source.toLowerCase()) {
81+
continue;
82+
}
83+
84+
// check for wildcard match
85+
if (isMatch(source, [cleanKey])) {
86+
return getLogLevel(configSettings[key]);
87+
}
88+
}
89+
90+
return null;
91+
}
92+
93+
// TODO: Move to string utils
94+
function isMatch(input: string, patterns: string[], ignoreCase = true): boolean {
95+
const trimmedInput = ignoreCase ? input.toLowerCase().trim() : input.trim();
96+
97+
return (patterns || []).some((pattern) => {
98+
let trimmedPattern = ignoreCase ? pattern.toLowerCase().trim() : pattern.trim();
99+
if (trimmedPattern.length <= 0) {
100+
return false;
101+
}
102+
103+
const startsWithWildcard = trimmedPattern[0] === '*';
104+
if (startsWithWildcard) {
105+
trimmedPattern = trimmedPattern.slice(1);
106+
}
107+
108+
const endsWithWildcard = trimmedPattern[trimmedPattern.length - 1] === '*';
109+
if (endsWithWildcard) {
110+
trimmedPattern = trimmedPattern.substring(0, trimmedPattern.length - 1);
111+
}
112+
113+
if (startsWithWildcard && endsWithWildcard) {
114+
return trimmedPattern.length <= trimmedInput.length && trimmedInput.indexOf(trimmedPattern) !== -1;
115+
}
116+
117+
if (startsWithWildcard) {
118+
return trimmedInput.endsWith(trimmedPattern);
119+
}
120+
121+
if (endsWithWildcard) {
122+
return trimmedInput.startsWith(trimmedPattern);
123+
}
124+
125+
return trimmedInput === trimmedPattern;
126+
});
127+
}
128+
</script>
129+
130+
{#if projectConfigResponse.isSuccess}
131+
<DropdownMenu.Root>
132+
<DropdownMenu.Trigger>
133+
<Button variant="outline">
134+
Log Level:
135+
{#if level}
136+
{level}
137+
{:else}
138+
{defaultLevel} (Default)
139+
{/if}
140+
<ChevronDown class="size-4" />
141+
</Button>
142+
</DropdownMenu.Trigger>
143+
<DropdownMenu.Content>
144+
<DropdownMenu.Group>
145+
<DropdownMenu.GroupHeading>Log Level</DropdownMenu.GroupHeading>
146+
<DropdownMenu.Separator />
147+
148+
{#each logLevels as level (level.value)}
149+
<DropdownMenu.Item
150+
title={`Update Log Level to ${level.label}`}
151+
onclick={() => setLogLevel(level.value)}
152+
disabled={updateProjectConfig.isPending}>{level.label}</DropdownMenu.Item
153+
>
154+
{/each}
155+
{#if level && source !== '*'}
156+
<DropdownMenu.Separator />
157+
<DropdownMenu.Item title={`Reset to default (${defaultLevel})`} onclick={revertToDefaultLogLevel} disabled={removeProjectConfig.isPending}
158+
>Default ({defaultLevel})</DropdownMenu.Item
159+
>
160+
{/if}
161+
</DropdownMenu.Group>
162+
</DropdownMenu.Content>
163+
</DropdownMenu.Root>
164+
{:else}
165+
<Skeleton class="h-[36px] w-[135px]" />
166+
{/if}

src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { NewProject, ViewProject } from '$generated/api';
1+
export { ClientConfiguration, NewProject, ViewProject } from '$generated/api';
22

33
import { IsBoolean, IsString } from 'class-validator';
44

src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
}
6565
});
6666
67+
// TODO: Log Level
6768
const stack = $derived(stackResponse.data!);
6869
const eventOccurrences = $derived(sum(stackCountResponse?.data?.aggregations, 'sum_count')?.value ?? 0);
6970
const totalOccurrences = $derived(stack && stack.total_occurrences > eventOccurrences ? stack.total_occurrences : eventOccurrences);

0 commit comments

Comments
 (0)