Skip to content

Commit 2cac556

Browse files
authored
Feature/charts (#1906)
* WIP: Project usage + charts. * Tweaked usage page * next: updated deps * next: updated shad and fixed errors * Fixed sheet css * Fixed stack card title overflow * updated charts * linting
1 parent 3fc651b commit 2cac556

File tree

21 files changed

+809
-556
lines changed

21 files changed

+809
-556
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"haserror",
3131
"iconify",
3232
"keyof",
33+
"layerchart",
3334
"LDAP",
3435
"legos",
3536
"lucene",

src/Exceptionless.Web/ClientApp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ You can preview the production build with `npm run preview`.
2626
You can upgrade [shadcn-svelte components](https://www.shadcn-svelte.com/) by running the following command
2727

2828
```bash
29-
npx shadcn-svelte@next update
29+
npx shadcn-svelte@latest update
3030
```

src/Exceptionless.Web/ClientApp/package-lock.json

Lines changed: 445 additions & 485 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Exceptionless.Web/ClientApp/package.json

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,69 +26,72 @@
2626
},
2727
"devDependencies": {
2828
"@chromatic-com/storybook": "^4.0.0",
29-
"@eslint/compat": "^1.2.9",
30-
"@eslint/js": "^9.28.0",
31-
"@iconify-json/lucide": "^1.2.47",
32-
"@playwright/test": "^1.52.0",
33-
"@storybook/addon-a11y": "^9.0.6",
34-
"@storybook/addon-docs": "^9.0.6",
29+
"@eslint/compat": "^1.3.0",
30+
"@eslint/js": "^9.29.0",
31+
"@iconify-json/lucide": "^1.2.48",
32+
"@playwright/test": "^1.53.0",
33+
"@storybook/addon-a11y": "^9.0.9",
34+
"@storybook/addon-docs": "^9.0.9",
3535
"@storybook/addon-svelte-csf": "^5.0.3",
36-
"@storybook/sveltekit": "^9.0.6",
36+
"@storybook/sveltekit": "^9.0.9",
3737
"@sveltejs/adapter-static": "^3.0.8",
38-
"@sveltejs/kit": "^2.21.2",
38+
"@sveltejs/kit": "^2.21.5",
3939
"@sveltejs/vite-plugin-svelte": "^5.1.0",
40-
"@tailwindcss/vite": "^4.1.8",
40+
"@tailwindcss/vite": "^4.1.10",
4141
"@testing-library/jest-dom": "^6.6.3",
4242
"@testing-library/svelte": "^5.2.8",
4343
"@types/eslint": "^9.6.1",
44-
"@types/node": "^22.15.30",
44+
"@types/node": "^24.0.1",
4545
"@types/throttle-debounce": "^5.0.2",
4646
"cross-env": "^7.0.3",
47-
"eslint": "^9.28.0",
47+
"eslint": "^9.29.0",
4848
"eslint-config-prettier": "^10.1.5",
4949
"eslint-plugin-perfectionist": "^4.14.0",
50-
"eslint-plugin-storybook": "^9.0.6",
51-
"eslint-plugin-svelte": "^3.9.1",
50+
"eslint-plugin-storybook": "^9.0.9",
51+
"eslint-plugin-svelte": "^3.9.2",
5252
"jsdom": "^26.1.0",
5353
"prettier": "^3.5.3",
5454
"prettier-plugin-svelte": "^3.4.0",
5555
"prettier-plugin-tailwindcss": "^0.6.12",
56-
"storybook": "^9.0.6",
57-
"svelte": "^5.33.18",
56+
"storybook": "^9.0.9",
57+
"svelte": "^5.34.1",
5858
"svelte-check": "^4.2.1",
5959
"swagger-typescript-api": "^13.2.1",
6060
"tslib": "^2.8.1",
6161
"typescript": "^5.8.3",
62-
"typescript-eslint": "^8.33.1",
62+
"typescript-eslint": "^8.34.0",
6363
"vite": "^6.3.5",
64-
"vitest": "3.2.2"
64+
"vitest": "3.2.3"
6565
},
6666
"dependencies": {
6767
"@exceptionless/browser": "^3.1.0",
6868
"@exceptionless/fetchclient": "^0.42.0",
69-
"@lucide/svelte": "^0.513.0",
69+
"@lucide/svelte": "^0.515.0",
7070
"@tanstack/svelte-query": "https://pkg.pr.new/@tanstack/svelte-query@8c9ce9",
7171
"@tanstack/svelte-query-devtools": "https://pkg.pr.new/@tanstack/svelte-query-devtools@8c9ce9",
7272
"@tanstack/svelte-table": "^9.0.0-alpha.10",
73+
"@types/d3-scale": "^4.0.9",
74+
"@types/d3-shape": "^3.1.7",
7375
"@typeschema/class-validator": "^0.3.0",
74-
"bits-ui": "^2.5.0",
76+
"bits-ui": "^2.7.0",
7577
"class-validator": "^0.14.2",
7678
"clsx": "^2.1.1",
79+
"d3-scale": "^4.0.2",
7780
"dompurify": "^3.2.6",
7881
"formsnap": "^2.0.1",
7982
"kit-query-params": "^0.0.26",
80-
"layerchart": "^2.0.0-next.17",
81-
"mode-watcher": "^1.0.7",
83+
"layerchart": "^2.0.0-next.21",
84+
"mode-watcher": "^1.0.8",
8285
"oidc-client-ts": "^3.2.1",
8386
"pretty-ms": "^9.2.0",
8487
"runed": "^0.28.0",
8588
"shiki": "^3.6.0",
86-
"svelte-sonner": "^1.0.4",
89+
"svelte-sonner": "^1.0.5",
8790
"svelte-time": "^2.0.1",
8891
"sveltekit-superforms": "^2.26.1",
89-
"tailwind-merge": "^3.3.0",
92+
"tailwind-merge": "^3.3.1",
9093
"tailwind-variants": "^1.0.0",
91-
"tailwindcss": "^4.1.8",
94+
"tailwindcss": "^4.1.10",
9295
"throttle-debounce": "^5.0.2",
9396
"tw-animate-css": "^1.3.4"
9497
},

src/Exceptionless.Web/ClientApp/src/app.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@
6262
--sidebar-accent-foreground: var(--accent-foreground);
6363
--sidebar-border: var(--border);
6464
--sidebar-ring: var(--ring);
65+
66+
--chart-1: #7bb662; /* Total (green, light) */
67+
--chart-2: #56b4e9; /* Blocked (blue, light) */
68+
--chart-3: #d47a00; /* Discarded – hsl(32 100% 42%) */
69+
--chart-4: #ffd64d; /* Too Big – hsl(46 100% 65%) */
70+
--chart-5: #d9d9d9; /* Total in Organization (magenta, light) */
71+
--chart-6: #c62828; /* material-red-700: deep red for light mode */
6572
}
6673

6774
.dark {
@@ -102,6 +109,13 @@
102109
--sidebar-accent-foreground: var(--accent-foreground);
103110
--sidebar-border: var(--border);
104111
--sidebar-ring: var(--ring);
112+
113+
--chart-1: #a4d56f; /* Total (green, dark) */
114+
--chart-2: #8fdbff; /* Blocked (blue, dark) */
115+
--chart-3: #ff9e3d; /* Discarded – hsl(30 100% 62%) */
116+
--chart-4: #ffea70; /* Too Big – hsl(48 100% 70%) */
117+
--chart-5: #5a5a5a; /* Total in Organization (magenta, dark) */
118+
--chart-6: #ff5c5c; /* hsl(0 100% 66%): bright red for dark mode */
105119
}
106120

107121
@theme inline {

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type { ViewOrganization } from './models';
1010
export async function invalidateOrganizationQueries(queryClient: QueryClient, message: WebSocketMessageValue<'OrganizationChanged'>) {
1111
const { id } = message;
1212
if (id) {
13-
await queryClient.invalidateQueries({ queryKey: queryKeys.id(id) });
13+
await queryClient.invalidateQueries({ queryKey: queryKeys.id(id, undefined) });
14+
await queryClient.invalidateQueries({ queryKey: queryKeys.id(id, 'stats') });
1415

1516
// Invalidate regardless of mode
1617
await queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) });
@@ -20,25 +21,63 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me
2021
}
2122

2223
export const queryKeys = {
23-
id: (id: string | undefined) => [...queryKeys.type, id] as const,
24+
id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)),
2425
list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)),
2526
type: ['Organization'] as const
2627
};
2728

29+
export interface GetOrganizationRequest {
30+
params?: {
31+
mode: 'stats' | undefined;
32+
};
33+
route: {
34+
id: string | undefined;
35+
};
36+
}
37+
2838
export interface GetOrganizationsRequest {
2939
params?: {
30-
mode: 'stats' | null;
40+
mode: 'stats' | undefined;
3141
};
3242
}
3343

34-
export function getOrganizationQuery(request: GetOrganizationsRequest) {
44+
export function getOrganizationQuery(request: GetOrganizationRequest) {
45+
const queryClient = useQueryClient();
46+
47+
return createQuery<ViewOrganization, ProblemDetails>(() => ({
48+
enabled: () => !!accessToken.current && !!request.route.id,
49+
onSuccess: (data: ViewOrganization) => {
50+
if (request.params?.mode) {
51+
queryClient.setQueryData(queryKeys.id(request.route.id, request.params.mode), data);
52+
}
53+
54+
queryClient.setQueryData(queryKeys.id(request.route.id!, undefined), data);
55+
},
56+
queryClient,
57+
queryFn: async ({ signal }: { signal: AbortSignal }) => {
58+
const client = useFetchClient();
59+
const response = await client.getJSON<ViewOrganization>(`organizations/${request.route.id}`, {
60+
signal
61+
});
62+
63+
return response.data!;
64+
},
65+
queryKey: queryKeys.id(request.route.id, request.params?.mode)
66+
}));
67+
}
68+
69+
export function getOrganizationsQuery(request: GetOrganizationsRequest) {
3570
const queryClient = useQueryClient();
3671

3772
return createQuery<ViewOrganization[], ProblemDetails>(() => ({
3873
enabled: () => !!accessToken.current,
3974
onSuccess: (data: ViewOrganization[]) => {
4075
data.forEach((organization) => {
41-
queryClient.setQueryData(queryKeys.id(organization.id!), organization);
76+
if (request.params?.mode) {
77+
queryClient.setQueryData(queryKeys.id(organization.id!, request.params.mode), organization);
78+
}
79+
80+
queryClient.setQueryData(queryKeys.id(organization.id!, undefined), organization);
4281
});
4382
},
4483
queryClient,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { isSameUtcMonth } from '$features/shared/dates';
2+
3+
import type { ViewOrganization } from './models';
4+
5+
export function getNextBillingDateUtc(organization?: ViewOrganization): Date {
6+
if (organization?.subscribe_date) {
7+
console.log('Organization subscribe date for next billing date:', organization.subscribe_date);
8+
}
9+
10+
const now = new Date();
11+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
12+
}
13+
14+
export function getRemainingEventLimit(organization?: ViewOrganization): number {
15+
if (!organization?.max_events_per_month) {
16+
return 0;
17+
}
18+
19+
const now = new Date();
20+
const bonusEvents = organization.bonus_expiration && new Date(organization.bonus_expiration) > now ? organization.bonus_events_per_month : 0;
21+
22+
const usage = organization.usage && organization.usage[organization.usage.length - 1];
23+
if (usage) {
24+
const usageDate = new Date(usage.date);
25+
if (isSameUtcMonth(usageDate, now)) {
26+
const remaining = usage.limit - usage.total;
27+
return remaining > 0 ? remaining : 0;
28+
}
29+
}
30+
31+
return organization.max_events_per_month + bonusEvents;
32+
}

src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/typography/code-block.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
let theme = $derived(mode.current === 'light' ? 'github-light' : 'github-dark');
2727
const jsEngine = createJavaScriptRegexEngine();
2828
29+
// TODO: https://shiki.style/guide/dual-themes
2930
const highlighter = createHighlighterCoreSync({
3031
engine: jsEngine,
3132
langs: [csharpLanguage, javaScriptLanguage, jsonLanguage, powershellLanguage, shellScriptLanguage, xmlLanguage],

src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-style.svelte

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,30 @@
77
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
88
);
99
10-
const styleOpen = ">elyts<".split("").reverse().join("");
11-
const styleClose = ">elyts/<".split("").reverse().join("");
12-
</script>
10+
const themeContents = $derived.by(() => {
11+
if (!colorConfig || !colorConfig.length) return;
12+
13+
const themeContents = [];
14+
for (let [_theme, prefix] of Object.entries(THEMES)) {
15+
let content = `${prefix} [data-chart=${id}] {\n`;
16+
const color = colorConfig.map(([key, itemConfig]) => {
17+
const theme = _theme as keyof typeof itemConfig.theme;
18+
const color = itemConfig.theme?.[theme] || itemConfig.color;
19+
return color ? `\t--color-${key}: ${color};` : null;
20+
});
21+
22+
content += color.join("\n") + "\n}";
1323
14-
{#if colorConfig && colorConfig.length}
15-
{@const themeContents = Object.entries(THEMES)
16-
.map(
17-
([theme, prefix]) => `
18-
${prefix} [data-chart=${id}] {
19-
${colorConfig
20-
.map(([key, itemConfig]) => {
21-
const color =
22-
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
23-
return color ? ` --color-${key}: ${color};` : null;
24-
})
25-
.join("\n")}
26-
}
27-
`
28-
)
29-
.join("\n")}
24+
themeContents.push(content);
25+
}
26+
27+
return themeContents.join("\n");
28+
});
29+
</script>
3030

31+
{#if themeContents}
3132
{#key id}
3233
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
33-
{@html `${styleOpen}
34-
${themeContents}
35-
${styleClose}`}
34+
{@html `<style>${themeContents}</style>`}
3635
{/key}
3736
{/if}

src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/chart/chart-tooltip.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@
5656
const [item] = tooltipCtx.payload;
5757
const key = labelKey || item?.label || item?.name || "value";
5858
59-
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
59+
const itemConfig = item ? getPayloadConfigFromPayload(chart.config, item, key) : undefined;
6060
6161
const value =
6262
!labelKey && typeof label === "string"
6363
? chart.config[label as keyof typeof chart.config]?.label || label
64-
: (itemConfig?.label ?? item.label);
64+
: (itemConfig?.label ?? item?.label);
6565
6666
if (!value) return null;
6767
if (!labelFormatter) return value;

src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar-separator.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
bind:ref
1515
data-slot="sidebar-separator"
1616
data-sidebar="separator"
17-
class={cn("bg-sidebar-border mx-2 w-auto", className)}
17+
class={cn("bg-sidebar-border", className)}
1818
{...restProps}
1919
/>

src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
export function formatDateLabel(value: Date): string {
2+
return value.toLocaleDateString(undefined, {
3+
day: 'numeric',
4+
month: 'long',
5+
year: 'numeric'
6+
});
7+
}
8+
9+
export function formatLongDate(value: Date): string {
10+
return value.toLocaleDateString(undefined, {
11+
day: 'numeric',
12+
month: 'long',
13+
year: 'numeric'
14+
});
15+
}
16+
117
export function getDifferenceInSeconds(value: Date | string): number {
218
return (new Date().getTime() - new Date(value).getTime()) / 1000;
319
}
@@ -44,3 +60,7 @@ export function getSetIntervalTime(value: Date | string): number {
4460
return day * 1000; // update every day
4561
}
4662
}
63+
64+
export function isSameUtcMonth(date: Date, other: Date = new Date()): boolean {
65+
return date.getUTCFullYear() === other.getUTCFullYear() && date.getUTCMonth() === other.getUTCMonth();
66+
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,13 @@
7878
<Card.Root>
7979
<Card.Header>
8080
<Card.Title class="flex flex-row items-center justify-between text-lg font-semibold">
81-
<span class="mb-2 flex flex-col lg:mb-0">
82-
<div class="flex items-center">
83-
<EventsFacetedFilter.StringTrigger changed={filterChanged} class="mr-2" term="stack" value={stack.id} />
84-
<span class="truncate">{stack.title}</span>
81+
<div class="mb-2 flex w-0 min-w-0 flex-1 flex-col lg:mb-0">
82+
<div class="flex min-w-0 items-center">
83+
<EventsFacetedFilter.StringTrigger changed={filterChanged} class="mr-2 shrink-0" term="stack" value={stack.id} />
84+
<span class="block max-w-full min-w-0 truncate" title={stack.title}>{stack.title}</span>
8585
</div>
86-
</span>
87-
<div class="flex items-center space-x-2">
86+
</div>
87+
<div class="ml-2 flex shrink-0 items-center space-x-2">
8888
<StackStatusDropdownMenu {stack} />
8989
<StackOptionsDropdownMenu {stack} />
9090
</div>

0 commit comments

Comments
 (0)