diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 228f6aeb2f..e6c4f79ba1 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -14,6 +14,7 @@ Located in the `src/Exceptionless.Web/ClientApp` directory. - When there is a linting error, always try to run `npm run format` first. - Limit use of $effect as there is usually a better way to solve the problem like using $derived. - **Do NOT** use any server-side Svelte features. +- Always use the chrome mcp to verify visual and functional correctness, default to using the new site /next path. ## Architecture & Components diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md index 96e4f5e3af..5adacdb675 100644 --- a/.github/instructions/general.instructions.md +++ b/.github/instructions/general.instructions.md @@ -8,7 +8,7 @@ applyTo: "**" You are a distinguished engineer and are expected to deliver high-quality code that adheres to the guidelines below. All contributions must respect existing formatting and conventions specified in the `.editorconfig` file. -use context7 for documentation +Use context7 when I need code generation, setup or configuration steps, or library/api documentation. ## Code Style & Minimal Diffs diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs index c13d077e69..a1e9cb4e3a 100644 --- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs @@ -51,7 +51,7 @@ public Task> GetByCriteriaAsync(string? criteria, Comm { var filter = Query.MatchAll(); if (!String.IsNullOrWhiteSpace(criteria)) - filter &= Query.Term(o => o.Name, criteria); + filter &= (Query.Term(o => o.Id, criteria) || Query.Term(o => o.Name, criteria)); if (paid.HasValue) { diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index d2c6359fab..2198a11515 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -1,5 +1,6 @@ import { includeIgnoreFile } from '@eslint/compat'; import js from '@eslint/js'; +import pluginQuery from '@tanstack/eslint-plugin-query'; import prettier from 'eslint-config-prettier'; import perfectionist from 'eslint-plugin-perfectionist'; // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format @@ -17,6 +18,7 @@ export default ts.config( ...svelte.configs['flat/recommended'], perfectionist.configs['recommended-natural'], prettier, + ...pluginQuery.configs['flat/recommended'], ...svelte.configs['flat/prettier'], { languageOptions: { @@ -37,6 +39,11 @@ export default ts.config( { ignores: ['build/', '.svelte-kit/', 'dist/', 'src/lib/generated/api.ts', 'src/lib/features/shared/components/ui/'] }, + { + rules: { + '@tanstack/query/exhaustive-deps': 'off' + } + }, { rules: { 'svelte/no-navigation-without-resolve': 'off' diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 941d72aafc..7ae48c11b6 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -11,73 +11,76 @@ "@exceptionless/browser": "^3.1.0", "@exceptionless/fetchclient": "^0.44.0", "@internationalized/date": "^3.10.0", - "@lucide/svelte": "^0.548.0", - "@tanstack/svelte-query": "^6.0.3", - "@tanstack/svelte-query-devtools": "^6.0.0", + "@lucide/svelte": "^0.556.0", + "@tanstack/svelte-query": "^6.0.10", + "@tanstack/svelte-query-devtools": "^6.0.2", "@tanstack/svelte-table": "^9.0.0-alpha.10", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.7", "@typeschema/class-validator": "^0.3.0", - "bits-ui": "^2.14.1", - "class-validator": "^0.14.2", + "bits-ui": "^2.14.4", + "class-validator": "^0.14.3", "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.3.0", "formsnap": "^2.0.1", "kit-query-params": "^0.0.26", - "layerchart": "^2.0.0-next.40", + "layerchart": "^2.0.0-next.43", "mode-watcher": "^1.1.0", - "oidc-client-ts": "^3.3.0", + "oidc-client-ts": "^3.4.1", "pretty-ms": "^9.3.0", - "runed": "^0.35.1", - "shiki": "^3.14.0", - "svelte-sonner": "^1.0.5", + "runed": "^0.37.0", + "shiki": "^3.19.0", + "svelte-sonner": "^1.0.7", "svelte-time": "^2.0.2", - "sveltekit-superforms": "^2.28.0", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^3.1.1", - "tailwindcss": "^4.1.16", + "sveltekit-superforms": "^2.28.1", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.1.17", "throttle-debounce": "^5.0.2", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@chromatic-com/storybook": "^4.1.1", - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.38.0", - "@iconify-json/lucide": "^1.2.71", - "@playwright/test": "^1.56.1", - "@storybook/addon-a11y": "^10.0.0", - "@storybook/addon-docs": "^10.0.0", + "@chromatic-com/storybook": "^4.1.3", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@iconify-json/lucide": "^1.2.79", + "@playwright/test": "^1.57.0", + "@storybook/addon-a11y": "^10.1.4", + "@storybook/addon-docs": "^10.1.4", "@storybook/addon-svelte-csf": "^5.0.10", - "@storybook/sveltekit": "^10.0.0", + "@storybook/sveltekit": "^10.1.4", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.48.2", + "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/vite": "^4.1.16", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/eslint-plugin-query": "^5.91.2", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/svelte": "^5.2.8", + "@testing-library/svelte": "^5.2.9", "@types/eslint": "^9.6.1", - "@types/node": "^24.9.2", + "@types/node": "^24.10.1", "@types/throttle-debounce": "^5.0.2", "cross-env": "^10.1.0", - "eslint": "^9.38.0", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^4.15.1", - "eslint-plugin-storybook": "^10.0.0", - "eslint-plugin-svelte": "^3.12.5", - "jsdom": "^27.0.1", - "prettier": "^3.6.2", + "eslint-plugin-storybook": "^10.1.4", + "eslint-plugin-svelte": "^3.13.1", + "jsdom": "^27.2.0", + "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.7.1", - "storybook": "^10.0.0", - "svelte": "^5.43.0", - "svelte-check": "^4.3.3", + "prettier-plugin-tailwindcss": "^0.7.2", + "storybook": "^10.1.4", + "svelte": "^5.45.6", + "svelte-check": "^4.3.4", "swagger-typescript-api": "^13.2.16", "tslib": "^2.8.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.2", - "vite": "^7.1.12", - "vitest": "4.0.4" + "typescript-eslint": "^8.48.1", + "vite": "^7.2.6", + "vitest": "4.0.15", + "vitest-websocket-mock": "^0.5.0", + "zod": "^4.1.13" } }, "node_modules/@acemir/cssom": { @@ -851,16 +854,16 @@ } }, "node_modules/@eslint/compat": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", - "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.0.tgz", + "integrity": "sha512-T9AfE1G1uv4wwq94ozgTGio5EUQBqAVe1X9qsQtSNVEYW6j3hvtZVm8Smr4qL1qDPFg+lOB2cL5RxTRMzq4CTA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { "eslint": "^8.40 || 9" @@ -871,6 +874,19 @@ } } }, + "node_modules/@eslint/compat/node_modules/@eslint/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.0.0.tgz", + "integrity": "sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", @@ -1139,9 +1155,9 @@ } }, "node_modules/@iconify-json/lucide": { - "version": "1.2.78", - "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.78.tgz", - "integrity": "sha512-TqIzEzBCjs1IOUre/NBKhg29DkL6+Vqh93SD9V189TwIEl5Kl2dBSL7OZ0pjjF1ru8HQ1bllBo/oS0YYVUTPgA==", + "version": "1.2.79", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.79.tgz", + "integrity": "sha512-CcwoXfC2Y7UVW0PXopmXtB4Do/eUJkhAqQqOnVENEiw3FwU707TK4uyIUqdo9tlvBaFBl95wnJf3smqsTnSyKA==", "dev": true, "license": "ISC", "dependencies": { @@ -1255,9 +1271,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "0.548.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.548.0.tgz", - "integrity": "sha512-Iwh5GXK8+tE1lBjYoBPfOhBiWv6/K/XinZ/Bjx/2Qys86ufZ/CbjHKacYyZeGnyWQSsjcr7P3xavkmCMhv/1RA==", + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.556.0.tgz", + "integrity": "sha512-jsJhyyd2xrJ4WtflO5TrTtyCBacF4OLBNCzotvwh4zfbbuE6aqdblx+OB0zg6zOno06ib1GJGdDcfR/2TUcb7g==", "license": "ISC", "peerDependencies": { "svelte": "^5" @@ -2302,6 +2318,23 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", + "integrity": "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.44.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.12", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", @@ -3038,13 +3071,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.4.tgz", - "integrity": "sha512-99EDqiCkncCmvIZj3qJXBZbyoQ35ghOwVWNnQ5nj0Hnsv4Qm40HmrMJrceewjLVvsxV/JSU4qyx2CGcfMBmXJw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.4", + "@vitest/utils": "4.0.15", "pathe": "^2.0.3" }, "funding": { @@ -3052,9 +3085,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.4.tgz", - "integrity": "sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", "dev": true, "license": "MIT", "dependencies": { @@ -3065,13 +3098,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.4.tgz", - "integrity": "sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.4", + "@vitest/pretty-format": "4.0.15", "tinyrainbow": "^3.0.3" }, "funding": { @@ -3089,14 +3122,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.4.tgz", - "integrity": "sha512-XICqf5Gi4648FGoBIeRgnHWSNDp+7R5tpclGosFaUUFzY6SfcpsfHNMnC7oDu/iOLBxYfxVzaQpylEvpgii3zw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.4", - "magic-string": "^0.30.19", + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -3104,9 +3137,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.4.tgz", - "integrity": "sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", "dev": true, "license": "MIT", "dependencies": { @@ -3350,6 +3383,30 @@ "svelte": "^5.33.0" } }, + "node_modules/bits-ui/node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5984,6 +6041,16 @@ "node": "*" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/mode-watcher": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", @@ -6320,6 +6387,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7104,9 +7182,9 @@ } }, "node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.37.0.tgz", + "integrity": "sha512-zphHjvLZEpcJiV3jezT96SnNwePaUIEd1HEMuPGZ6DwOMao9S2ZAUCYJPKquRM5J22AwAOpGj0KmxOkQdkBfwQ==", "funding": [ "https://github.com/sponsors/huntabyte", "https://github.com/sponsors/tglide" @@ -7119,11 +7197,15 @@ }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" + "svelte": "^5.7.0", + "zod": "^4.1.0" }, "peerDependenciesMeta": { "@sveltejs/kit": { "optional": true + }, + "zod": { + "optional": true } } }, @@ -7581,9 +7663,9 @@ } }, "node_modules/svelte": { - "version": "5.45.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.5.tgz", - "integrity": "sha512-2074U+vObO5Zs8/qhxtBwdi6ZXNIhEBTzNmUFjiZexLxTdt9vq96D/0pnQELl6YcpLMD7pZ2dhXKByfGS8SAdg==", + "version": "5.45.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz", + "integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -7762,6 +7844,30 @@ "svelte": "^5.30.2" } }, + "node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/svelte/node_modules/esrap": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", @@ -9057,28 +9163,28 @@ } }, "node_modules/vitest": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.4.tgz", - "integrity": "sha512-hV31h0/bGbtmDQc0KqaxsTO1v4ZQeF8ojDFuy4sZhFadwAqqvJA0LDw68QUocctI5EDpFMql/jVWKuPYHIf2Ew==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.4", - "@vitest/mocker": "4.0.4", - "@vitest/pretty-format": "4.0.4", - "@vitest/runner": "4.0.4", - "@vitest/snapshot": "4.0.4", - "@vitest/spy": "4.0.4", - "@vitest/utils": "4.0.4", - "debug": "^4.4.3", + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", - "magic-string": "^0.30.19", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.9.0", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", + "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", @@ -9095,12 +9201,12 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", + "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.4", - "@vitest/browser-preview": "4.0.4", - "@vitest/browser-webdriverio": "4.0.4", - "@vitest/ui": "4.0.4", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", "happy-dom": "*", "jsdom": "*" }, @@ -9108,7 +9214,7 @@ "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { @@ -9134,18 +9240,32 @@ } } }, + "node_modules/vitest-websocket-mock": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/vitest-websocket-mock/-/vitest-websocket-mock-0.5.0.tgz", + "integrity": "sha512-vzBWeuF/kD/OCOFzB7WAclb7PxfI105qPkZtdOkPMwZdilBskQjJL4l319JtPtmeovDU7ZVhO3hTfGPjM4txQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "^3.0.0", + "mock-socket": "^9.2.1" + }, + "peerDependencies": { + "vitest": ">=3" + } + }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.4.tgz", - "integrity": "sha512-0ioMscWJtfpyH7+P82sGpAi3Si30OVV73jD+tEqXm5+rIx9LgnfdaOn45uaFkKOncABi/PHL00Yn0oW/wK4cXw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.4", - "@vitest/utils": "4.0.4", - "chai": "^6.0.1", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, "funding": { @@ -9153,15 +9273,15 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.4.tgz", - "integrity": "sha512-UTtKgpjWj+pvn3lUM55nSg34098obGhSHH+KlJcXesky8b5wCUgg7s60epxrS6yAG8slZ9W8T9jGWg4PisMf5Q==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.4", + "@vitest/spy": "4.0.15", "estree-walker": "^3.0.3", - "magic-string": "^0.30.19" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" @@ -9180,9 +9300,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.4.tgz", - "integrity": "sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", "dev": true, "license": "MIT", "dependencies": { @@ -9193,9 +9313,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.4.tgz", - "integrity": "sha512-G9L13AFyYECo40QG7E07EdYnZZYCKMTSp83p9W8Vwed0IyCG1GnpDLxObkx8uOGPXfDpdeVf24P1Yka8/q1s9g==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", "dev": true, "license": "MIT", "funding": { @@ -9203,13 +9323,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.4.tgz", - "integrity": "sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.4", + "@vitest/pretty-format": "4.0.15", "tinyrainbow": "^3.0.3" }, "funding": { @@ -9226,13 +9346,6 @@ "node": ">=18" } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/vitest/node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -9514,8 +9627,8 @@ "version": "4.1.13", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "devOptional": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index cf2a6dafe7..18f31b05b7 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -25,74 +25,77 @@ "upgrade": "ncu -i" }, "devDependencies": { - "@chromatic-com/storybook": "^4.1.1", - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.38.0", - "@iconify-json/lucide": "^1.2.71", - "@playwright/test": "^1.56.1", - "@storybook/addon-a11y": "^10.0.0", - "@storybook/addon-docs": "^10.0.0", + "@chromatic-com/storybook": "^4.1.3", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@iconify-json/lucide": "^1.2.79", + "@playwright/test": "^1.57.0", + "@storybook/addon-a11y": "^10.1.4", + "@storybook/addon-docs": "^10.1.4", "@storybook/addon-svelte-csf": "^5.0.10", - "@storybook/sveltekit": "^10.0.0", + "@storybook/sveltekit": "^10.1.4", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.48.2", + "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/vite": "^4.1.16", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/eslint-plugin-query": "^5.91.2", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/svelte": "^5.2.8", + "@testing-library/svelte": "^5.2.9", "@types/eslint": "^9.6.1", - "@types/node": "^24.9.2", + "@types/node": "^24.10.1", "@types/throttle-debounce": "^5.0.2", "cross-env": "^10.1.0", - "eslint": "^9.38.0", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^4.15.1", - "eslint-plugin-storybook": "^10.0.0", - "eslint-plugin-svelte": "^3.12.5", - "jsdom": "^27.0.1", - "prettier": "^3.6.2", + "eslint-plugin-storybook": "^10.1.4", + "eslint-plugin-svelte": "^3.13.1", + "jsdom": "^27.2.0", + "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.7.1", - "storybook": "^10.0.0", - "svelte": "^5.43.0", - "svelte-check": "^4.3.3", + "prettier-plugin-tailwindcss": "^0.7.2", + "storybook": "^10.1.4", + "svelte": "^5.45.6", + "svelte-check": "^4.3.4", "swagger-typescript-api": "^13.2.16", "tslib": "^2.8.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.2", - "vite": "^7.1.12", - "vitest": "4.0.4" + "typescript-eslint": "^8.48.1", + "vite": "^7.2.6", + "vitest": "4.0.15", + "vitest-websocket-mock": "^0.5.0", + "zod": "^4.1.13" }, "dependencies": { "@exceptionless/browser": "^3.1.0", "@exceptionless/fetchclient": "^0.44.0", "@internationalized/date": "^3.10.0", - "@lucide/svelte": "^0.548.0", - "@tanstack/svelte-query": "^6.0.3", - "@tanstack/svelte-query-devtools": "^6.0.0", + "@lucide/svelte": "^0.556.0", + "@tanstack/svelte-query": "^6.0.10", + "@tanstack/svelte-query-devtools": "^6.0.2", "@tanstack/svelte-table": "^9.0.0-alpha.10", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.7", "@typeschema/class-validator": "^0.3.0", - "bits-ui": "^2.14.1", - "class-validator": "^0.14.2", + "bits-ui": "^2.14.4", + "class-validator": "^0.14.3", "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.3.0", "formsnap": "^2.0.1", "kit-query-params": "^0.0.26", - "layerchart": "^2.0.0-next.40", + "layerchart": "^2.0.0-next.43", "mode-watcher": "^1.1.0", - "oidc-client-ts": "^3.3.0", + "oidc-client-ts": "^3.4.1", "pretty-ms": "^9.3.0", - "runed": "^0.35.1", - "shiki": "^3.14.0", - "svelte-sonner": "^1.0.5", + "runed": "^0.37.0", + "shiki": "^3.19.0", + "svelte-sonner": "^1.0.7", "svelte-time": "^2.0.2", - "sveltekit-superforms": "^2.28.0", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^3.1.1", - "tailwindcss": "^4.1.16", + "sveltekit-superforms": "^2.28.1", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.1.17", "throttle-debounce": "^5.0.2", "tw-animate-css": "^1.4.0" }, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts index 9196d6f58e..bb4496b1c1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts @@ -2,8 +2,8 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; import { env } from '$env/dynamic/public'; +import { CachedPersistedState } from '$features/shared/utils/cached-persisted-state.svelte'; import { useFetchClient } from '@exceptionless/fetchclient'; -import { PersistedState } from 'runed'; import type { Login, TokenResult } from './models'; @@ -29,22 +29,14 @@ export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'live' const authSerializer = { deserialize: (value: null | string): null | string => { - if (value === '') { - return null; - } - - return value; + return value === '' ? null : value; }, serialize: (value: null | string): string => { - if (value === null) { - return ''; - } - - return value; + return value === null ? '' : value; } }; -export const accessToken = new PersistedState('satellizer_token', null, { serializer: authSerializer }); +export const accessToken = new CachedPersistedState('satellizer_token', null, { serializer: authSerializer }); export const enableAccountCreation = env.PUBLIC_ENABLE_ACCOUNT_CREATION === 'true'; export const facebookClientId = env.PUBLIC_FACEBOOK_APPID; @@ -147,7 +139,8 @@ export async function login(email: string, password: string) { export async function logout() { const client = useFetchClient(); await client.get('auth/logout', { expectedStatusCodes: [200, 401] }); - accessToken.current = null; + + accessToken.current = ''; } export async function slackOAuthLogin(): Promise { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index ac888cea32..1c145039b0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -74,6 +74,7 @@ export interface GetEventsParams { } export interface GetOrganizationCountRequest { + enabled?: () => boolean; params?: { aggregations?: string; filter?: string; @@ -171,7 +172,7 @@ export function getOrganizationCountQuery(request: GetOrganizationCountRequest) const queryClient = useQueryClient(); return createQuery(() => ({ - enabled: () => !!accessToken.current && !!request.route.organizationId, + enabled: () => !!accessToken.current && !!request.route.organizationId && (request.enabled?.() ?? true), queryClient, queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte index ac46cbff72..dd820cb20c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte @@ -161,7 +161,7 @@ {:else if showXmlCodeEditor} {:else} -
{clipboardData}
{clipboardData}
{/if} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte new file mode 100644 index 0000000000..6ca11b62aa --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte @@ -0,0 +1,36 @@ + + + +
+ {}} term="fixed" value={true}> + + Fixed: true + + {}} term="hidden" value={false}> + + Hidden: false + + {}} term="critical" value={true}> + + Critical: true + +
+
+ + +
+ {}} term="fixed" value={true} /> + {}} term="hidden" value={false} /> + {}} term="critical" value={true} /> +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte index 76b4b46826..ae290b03d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte @@ -4,22 +4,20 @@ import { BooleanFilter } from './models.svelte'; - type Props = ButtonProps & { + type Props = Omit & { changed: (filter: BooleanFilter) => void; term: string; value?: boolean; }; let { changed, children, class: className, term, value, ...props }: Props = $props(); - - const title = `Search ${term}:${value}`; + {/if} + + +
+
+ {#if searchResults.isFetching} +
+ {#each [1, 2, 3, 4, 5] as i (i)} +
+ +
+ + +
+
+ {/each} +
+ {:else if organizations.length > 0} +
+ {#each organizations as organization (organization.id)} + {@const isSelected = selectedOrganization?.id === organization.id} + {@const isMember = isUserMember(organization.id)} +
+ + + {#if isSelected} +
+ {#if organization.is_suspended} + + {#snippet icon()}{/snippet} + + Suspended + {#if organization.suspension_date}{/if} + for + {getSuspensionLabel(organization.suspension_code)}{#if organization.suspension_notes}: {organization.suspension_notes}{/if} + + + {/if} + + {#if organization.is_over_monthly_limit || organization.is_over_request_limit} + + {#snippet icon()}{/snippet} + + Over Limit: + {#if organization.is_over_monthly_limit}Monthly{/if} + {#if organization.is_over_request_limit}{organization.is_over_monthly_limit ? ', ' : ''}Request{/if} + + + {/if} + +
+
+
+ Plan: + {organization.plan_name} + + {getBillingStatusLabel(organization.billing_status)} + +
+ {#if organization.billing_change_date} + Changed + {/if} +
+ {#if organization.subscribe_date} +
+
+ Subscribed: + +
+
+ {/if} +
+
+ Limit: + events/mo +
+
+
+
+ Retention: + days +
+
+ {#if organization.bonus_events_per_month && organization.bonus_events_per_month > 0} +
+
+ Bonus: + events/mo +
+ {#if organization.bonus_expiration} + Expires + {/if} +
+ {/if} +
+ +
+
+ + + + projects + + + + + stacks + + + + + events + +
+ {#if organization.created_utc} + + + Created + + + {/if} + {#if organization.updated_utc && organization.created_utc !== organization.updated_utc} + + + Updated + + + {/if} + {#if getLastEventDate(organization)} + + + Last Event + + + {/if} +
+
+ {/if} +
+ {/each} +
+ {:else} +
+ +

No organizations found

+ + {#if hasFilters} + Try adjusting your search or + {:else} + Try a different search query + {/if} + +
+ {/if} +
+
+ +
+
+ + {#if totalCount > 0} + {totalCount} organizations + {/if} +
+ {#if totalPages > 1} + + + + + + + + + + + + + + {/if} +
+ + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte index e4f57a52e8..d6b75dd0e8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte @@ -1,6 +1,7 @@ + + + {#snippet icon()}{/snippet} + {#snippet action()} + {#if userOrganizations.length > 0} + + {/if} + {/snippet} + Impersonating {name} + You are viewing this organization as a global admin. + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte index b9cf42c20c..e1e0f5f8d5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte @@ -1,6 +1,7 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 264f4dac74..36a2208b26 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -1,13 +1,15 @@ +{#if isImpersonating && organization} + +{/if} + {#if organization} {#if isSuspended} + import { defineMeta } from '@storybook/addon-svelte-csf'; + + import OverLimitIndicator from './over-limit-indicator.svelte'; + + const { Story } = defineMeta({ + argTypes: { + isOverLimit: { + control: { type: 'boolean' }, + defaultValue: true + } + }, + component: OverLimitIndicator, + tags: ['autodocs'], + title: 'Components/Organizations/OverLimitIndicator' + }); + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte new file mode 100644 index 0000000000..be61e15f5b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte @@ -0,0 +1,21 @@ + + +{#if isOverLimit} + + + {#snippet child({ props })} + Over Limit + {/snippet} + + This organization has exceeded its monthly event limit. + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte new file mode 100644 index 0000000000..abdc9d2f40 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte new file mode 100644 index 0000000000..f10ff24978 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte @@ -0,0 +1,53 @@ + + + + + {#snippet child({ props })} + + {getLabel(code)} + + {/snippet} + + + {getSuspensionDescription(code, notes)} + + 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 c4a77cb43f..1a9f7337e6 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 @@ -2,13 +2,17 @@ import type { FetchClientResponse, ProblemDetails } from '@exceptionless/fetchcl import type { CreateQueryResult } from '@tanstack/svelte-query'; import NumberFormatter from '$comp/formatters/number.svelte'; -import OrganizationsActionsCell from '$features/organizations/components/table/organization-actions-cell.svelte'; import { ViewOrganization } from '$features/organizations/models'; import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; import { type ColumnDef, renderComponent } from '@tanstack/svelte-table'; import type { GetOrganizationsMode, GetOrganizationsParams } from '../../api.svelte'; +import OrganizationsActionsCell from './organization-actions-cell.svelte'; +import OrganizationOverLimitCell from './organization-over-limit-cell.svelte'; +import OrganizationRetentionDaysCell from './organization-retention-days-cell.svelte'; +import OrganizationSuspensionCell from './organization-suspension-cell.svelte'; + export function getColumns(mode: GetOrganizationsMode = 'stats'): ColumnDef[] { const columns: ColumnDef[] = [ { @@ -29,6 +33,44 @@ export function getColumns(mode: GetOrg meta: { class: 'w-[200px]' } + }, + { + accessorFn: (row) => row.is_over_monthly_limit, + cell: (info) => renderComponent(OrganizationOverLimitCell, { isOverLimit: info.getValue() }), + enableSorting: false, + header: 'Over Limit', + id: 'is_over_monthly_limit', + meta: { + class: 'w-24', + defaultHidden: true + } + }, + { + accessorKey: 'retention_days', + cell: (info) => renderComponent(OrganizationRetentionDaysCell, { value: info.getValue() }), + enableSorting: false, + header: 'Retention', + meta: { + class: 'w-24', + defaultHidden: true + } + }, + { + accessorFn: (row) => row.is_suspended, + cell: (info) => { + const org = info.row.original; + return renderComponent(OrganizationSuspensionCell, { + code: org.suspension_code, + notes: org.suspension_notes + }); + }, + enableSorting: false, + header: 'Suspended', + id: 'is_suspended', + meta: { + class: 'w-28', + defaultHidden: true + } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte index 2aeb29960e..75d11ae0c2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte @@ -83,15 +83,15 @@ {/snippet} - goto(resolve(`/(app)/organization/[organizationId]/manage`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: org.id }))}> Edit - goto(resolve(`/(app)/organization/[organizationId]/billing`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/billing', { organizationId: org.id }))}> Change Plan - goto(resolve(`/(app)/organization/[organizationId]/billing`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/billing', { organizationId: org.id }))}> View Invoices diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte new file mode 100644 index 0000000000..d460a61a1f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte @@ -0,0 +1,15 @@ + + +{#if isOverLimit} + +{:else} + - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte new file mode 100644 index 0000000000..e1c7aac565 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte @@ -0,0 +1,15 @@ + + +{#if value} + days +{:else} + - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte new file mode 100644 index 0000000000..a004279ea7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte @@ -0,0 +1,15 @@ + + +{#if code} + +{:else}x - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts new file mode 100644 index 0000000000..dcb7db8641 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts @@ -0,0 +1,32 @@ +export function getSuspensionDescription(code: null | string | undefined, notes?: null | string): string { + if (notes?.trim()) { + return notes; + } + + switch (code) { + case 'Abuse': + return 'This organization has been suspended due to abuse.'; + case 'Billing': + return 'This organization has been suspended due to billing issues.'; + case 'Overage': + return 'This organization has been suspended due to exceeding usage limits.'; + case 'Other': + default: + return 'This organization has been suspended.'; + } +} + +export function getSuspensionLabel(code: null | string | undefined): string { + switch (code) { + case 'Abuse': + return 'Abuse Detected'; + case 'Billing': + return 'Billing Issue'; + case 'Other': + return 'Other'; + case 'Overage': + return 'Over Limit'; + default: + return 'Suspended'; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 9a892854bc..c8bc428bb5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -87,6 +87,7 @@ export interface GetOrganizationProjectsParams { } export interface GetOrganizationProjectsRequest { + enabled?: () => boolean; params?: GetOrganizationProjectsParams; route: { organizationId: string | undefined; @@ -279,7 +280,7 @@ export function getOrganizationProjectsQuery(request: GetOrganizationProjectsReq const queryClient = useQueryClient(); return createQuery, ProblemDetails>(() => ({ - enabled: () => !!accessToken.current && !!request.route.organizationId, + enabled: () => !!accessToken.current && !!request.route.organizationId && (request.enabled?.() ?? true), onSuccess: (data: FetchClientResponse) => { data.data?.forEach((project) => { queryClient.setQueryData(queryKeys.id(project.id!), project); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-count.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-count.svelte index 92b4831467..bbfaf332b9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-count.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-page-count.svelte @@ -5,25 +5,16 @@ - -
- -
- -
+ const currentPage = $derived(table.getState().pagination.pageIndex + 1); + const totalPages = $derived(Math.max(1, table.getPageCount())); + - Page of -
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte index 4f2078a10e..8392bcc63f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte @@ -5,31 +5,33 @@ -
- {#if table.getState().pagination.pageIndex > 1} - - {/if} - - -
+ + + + + + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte index ae57fe6ba4..80894099f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte @@ -5,7 +5,7 @@ let { value }: Props = $props(); - const parsedValue = typeof value === 'number' ? value : parseFloat(value ?? ''); + const parsedValue = $derived(typeof value === 'number' ? value : parseFloat(value ?? '')); const byteValueNumberFormatter = new Intl.NumberFormat(navigator.language, { notation: 'compact', style: 'unit', diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.stories.svelte index d315a0313a..e57a3edf3c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.stories.svelte @@ -1,5 +1,9 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte index f17894f3fd..7bdf920933 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte @@ -13,11 +13,11 @@ let { value }: Props = $props(); - let type = typeof value; - let isBoolean = type === 'boolean' || value instanceof Boolean; - let isObject = (type === 'object' || value instanceof Object) && value !== null; - let isNull = value === null; - let isEmptyValue = isEmpty(value); + const type = $derived(typeof value); + const isBoolean = $derived(type === 'boolean' || value instanceof Boolean); + const isObject = $derived((type === 'object' || value instanceof Object) && value !== null); + const isNull = $derived(value === null); + const isEmptyValue = $derived(isEmpty(value)); function isEmpty(value: unknown) { if (value === undefined) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte new file mode 100644 index 0000000000..2cf257d856 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte @@ -0,0 +1,38 @@ + + +
+ + +
+ +
+ + Page of +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte index bfaa9c527b..34d37cfbc9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte @@ -3,17 +3,23 @@ export const badgeVariants = tv({ base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3", - variants: { - variant: { - default: - "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent", - secondary: - "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", - destructive: - "bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white", - outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, + variants: { + variant: { + default: + "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", + destructive: + "bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + red: + "bg-red-100 text-red-700 [a&]:hover:bg-red-200 border-transparent focus-visible:ring-red-400 dark:bg-red-900/30 dark:text-red-300 dark:[a&]:hover:bg-red-900/50", + amber: + "bg-amber-100 text-amber-700 [a&]:hover:bg-amber-200 border-transparent focus-visible:ring-amber-400 dark:bg-amber-900/30 dark:text-amber-300 dark:[a&]:hover:bg-amber-900/50", + orange: + "bg-orange-100 text-orange-700 [a&]:hover:bg-orange-200 border-transparent focus-visible:ring-orange-400 dark:bg-orange-900/30 dark:text-orange-300 dark:[a&]:hover:bg-orange-900/50", + }, + }, defaultVariants: { variant: "default", }, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte index b5458fabf5..7431eee357 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="breadcrumb-list" class={cn( - "text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5", + "text-muted-foreground flex flex-wrap items-center gap-1.5 wrap-break-word text-sm sm:gap-2.5", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts new file mode 100644 index 0000000000..c867c237c0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts @@ -0,0 +1,28 @@ +import Root from "./pagination.svelte"; +import Content from "./pagination-content.svelte"; +import Item from "./pagination-item.svelte"; +import Link from "./pagination-link.svelte"; +import FirstButton from "./pagination-first-button.svelte"; +import PrevButton from "./pagination-prev-button.svelte"; +import NextButton from "./pagination-next-button.svelte"; +import Ellipsis from "./pagination-ellipsis.svelte"; + +export { + Root, + Content, + Item, + Link, + FirstButton, + PrevButton, + NextButton, + Ellipsis, + // + Root as Pagination, + Content as PaginationContent, + FirstButton as PaginationFirstButton, + Item as PaginationItem, + Link as PaginationLink, + PrevButton as PaginationPrevButton, + NextButton as PaginationNextButton, + Ellipsis as PaginationEllipsis, +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte new file mode 100644 index 0000000000..e1124fce19 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte new file mode 100644 index 0000000000..3be94c9ca4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte new file mode 100644 index 0000000000..6a42fae6ef --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte @@ -0,0 +1,51 @@ + + +{#snippet Fallback()} + + Go to first page +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte new file mode 100644 index 0000000000..fd7ffc3a7c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte @@ -0,0 +1,14 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte new file mode 100644 index 0000000000..2cdd031959 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte @@ -0,0 +1,39 @@ + + +{#snippet Fallback()} + {page.value} +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte new file mode 100644 index 0000000000..d19b7c78a2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte @@ -0,0 +1,32 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte new file mode 100644 index 0000000000..fbde95b66d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte @@ -0,0 +1,32 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte new file mode 100644 index 0000000000..60e3471b0c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte @@ -0,0 +1,28 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.test.ts new file mode 100644 index 0000000000..5517d20ae5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CachedPersistedState } from './cached-persisted-state.svelte'; + +describe('CachedPersistedState', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with default value when storage is empty', () => { + const state = new CachedPersistedState('test-key', 'default'); + expect(state.current).toBe('default'); + }); + + it('should initialize with stored value when available', () => { + localStorage.setItem('test-key', JSON.stringify('stored-value')); + const state = new CachedPersistedState('test-key', 'default'); + expect(state.current).toBe('stored-value'); + }); + + it('should support custom serializer', () => { + const serializer = { + deserialize: (value: string) => Number(value) / 2, + serialize: (value: number) => String(value * 2) + }; + + localStorage.setItem('test-key', '20'); + const state = new CachedPersistedState('test-key', 0, { serializer }); + expect(state.current).toBe(10); + }); + }); + + describe('reading values', () => { + it('should return cached value without reading storage repeatedly', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + const serializer = { + deserialize: (value: string) => { + return value; + }, + serialize: (value: string) => value + }; + + localStorage.setItem('test-key', 'value1'); + const state = new CachedPersistedState('test-key', 'default', { serializer }); + + // Reset spy after initial read + getItemSpy.mockClear(); + + // Multiple reads should only use the cache, not read storage + const val1 = state.current; + const val2 = state.current; + const val3 = state.current; + + expect(val1).toBe('value1'); + expect(val2).toBe('value1'); + expect(val3).toBe('value1'); + + // Storage should not be read again (only initial read in constructor) + expect(getItemSpy).not.toHaveBeenCalled(); + }); + + it('should deserialize value only once at initialization', () => { + const deserializeSpy = vi.fn((value: string) => JSON.parse(value)); + + localStorage.setItem('test-key', '{"name":"John"}'); + + const state = new CachedPersistedState( + 'test-key', + { name: 'default' }, + { + serializer: { + deserialize: deserializeSpy, + serialize: (value: object) => JSON.stringify(value) + } + } + ); + + // Assert deserialize was called exactly twice during construction: + // once in PersistedState constructor, once when CachedPersistedState reads .current + expect(deserializeSpy).toHaveBeenCalledTimes(2); + + const callCountAfterConstruction = deserializeSpy.mock.calls.length; + + // Multiple accesses after initialization should not call deserialize again (cached) + const val1 = state.current; + const val2 = state.current; + const val3 = state.current; + + // Assert call count hasn't changed - deserialize was not called during reads + expect(deserializeSpy).toHaveBeenCalledTimes(callCountAfterConstruction); + expect(val1).toEqual({ name: 'John' }); + expect(val2).toEqual({ name: 'John' }); + expect(val3).toEqual({ name: 'John' }); + }); + }); + + describe('setting values', () => { + it('should update cache and persist to storage', () => { + const state = new CachedPersistedState('test-key', 'initial'); + state.current = 'updated'; + + expect(state.current).toBe('updated'); + expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated'); + }); + + it('should call serialize when setting value', () => { + const serializeSpy = vi.fn((value: string) => `[${value}]`); + + const state = new CachedPersistedState('test-key', 'initial', { + serializer: { + deserialize: (value: string) => value.slice(1, -1), + serialize: serializeSpy + } + }); + + state.current = 'new-value'; + + expect(serializeSpy).toHaveBeenCalledWith('new-value'); + expect(localStorage.getItem('test-key')).toBe('[new-value]'); + }); + + it('should support object values', () => { + interface User { + id: number; + name: string; + } + + const state = new CachedPersistedState('user-key', { id: 0, name: 'default' }); + const newUser = { id: 1, name: 'Alice' }; + + state.current = newUser; + + expect(state.current).toEqual(newUser); + expect(JSON.parse(localStorage.getItem('user-key')!)).toEqual(newUser); + }); + }); + + describe('cross-tab synchronization', () => { + it('should sync when PersistedState detects storage change', async () => { + const state = new CachedPersistedState('test-key', 'initial'); + + expect(state.current).toBe('initial'); + + // Simulate another tab updating storage + localStorage.setItem('test-key', JSON.stringify('from-other-tab')); + + // Manually trigger the effect to sync (in real Svelte, storage events do this) + // Wait for reactivity to settle + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The cache should eventually sync with the persisted value + // Note: This test verifies the mechanism is in place; full cross-tab requires storage events + }); + }); + + describe('edge cases', () => { + it('should handle null values', () => { + const state = new CachedPersistedState('test-key', null); + expect(state.current).toBeNull(); + + state.current = 'not-null'; + expect(state.current).toBe('not-null'); + + state.current = null; + expect(state.current).toBeNull(); + }); + + it('should handle undefined in stored data', () => { + // JSON.stringify(undefined) returns undefined (not a string), which localStorage + // converts to "undefined". JSON.parse("undefined") throws an error, and PersistedState's + // deserialize returns undefined. So the cached value will be undefined. + localStorage.setItem('test-key', String(JSON.stringify(undefined))); + const state = new CachedPersistedState('test-key', 'default'); + expect(state.current).toBeUndefined(); + }); + + it('should handle complex nested objects', () => { + interface ComplexData { + metadata: { + created: string; + modified: string; + }; + user: { + name: string; + tags: string[]; + }; + } + + const complexData: ComplexData = { + metadata: { created: '2024-01-01', modified: '2024-01-02' }, + user: { name: 'Bob', tags: ['admin', 'user'] } + }; + + const state = new CachedPersistedState('complex-key', complexData); + const updated: ComplexData = { + metadata: { created: '2024-01-01', modified: '2024-01-03' }, + user: { name: 'Alice', tags: ['user'] } + }; + + state.current = updated; + + expect(state.current).toEqual(updated); + expect(state.current.user.name).toBe('Alice'); + expect(state.current.metadata.modified).toBe('2024-01-03'); + }); + }); + + describe('performance', () => { + it('should minimize storage reads on repeated access', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + + const state = new CachedPersistedState('perf-key', 'value'); + + // Initial read happens during construction + const initialCallCount = getItemSpy.mock.calls.length; + + // Perform 1000 reads + for (let i = 0; i < 1000; i++) { + void state.current; + } + + // Should still be only the initial read (no additional storage reads) + expect(getItemSpy.mock.calls.length).toBe(initialCallCount); + }); + + it('should cache effectively during query enabled checks', () => { + const deserializeSpy = vi.fn((value: string) => JSON.parse(value)); + + localStorage.setItem('token-key', JSON.stringify('auth-token-123')); + + const state = new CachedPersistedState('token-key', null, { + serializer: { + deserialize: deserializeSpy, + serialize: (value: null | string) => JSON.stringify(value) + } + }); + + // Reset spy after initialization + deserializeSpy.mockClear(); + + // Simulate 100 queries checking: enabled: () => !!state.current + for (let i = 0; i < 100; i++) { + if (state.current) { + // Simulate enabled check + } + } + + // Deserialize should NOT be called again (it's cached) + expect(deserializeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.ts new file mode 100644 index 0000000000..a989bed297 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.ts @@ -0,0 +1,40 @@ +import { PersistedState } from 'runed'; +import { untrack } from 'svelte'; + +/** + * Wraps PersistedState to cache reads and prevent excessive reactive signal triggers. + * + * Problem: PersistedState reads from localStorage and calls deserialize on every `.current` access, + * which triggers reactive subscriptions. When dozens of queries check `enabled: () => !!state.current`, + * this causes a cascading reactivity loop. + * + * Solution: Cache the value in a local $state. Reads come from the cache (instant, no deserialize). + * Writes update both the cache and PersistedState. PersistedState handles cross-tab sync internally. + */ +export class CachedPersistedState { + /** + * Get the cached value without triggering PersistedState's deserialize + */ + get current(): T { + return this.#cached; + } + /** + * Set the value, updating both the cache and PersistedState + */ + set current(newValue: T) { + this.#cached = newValue; + this.#persisted.current = newValue; + } + + #cached = $state(null!); + + #persisted: PersistedState; + + constructor(key: string, initialValue: T, options?: object) { + // Initialize PersistedState from storage + this.#persisted = new PersistedState(key, initialValue, options); + + // Cache the initial value (untrack to avoid creating dependencies during construction) + this.#cached = untrack(() => this.#persisted.current); + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index 5fa2b39e54..84f770d87e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -24,6 +24,7 @@ import Filter from '@lucide/svelte/icons/filter'; import Users from '@lucide/svelte/icons/users'; + import StackLogLevel from './stack-log-level.svelte'; import StackOptionsDropdownMenu from './stack-options-dropdown-menu.svelte'; import StackReferences from './stack-references.svelte'; import StackStatusDropdownMenu from './stack-status-dropdown-menu.svelte'; @@ -121,7 +122,8 @@ {stack.title} -
    +
    + @@ -133,8 +135,11 @@
    filterChanged(new StringFilter('stack', stack.id))} + aria-label="Filter by this stack" + role="button" + tabindex={0} > @@ -238,6 +243,12 @@
    {/each}
    + +
    + Last 7 days +
    + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte new file mode 100644 index 0000000000..f81ba54421 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte @@ -0,0 +1,17 @@ + + +{#if stack.type === 'log' && source} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts index 08fa17d549..1596a81630 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts @@ -2,39 +2,90 @@ import { DocumentVisibility } from '$shared/document-visibility.svelte'; import { accessToken } from '../auth/index.svelte'; +export interface WebSocketClientOptions { + /** + * Base URL for WebSocket connection (e.g., 'ws://localhost:1234') + * If not provided, constructs from window.location + */ + baseUrl?: string; + /** + * Connection timeout in milliseconds + * Default: 10000ms (10 seconds) + */ + connectionTimeout?: number; + /** + * Custom reconnection delay calculator + * Default uses exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + * For testing, can return 0 to reconnect immediately + */ + reconnectDelay?: (attempt: number) => number; +} + export class WebSocketClient { - public readyState: number = WebSocket.CLOSED; + public readyState = $state(WebSocket.CLOSED); + + /** + * Lazy getter for WebSocket URL. + * Constructed on first access. Uses baseUrl from options if provided, otherwise constructs from window.location. + */ + public get url(): string { + if (this._url === null) { + if (this._options.baseUrl) { + this._url = `${this._options.baseUrl}${this._path}`; + } else { + const { host, protocol } = window.location; + const wsProtocol = protocol === 'https:' ? 'wss://' : 'ws://'; + this._url = `${wsProtocol}${host}${this._path}`; + } + } + return this._url; + } - public reconnectInterval: number = 1000; - public timeoutInterval: number = 2000; - public url: string; + private _options: WebSocketClientOptions; + private _path: string; + private _url: null | string = null; private accessToken: null | string = null; - + private connectionTimeoutId: null | ReturnType = null; private forcedClose: boolean = false; - private timedOut: boolean = false; + private hasConnectedBefore: boolean = false; + private reconnectAttempts: number = 0; + private reconnectTimeoutId: null | ReturnType = null; + private ws: null | WebSocket = null; - constructor(path: string = '/api/v2/push') { - const { host, protocol } = window.location; - const wsProtocol = protocol === 'https:' ? 'wss://' : 'ws://'; - this.url = `${wsProtocol}${host}${path}`; + /** + * @param path - WebSocket path (default: '/api/v2/push') + * @param options - Optional configuration + */ + constructor(path: string = '/api/v2/push', options: WebSocketClientOptions = {}) { + this._path = path; + this._options = options; const visibility = new DocumentVisibility(); $effect(() => { if (this.accessToken !== accessToken.current) { this.accessToken = accessToken.current; + this.reconnectAttempts = 0; // Reset backoff on token change this.close(); } else if (!visibility.visible) { this.close(); } - if (this.accessToken && visibility.visible && (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED)) { + // Only auto-connect if we're fully closed and don't have a pending reconnect attempt + // Don't try to connect if we're CONNECTING, OPEN, or CLOSING + if (this.accessToken && visibility.visible && this.readyState === WebSocket.CLOSED && this.reconnectTimeoutId === null) { this.connect(); } }); } + public close(): boolean { + clearTimeout(this.reconnectTimeoutId!); + this.reconnectTimeoutId = null; + clearTimeout(this.connectionTimeoutId!); + this.connectionTimeoutId = null; + if (this.ws) { this.forcedClose = true; this.ws.close(); @@ -43,61 +94,102 @@ export class WebSocketClient { return false; } - public connect(reconnectAttempt: boolean = true) { - const isReconnect: boolean = this.forcedClose; + + public connect() { + // isReconnect means: have we successfully connected before? + const isReconnect: boolean = this.hasConnectedBefore; // Reset state this.readyState = WebSocket.CONNECTING; this.forcedClose = false; - this.ws = new WebSocket(`${this.url}?access_token=${this.accessToken}`); - this.onConnecting(isReconnect); + try { + this.ws = new WebSocket(`${this.url}?access_token=${this.accessToken}`); + this.onConnecting(isReconnect); + } catch (error) { + console.error('[WebSocketClient] Failed to create WebSocket', error); + throw error; + } - const localWs = this.ws; - const timeout = setTimeout(() => { - this.timedOut = true; - localWs.close(); - this.timedOut = false; - }, this.timeoutInterval); + // Connection timeout: if we don't connect within configured timeout, force close + clearTimeout(this.connectionTimeoutId!); + const timeout = this._options.connectionTimeout ?? 10000; + this.connectionTimeoutId = setTimeout(() => { + this.connectionTimeoutId = null; + if (this.ws && this.readyState === WebSocket.CONNECTING) { + console.warn(`[WebSocketClient] Connection timeout after ${timeout}ms`); + this.ws.close(); + } + }, timeout); this.ws.onopen = (event: Event) => { - clearTimeout(timeout); - + clearTimeout(this.connectionTimeoutId!); + this.connectionTimeoutId = null; this.readyState = WebSocket.OPEN; - reconnectAttempt = false; + this.reconnectAttempts = 0; // Reset backoff on successful connection + this.hasConnectedBefore = true; // Mark that we've connected successfully this.onOpen(event, isReconnect); }; this.ws.onclose = (event: CloseEvent) => { - clearTimeout(timeout); + clearTimeout(this.connectionTimeoutId!); + this.connectionTimeoutId = null; this.ws = null; if (this.forcedClose) { this.readyState = WebSocket.CLOSED; this.onClose(event); - } else { - this.readyState = WebSocket.CONNECTING; - this.onConnecting(isReconnect); - if (!reconnectAttempt && !this.timedOut) { - this.onClose(event); - } - setTimeout(() => { - this.connect(true); - }, this.reconnectInterval); + return; + } + + // Don't retry on authentication/authorization failures + // Code 1008 (Policy Violation) is explicit auth failure + // Code 1006 (Abnormal Closure) during handshake could be 401/403 + // Codes 4xxx are custom application codes (e.g., 4401=401, 4403=403) + const isAuthFailure = event.code === 1008 || (event.code === 1006 && event.wasClean === false) || (event.code >= 4400 && event.code < 4500); + if (isAuthFailure) { + console.warn('[WebSocketClient] Auth failure detected, not reconnecting', { + code: event.code, + reason: event.reason + }); + this.readyState = WebSocket.CLOSED; + this.onClose(event); + return; // Let the auth system handle redirect to login } + + // Calculate reconnection delay with exponential backoff + this.reconnectAttempts++; + const delay = this.getReconnectDelay(this.reconnectAttempts); + + this.onConnecting(true); // Always true when reconnecting after close + this.onClose(event); + + // Schedule reconnect - clear any existing timeout first + clearTimeout(this.reconnectTimeoutId!); + this.reconnectTimeoutId = setTimeout(() => { + this.reconnectTimeoutId = null; + this.connect(); + }, delay); }; + this.ws.onmessage = (event) => { this.onMessage(event); }; + this.ws.onerror = (event) => { + console.error('[WebSocketClient] onerror triggered', { + event, + readyState: this.readyState, + reconnectAttempts: this.reconnectAttempts + }); this.onError(event); }; } + public onClose: (ev: CloseEvent) => void = () => {}; - public onConnecting: (isReconnect: boolean) => void = () => {}; + public onConnecting: (isReconnect: boolean) => void = () => {}; public onError: (ev: Event) => void = () => {}; - public onMessage: (ev: MessageEvent) => void = () => {}; public onOpen: (ev: Event, isReconnect: boolean) => void = () => {}; @@ -109,4 +201,16 @@ export class WebSocketClient { throw new Error('INVALID_STATE_ERR : Pausing to reconnect websocket'); } } + + /** + * Calculate reconnection delay using exponential backoff + * Can be overridden via options for testing + */ + private getReconnectDelay(attempt: number): number { + if (this._options.reconnectDelay) { + return this._options.reconnectDelay(attempt); + } + // Default: exponential backoff 1s, 2s, 4s, 8s, 16s, max 30s + return Math.min(1000 * Math.pow(2, attempt - 1), 30000); + } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts new file mode 100644 index 0000000000..c7138d1206 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts @@ -0,0 +1,494 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import WS from 'vitest-websocket-mock'; + +import { WebSocketClient, type WebSocketClientOptions } from './web-socket-client.svelte'; + +// Mock the auth module +vi.mock('../auth/index.svelte', () => ({ + accessToken: { + current: 'test-token-123' + } +})); + +// Mock DocumentVisibility to always return visible +vi.mock('$shared/document-visibility.svelte', () => { + return { + DocumentVisibility: class { + visible = true; + } + }; +}); + +let server: WS; + +beforeEach(() => { + server = new WS('ws://localhost:1234/api/v2/push'); +}); + +afterEach(() => { + WS.clean(); +}); + +function createClient(path?: string, options?: WebSocketClientOptions): WebSocketClient { + return new WebSocketClient(path, { + baseUrl: 'ws://localhost:1234', + reconnectDelay: () => 0, + ...options + }); +} + +describe('WebSocketClient', () => { + describe('Connection Lifecycle', () => { + it('should connect successfully', async () => { + const client = createClient(); + client.connect(); + await server.connected; + + expect(client.readyState).toBe(WebSocket.OPEN); + client.close(); + }); + + it('should set readyState to CONNECTING then OPEN', async () => { + const client = createClient(); + client.connect(); + + expect(client.readyState).toBe(WebSocket.CONNECTING); + await server.connected; + expect(client.readyState).toBe(WebSocket.OPEN); + + client.close(); + }); + + it('should call onConnecting with isReconnect=false on first connection', async () => { + const onConnecting = vi.fn(); + const client = createClient(); + client.onConnecting = onConnecting; + + client.connect(); + expect(onConnecting).toHaveBeenCalledWith(false); + await server.connected; + + client.close(); + }); + + it('should call onOpen with isReconnect=false on first connection', async () => { + const onOpen = vi.fn(); + const client = createClient(); + client.onOpen = onOpen; + + client.connect(); + await server.connected; + + expect(onOpen).toHaveBeenCalledWith(expect.anything(), false); + client.close(); + }); + + it('should handle multiple connect calls gracefully', async () => { + const client = createClient(); + + client.connect(); + client.connect(); + client.connect(); + + await server.connected; + expect(client.readyState).toBe(WebSocket.OPEN); + + client.close(); + }); + + it('should use custom connectionTimeout option', async () => { + const onConnecting = vi.fn(); + const client = new WebSocketClient('/api/v2/push', { + baseUrl: 'ws://localhost:9999', + connectionTimeout: 75, // Very short timeout + reconnectDelay: () => 1000 // Prevent immediate reconnect + }); + client.onConnecting = onConnecting; + + client.connect(); + expect(client.readyState).toBe(WebSocket.CONNECTING); + + // Wait for custom timeout to expire and close to be triggered + await new Promise((resolve) => setTimeout(resolve, 150)); + + // onConnecting was called with isReconnect=false for initial connect + expect(onConnecting).toHaveBeenCalledWith(false); + }); + }); + + describe('Disconnection', () => { + it('should close WebSocket when close() is called', async () => { + const client = createClient(); + client.connect(); + await server.connected; + + const result = client.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(result).toBe(true); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + + it('should return false when closing already closed connection', () => { + const client = createClient(); + client.close(); + const result = client.close(); + + expect(result).toBe(false); + }); + + it('should call onClose callback', async () => { + const onClose = vi.fn(); + const client = createClient(); + client.onClose = onClose; + + client.connect(); + await server.connected; + + server.close({ code: 1000, reason: 'Test', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + code: 1000, + reason: 'Test', + wasClean: true + }) + ); + }); + + it('should NOT reconnect after manual close', async () => { + const client = createClient(); + client.connect(); + await server.connected; + + client.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + }); + + describe('Reconnection Logic', () => { + it('should NOT reconnect on policy violation (code 1008) - auth failure', async () => { + const client = createClient(); + const onClose = vi.fn(); + client.onClose = onClose; + + client.connect(); + await server.connected; + + server.close({ code: 1008, reason: 'Policy Violation', wasClean: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + code: 1008, + reason: 'Policy Violation', + wasClean: false + }) + ); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + + it('should NOT reconnect on abnormal closure (code 1006, wasClean=false) - connection lost unexpectedly', async () => { + const client = createClient(); + const onClose = vi.fn(); + client.onClose = onClose; + + client.connect(); + await server.connected; + + server.close({ code: 1006, reason: 'Abnormal Closure', wasClean: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + code: 1006, + reason: 'Abnormal Closure', + wasClean: false + }) + ); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + + it('should NOT reconnect on unauthorized (code 4401) - 401 HTTP equivalent', async () => { + const client = createClient(); + const onClose = vi.fn(); + client.onClose = onClose; + + client.connect(); + await server.connected; + + server.close({ code: 4401, reason: 'Unauthorized', wasClean: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + code: 4401, + reason: 'Unauthorized', + wasClean: false + }) + ); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + + it('should NOT reconnect on forbidden (code 4403) - 403 HTTP equivalent', async () => { + const client = createClient(); + const onClose = vi.fn(); + client.onClose = onClose; + + client.connect(); + await server.connected; + + server.close({ code: 4403, reason: 'Forbidden', wasClean: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + code: 4403, + reason: 'Forbidden', + wasClean: false + }) + ); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + + it('should reconnect on normal closure (code 1000) - server initiated graceful close', async () => { + const onConnecting = vi.fn(); + const client = createClient(); + client.onConnecting = onConnecting; + + client.connect(); + await server.connected; + onConnecting.mockClear(); + + server.close({ code: 1000, reason: 'Normal Closure', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await server.connected; + + expect(onConnecting).toHaveBeenCalledWith(true); + client.close(); + }); + + it('should reconnect on going away (code 1001) - server restart', async () => { + const onConnecting = vi.fn(); + const client = createClient(); + client.onConnecting = onConnecting; + + client.connect(); + await server.connected; + onConnecting.mockClear(); + + server.close({ code: 1001, reason: 'Going Away', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await server.connected; + + expect(onConnecting).toHaveBeenCalledWith(true); + client.close(); + }); + + it('should call onConnecting with isReconnect=true on reconnection', async () => { + const onConnecting = vi.fn(); + const client = createClient(); + client.onConnecting = onConnecting; + + client.connect(); + await server.connected; + expect(onConnecting).toHaveBeenCalledWith(false); + onConnecting.mockClear(); + + server.close({ code: 1000, reason: 'Test', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(onConnecting).toHaveBeenCalledWith(true); + await server.connected; + client.close(); + }); + }); + + describe('Message Handling', () => { + it('should send messages when connected', async () => { + const client = createClient(); + client.connect(); + await server.connected; + + client.send('test message'); + await expect(server).toReceiveMessage('test message'); + + client.close(); + }); + + it('should throw error when sending while disconnected', () => { + const client = createClient(); + + expect(() => client.send('test')).toThrow('INVALID_STATE_ERR'); + }); + + it('should call onMessage callback when receiving messages', async () => { + const onMessage = vi.fn(); + const client = createClient(); + client.onMessage = onMessage; + + client.connect(); + await server.connected; + + server.send('test data'); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: 'test data' + }) + ); + + client.close(); + }); + + it('should receive JSON messages', async () => { + const onMessage = vi.fn(); + const client = createClient(); + client.onMessage = onMessage; + + client.connect(); + await server.connected; + + const message = JSON.stringify({ data: 'hello', type: 'test' }); + server.send(message); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: message + }) + ); + + client.close(); + }); + }); + + describe('Error Handling', () => { + it('should call onError callback', async () => { + const onError = vi.fn(); + const client = createClient(); + client.onError = onError; + + client.connect(); + await server.connected; + + server.error(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(onError).toHaveBeenCalled(); + client.close(); + }); + }); + + describe('URL Construction', () => { + it('should construct correct WebSocket URL', () => { + const client = createClient('/api/v2/push'); + + expect(client.url).toBe('ws://localhost:1234/api/v2/push'); + }); + + it('should use custom base URL', async () => { + const customClient = new WebSocketClient('/api/v2/push', { + baseUrl: 'ws://custom-host:5000', + reconnectDelay: () => 0 + }); + + const customServer = new WS('ws://custom-host:5000/api/v2/push'); + customClient.connect(); + await customServer.connected; + + expect(customClient.readyState).toBe(WebSocket.OPEN); + + customClient.close(); + customServer.close(); + }); + + it('should handle custom path', async () => { + const client = createClient('/custom/path'); + const customServer = new WS('ws://localhost:1234/custom/path'); + + client.connect(); + await customServer.connected; + + expect(client.readyState).toBe(WebSocket.OPEN); + + client.close(); + customServer.close(); + }); + }); + + describe('Options - getReconnectDelay', () => { + it('should use custom getReconnectDelay from options', async () => { + const getReconnectDelay = vi.fn(() => 100); + const client = new WebSocketClient('/api/v2/push', { + baseUrl: 'ws://localhost:1234', + reconnectDelay: getReconnectDelay + }); + + client.connect(); + await server.connected; + + server.close({ code: 1000, reason: 'Test', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await server.connected; + + expect(getReconnectDelay).toHaveBeenCalled(); + client.close(); + }); + + it('should use immediate reconnection with getReconnectDelay: () => 0', async () => { + const onConnecting = vi.fn(); + const client = createClient(); + client.onConnecting = onConnecting; + + client.connect(); + await server.connected; + onConnecting.mockClear(); + + const start = Date.now(); + server.close({ code: 1000, reason: 'Test', wasClean: true }); + + // Wait for reconnection attempt + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify reconnection happened quickly (within 50ms) + const elapsed = Date.now() - start; + expect(onConnecting).toHaveBeenCalledWith(true); + expect(elapsed).toBeLessThan(100); + + client.close(); + }); + }); + + describe('Edge Cases', () => { + it('should handle rapid connect/disconnect cycles', async () => { + const client = createClient(); + + client.connect(); + client.close(); + client.connect(); + await server.connected; + + expect(client.readyState).toBe(WebSocket.OPEN); + client.close(); + }); + + it('should maintain connection state correctly', async () => { + const client = createClient(); + + expect(client.readyState).toBe(WebSocket.CLOSED); + + client.connect(); + await server.connected; + expect(client.readyState).toBe(WebSocket.OPEN); + + client.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index f8d5b249fe..e295ab5af1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -1,5 +1,6 @@ /* eslint-disable */ /* tslint:disable */ +// @ts-nocheck /* * --------------------------------------------------------------- * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## @@ -13,6 +14,24 @@ import { IsDate, IsDefined, IsEmail, IsInt, IsMongoId, IsNumber, IsOptional, IsUrl, MaxLength, MinLength, ValidateNested } from 'class-validator'; +export enum StackStatus { + Open = 'open', + Fixed = 'fixed', + Regressed = 'regressed', + Snoozed = 'snoozed', + Ignored = 'ignored', + Discarded = 'discarded' +} + +/** @format int32 */ +export enum BillingStatus { + Trialing = 0, + Active = 1, + PastDue = 2, + Canceled = 3, + Unpaid = 4 +} + export class BillingPlan { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; @IsDefined({ message: 'name is required.' }) name!: string; @@ -20,42 +39,28 @@ export class BillingPlan { /** @format double */ @IsNumber({}, { message: 'price must be a numeric value.' }) price!: number; /** @format int32 */ - @IsInt({ message: 'max_projects must be a whole number.' }) max_projects!: number; + @IsInt({ message: 'max_projects must be a whole number.' }) + max_projects!: number; /** @format int32 */ @IsInt({ message: 'max_users must be a whole number.' }) max_users!: number; /** @format int32 */ - @IsInt({ message: 'retention_days must be a whole number.' }) retention_days!: number; + @IsInt({ message: 'retention_days must be a whole number.' }) + retention_days!: number; /** @format int32 */ - @IsInt({ message: 'max_events_per_month must be a whole number.' }) max_events_per_month!: number; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; + @IsInt({ message: 'max_events_per_month must be a whole number.' }) + max_events_per_month!: number; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; @IsDefined({ message: 'is_hidden is required.' }) is_hidden!: boolean; } -/** - * - * - * 0 = Trialing - * - * 1 = Active - * - * 2 = PastDue - * - * 3 = Canceled - * - * 4 = Unpaid - * @format int32 - */ -export enum BillingStatus { - Trialing = 0, - Active = 1, - PastDue = 2, - Canceled = 3, - Unpaid = 4 -} - export class ChangePasswordModel { - @MinLength(6, { message: 'current_password must be at least 6 characters long.' }) - @MaxLength(100, { message: 'current_password must be at most 100 characters long.' }) + @MinLength(6, { + message: 'current_password must be at least 6 characters long.' + }) + @MaxLength(100, { + message: 'current_password must be at most 100 characters long.' + }) current_password!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) @@ -70,20 +75,28 @@ export class ChangePlanResult { export class ClientConfiguration { /** @format int32 */ @IsInt({ message: 'version must be a whole number.' }) version!: number; - @ValidateNested({ message: 'settings must be a valid nested object.' }) settings!: Record; + @ValidateNested({ message: 'settings must be a valid nested object.' }) + settings!: Record; } export class CountResult { /** @format int64 */ @IsInt({ message: 'total must be a whole number.' }) total!: number; - @IsOptional() @ValidateNested({ message: 'aggregations must be a valid nested object.' }) aggregations?: Record; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsOptional() + @ValidateNested({ message: 'aggregations must be a valid nested object.' }) + aggregations?: Record; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; } export class ExternalAuthInfo { - @MinLength(1, { message: 'clientId must be at least 1 characters long.' }) clientId!: string; - @MinLength(1, { message: 'code must be at least 1 characters long.' }) code!: string; - @MinLength(1, { message: 'redirectUri must be at least 1 characters long.' }) redirectUri!: string; + @MinLength(1, { message: 'clientId must be at least 1 characters long.' }) + clientId!: string; + @MinLength(1, { message: 'code must be at least 1 characters long.' }) + code!: string; + @MinLength(1, { message: 'redirectUri must be at least 1 characters long.' }) + redirectUri!: string; @IsOptional() inviteToken?: string | null; } @@ -95,19 +108,23 @@ export class Invite { @IsDefined({ message: 'token is required.' }) token!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; /** @format date-time */ - @IsDate({ message: 'date_added must be a valid date and time.' }) date_added!: string; + @IsDate({ message: 'date_added must be a valid date and time.' }) + date_added!: string; } export class Invoice { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsDefined({ message: 'organization_name is required.' }) organization_name!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsDefined({ message: 'organization_name is required.' }) + organization_name!: string; /** @format date-time */ @IsDate({ message: 'date must be a valid date and time.' }) date!: string; @IsDefined({ message: 'paid is required.' }) paid!: boolean; /** @format double */ @IsNumber({}, { message: 'total must be a numeric value.' }) total!: number; - @ValidateNested({ message: 'items must be a valid nested object.' }) items!: InvoiceLineItem[]; + @ValidateNested({ message: 'items must be a valid nested object.' }) + items!: InvoiceLineItem[]; } export class InvoiceGridModel { @@ -126,13 +143,18 @@ export class InvoiceLineItem { export class Login { /** The email address or domain username */ - @MinLength(1, { message: 'email must be at least 1 characters long.' }) email!: string; + @MinLength(1, { message: 'email must be at least 1 characters long.' }) + email!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) password!: string; @IsOptional() - @MinLength(40, { message: 'invite_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'invite_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'invite_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'invite_token must be at most 40 characters long.' + }) invite_token?: string | null; } @@ -141,24 +163,34 @@ export class NewOrganization { } export class NewProject { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; @IsDefined({ message: 'name is required.' }) name!: string; - @IsDefined({ message: 'delete_bot_data_enabled is required.' }) delete_bot_data_enabled!: boolean; + @IsDefined({ message: 'delete_bot_data_enabled is required.' }) + delete_bot_data_enabled!: boolean; } export class NewToken { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsOptional() @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) default_project_id?: string | null; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsOptional() + @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) + default_project_id?: string | null; @IsDefined({ message: 'scopes is required.' }) scopes!: string[]; /** @format date-time */ - @IsOptional() @IsDate({ message: 'expires_utc must be a valid date and time.' }) expires_utc?: string | null; + @IsOptional() + @IsDate({ message: 'expires_utc must be a valid date and time.' }) + expires_utc?: string | null; @IsOptional() notes?: string | null; } export class NewWebHook { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsUrl({}, { message: 'url must be a valid URL.' }) url!: string; @IsDefined({ message: 'event_types is required.' }) event_types!: string[]; /** The schema version that should be used. */ @@ -166,30 +198,44 @@ export class NewWebHook { } export class NotificationSettings { - @IsDefined({ message: 'send_daily_summary is required.' }) send_daily_summary!: boolean; - @IsDefined({ message: 'report_new_errors is required.' }) report_new_errors!: boolean; - @IsDefined({ message: 'report_critical_errors is required.' }) report_critical_errors!: boolean; - @IsDefined({ message: 'report_event_regressions is required.' }) report_event_regressions!: boolean; - @IsDefined({ message: 'report_new_events is required.' }) report_new_events!: boolean; - @IsDefined({ message: 'report_critical_events is required.' }) report_critical_events!: boolean; + @IsDefined({ message: 'send_daily_summary is required.' }) + send_daily_summary!: boolean; + @IsDefined({ message: 'report_new_errors is required.' }) + report_new_errors!: boolean; + @IsDefined({ message: 'report_critical_errors is required.' }) + report_critical_errors!: boolean; + @IsDefined({ message: 'report_event_regressions is required.' }) + report_event_regressions!: boolean; + @IsDefined({ message: 'report_new_events is required.' }) + report_new_events!: boolean; + @IsDefined({ message: 'report_critical_events is required.' }) + report_critical_events!: boolean; } export class OAuthAccount { @IsDefined({ message: 'provider is required.' }) provider!: string; - @IsMongoId({ message: 'provider_user_id must be a valid ObjectId.' }) provider_user_id!: string; + @IsMongoId({ message: 'provider_user_id must be a valid ObjectId.' }) + provider_user_id!: string; @IsDefined({ message: 'username is required.' }) username!: string; - @ValidateNested({ message: 'extra_data must be a valid nested object.' }) extra_data!: Record; + @ValidateNested({ message: 'extra_data must be a valid nested object.' }) + extra_data!: Record; } export class PersistentEvent { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsMongoId({ message: 'stack_id must be a valid ObjectId.' }) stack_id!: string; - @IsDefined({ message: 'is_first_occurrence is required.' }) is_first_occurrence!: boolean; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsMongoId({ message: 'stack_id must be a valid ObjectId.' }) + stack_id!: string; + @IsDefined({ message: 'is_first_occurrence is required.' }) + is_first_occurrence!: boolean; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; - @ValidateNested({ message: 'idx must be a valid nested object.' }) idx!: Record; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; + @ValidateNested({ message: 'idx must be a valid nested object.' }) + idx!: Record; @IsOptional() type?: string | null; @IsOptional() source?: string | null; /** @format date-time */ @@ -198,16 +244,26 @@ export class PersistentEvent { @IsOptional() message?: string | null; @IsOptional() geo?: string | null; /** @format double */ - @IsOptional() @IsNumber({}, { message: 'value must be a numeric value.' }) value?: number | null; + @IsOptional() + @IsNumber({}, { message: 'value must be a numeric value.' }) + value?: number | null; /** @format int32 */ @IsOptional() @IsInt({ message: 'count must be a whole number.' }) count?: number | null; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; - @IsOptional() @IsMongoId({ message: 'reference_id must be a valid ObjectId.' }) reference_id?: string | null; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; + @IsOptional() + @IsMongoId({ message: 'reference_id must be a valid ObjectId.' }) + reference_id?: string | null; } export class ResetPasswordModel { - @MinLength(40, { message: 'password_reset_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'password_reset_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'password_reset_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'password_reset_token must be at most 40 characters long.' + }) password_reset_token!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) @@ -215,82 +271,71 @@ export class ResetPasswordModel { } export class Signup { - @MinLength(1, { message: 'name must be at least 1 characters long.' }) name!: string; + @MinLength(1, { message: 'name must be at least 1 characters long.' }) + name!: string; /** The email address or domain username */ - @MinLength(1, { message: 'email must be at least 1 characters long.' }) email!: string; + @MinLength(1, { message: 'email must be at least 1 characters long.' }) + email!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) password!: string; @IsOptional() - @MinLength(40, { message: 'invite_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'invite_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'invite_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'invite_token must be at most 40 characters long.' + }) invite_token?: string | null; } export class Stack { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsDefined({ message: 'type is required.' }) type!: string; - /** - * - * open - * fixed - * regressed - * snoozed - * ignored - * discarded - */ @IsDefined({ message: 'status is required.' }) status!: StackStatus; /** @format date-time */ - @IsOptional() @IsDate({ message: 'snooze_until_utc must be a valid date and time.' }) snooze_until_utc?: string | null; - @IsDefined({ message: 'signature_hash is required.' }) signature_hash!: string; - @ValidateNested({ message: 'signature_info must be a valid nested object.' }) signature_info!: Record; + @IsOptional() + @IsDate({ message: 'snooze_until_utc must be a valid date and time.' }) + snooze_until_utc?: string | null; + @IsDefined({ message: 'signature_hash is required.' }) + signature_hash!: string; + @ValidateNested({ message: 'signature_info must be a valid nested object.' }) + signature_info!: Record; @IsOptional() fixed_in_version?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'date_fixed must be a valid date and time.' }) date_fixed?: string | null; + @IsOptional() + @IsDate({ message: 'date_fixed must be a valid date and time.' }) + date_fixed?: string | null; @IsDefined({ message: 'title is required.' }) title!: string; /** @format int32 */ - @IsInt({ message: 'total_occurrences must be a whole number.' }) total_occurrences!: number; + @IsInt({ message: 'total_occurrences must be a whole number.' }) + total_occurrences!: number; /** @format date-time */ - @IsDate({ message: 'first_occurrence must be a valid date and time.' }) first_occurrence!: string; + @IsDate({ message: 'first_occurrence must be a valid date and time.' }) + first_occurrence!: string; /** @format date-time */ - @IsDate({ message: 'last_occurrence must be a valid date and time.' }) last_occurrence!: string; + @IsDate({ message: 'last_occurrence must be a valid date and time.' }) + last_occurrence!: string; @IsOptional() description?: string | null; - @IsDefined({ message: 'occurrences_are_critical is required.' }) occurrences_are_critical!: boolean; + @IsDefined({ message: 'occurrences_are_critical is required.' }) + occurrences_are_critical!: boolean; @IsDefined({ message: 'references is required.' }) references!: string[]; @IsDefined({ message: 'tags is required.' }) tags!: string[]; - @IsDefined({ message: 'duplicate_signature is required.' }) duplicate_signature!: string; + @IsDefined({ message: 'duplicate_signature is required.' }) + duplicate_signature!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; @IsDefined({ message: 'is_deleted is required.' }) is_deleted!: boolean; - @IsDefined({ message: 'allow_notifications is required.' }) allow_notifications!: boolean; -} - -/** - * - * - * open - * - * fixed - * - * regressed - * - * snoozed - * - * ignored - * - * discarded - */ -export enum StackStatus { - Open = 'open', - Fixed = 'fixed', - Regressed = 'regressed', - Snoozed = 'snoozed', - Ignored = 'ignored', - Discarded = 'discarded' + @IsDefined({ message: 'allow_notifications is required.' }) + allow_notifications!: boolean; } export class StringStringValuesKeyValuePair { @@ -303,7 +348,8 @@ export class StringValueFromBody { } export class TokenResult { - @MinLength(1, { message: 'token must be at least 1 characters long.' }) token!: string; + @MinLength(1, { message: 'token must be at least 1 characters long.' }) + token!: string; } export class UpdateEmailAddressResult { @@ -340,47 +386,69 @@ export class UsageInfo { export class User { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsOptional() password?: string | null; @IsOptional() salt?: string | null; @IsOptional() password_reset_token?: string | null; /** @format date-time */ - @IsDate({ message: 'password_reset_token_expiration must be a valid date and time.' }) password_reset_token_expiration!: string; - @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) o_auth_accounts!: OAuthAccount[]; - @MinLength(1, { message: 'full_name must be at least 1 characters long.' }) full_name!: string; + @IsDate({ + message: 'password_reset_token_expiration must be a valid date and time.' + }) + password_reset_token_expiration!: string; + @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) + o_auth_accounts!: OAuthAccount[]; + @MinLength(1, { message: 'full_name must be at least 1 characters long.' }) + full_name!: string; /** @format email */ @IsEmail({ require_tld: false }, { message: 'email_address must be a valid email address.' }) - @MinLength(1, { message: 'email_address must be at least 1 characters long.' }) + @MinLength(1, { + message: 'email_address must be at least 1 characters long.' + }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsOptional() verify_email_address_token?: string | null; /** @format date-time */ - @IsDate({ message: 'verify_email_address_token_expiration must be a valid date and time.' }) verify_email_address_token_expiration!: string; + @IsDate({ + message: 'verify_email_address_token_expiration must be a valid date and time.' + }) + verify_email_address_token_expiration!: string; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; } export class UserDescription { @IsOptional() email_address?: string | null; @IsOptional() description?: string | null; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; } export class ViewCurrentUser { @IsOptional() hash?: string | null; - @IsDefined({ message: 'has_local_account is required.' }) has_local_account!: boolean; - @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) o_auth_accounts!: OAuthAccount[]; + @IsDefined({ message: 'has_local_account is required.' }) + has_local_account!: boolean; + @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) + o_auth_accounts!: OAuthAccount[]; @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsDefined({ message: 'full_name is required.' }) full_name!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'is_invite is required.' }) is_invite!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; @@ -389,107 +457,159 @@ export class ViewCurrentUser { export class ViewOrganization { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; + /** @format date-time */ + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; @IsDefined({ message: 'name is required.' }) name!: string; @IsMongoId({ message: 'plan_id must be a valid ObjectId.' }) plan_id!: string; @IsDefined({ message: 'plan_name is required.' }) plan_name!: string; - @IsDefined({ message: 'plan_description is required.' }) plan_description!: string; - @IsOptional() 'card_last4'?: string | null; + @IsDefined({ message: 'plan_description is required.' }) + plan_description!: string; + @IsOptional() card_last4?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'subscribe_date must be a valid date and time.' }) subscribe_date?: string | null; + @IsOptional() + @IsDate({ message: 'subscribe_date must be a valid date and time.' }) + subscribe_date?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'billing_change_date must be a valid date and time.' }) billing_change_date?: string | null; - @IsOptional() @IsMongoId({ message: 'billing_changed_by_user_id must be a valid ObjectId.' }) billing_changed_by_user_id?: string | null; - /** - * - * 0 = Trialing - * 1 = Active - * 2 = PastDue - * 3 = Canceled - * 4 = Unpaid - */ - @IsDefined({ message: 'billing_status is required.' }) billing_status!: BillingStatus; + @IsOptional() + @IsDate({ message: 'billing_change_date must be a valid date and time.' }) + billing_change_date?: string | null; + @IsOptional() + @IsMongoId({ + message: 'billing_changed_by_user_id must be a valid ObjectId.' + }) + billing_changed_by_user_id?: string | null; + @IsDefined({ message: 'billing_status is required.' }) + billing_status!: BillingStatus; /** @format double */ - @IsNumber({}, { message: 'billing_price must be a numeric value.' }) billing_price!: number; + @IsNumber({}, { message: 'billing_price must be a numeric value.' }) + billing_price!: number; /** @format int32 */ - @IsInt({ message: 'max_events_per_month must be a whole number.' }) max_events_per_month!: number; + @IsInt({ message: 'max_events_per_month must be a whole number.' }) + max_events_per_month!: number; /** @format int32 */ - @IsInt({ message: 'bonus_events_per_month must be a whole number.' }) bonus_events_per_month!: number; + @IsInt({ message: 'bonus_events_per_month must be a whole number.' }) + bonus_events_per_month!: number; /** @format date-time */ - @IsOptional() @IsDate({ message: 'bonus_expiration must be a valid date and time.' }) bonus_expiration?: string | null; + @IsOptional() + @IsDate({ message: 'bonus_expiration must be a valid date and time.' }) + bonus_expiration?: string | null; /** @format int32 */ - @IsInt({ message: 'retention_days must be a whole number.' }) retention_days!: number; + @IsInt({ message: 'retention_days must be a whole number.' }) + retention_days!: number; @IsDefined({ message: 'is_suspended is required.' }) is_suspended!: boolean; @IsOptional() suspension_code?: string | null; @IsOptional() suspension_notes?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'suspension_date must be a valid date and time.' }) suspension_date?: string | null; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; + @IsOptional() + @IsDate({ message: 'suspension_date must be a valid date and time.' }) + suspension_date?: string | null; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; /** @format int32 */ @IsInt({ message: 'max_users must be a whole number.' }) max_users!: number; /** @format int32 */ - @IsInt({ message: 'max_projects must be a whole number.' }) max_projects!: number; + @IsInt({ message: 'max_projects must be a whole number.' }) + max_projects!: number; /** @format int64 */ - @IsInt({ message: 'project_count must be a whole number.' }) project_count!: number; + @IsInt({ message: 'project_count must be a whole number.' }) + project_count!: number; /** @format int64 */ - @IsInt({ message: 'stack_count must be a whole number.' }) stack_count!: number; + @IsInt({ message: 'stack_count must be a whole number.' }) + stack_count!: number; /** @format int64 */ - @IsInt({ message: 'event_count must be a whole number.' }) event_count!: number; - @ValidateNested({ message: 'invites must be a valid nested object.' }) invites!: Invite[]; - @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) usage_hours!: UsageHourInfo[]; - @ValidateNested({ message: 'usage must be a valid nested object.' }) usage!: UsageInfo[]; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsInt({ message: 'event_count must be a whole number.' }) + event_count!: number; + @ValidateNested({ message: 'invites must be a valid nested object.' }) + invites!: Invite[]; + @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) + usage_hours!: UsageHourInfo[]; + @ValidateNested({ message: 'usage must be a valid nested object.' }) + usage!: UsageInfo[]; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; @IsDefined({ message: 'is_throttled is required.' }) is_throttled!: boolean; - @IsDefined({ message: 'is_over_monthly_limit is required.' }) is_over_monthly_limit!: boolean; - @IsDefined({ message: 'is_over_request_limit is required.' }) is_over_request_limit!: boolean; + @IsDefined({ message: 'is_over_monthly_limit is required.' }) + is_over_monthly_limit!: boolean; + @IsDefined({ message: 'is_over_request_limit is required.' }) + is_over_request_limit!: boolean; } export class ViewProject { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsDefined({ message: 'organization_name is required.' }) organization_name!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsDefined({ message: 'organization_name is required.' }) + organization_name!: string; @IsDefined({ message: 'name is required.' }) name!: string; - @IsDefined({ message: 'delete_bot_data_enabled is required.' }) delete_bot_data_enabled!: boolean; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; - @IsDefined({ message: 'promoted_tabs is required.' }) promoted_tabs!: string[]; + @IsDefined({ message: 'delete_bot_data_enabled is required.' }) + delete_bot_data_enabled!: boolean; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; + @IsDefined({ message: 'promoted_tabs is required.' }) + promoted_tabs!: string[]; @IsOptional() is_configured?: boolean | null; /** @format int64 */ - @IsInt({ message: 'stack_count must be a whole number.' }) stack_count!: number; + @IsInt({ message: 'stack_count must be a whole number.' }) + stack_count!: number; /** @format int64 */ - @IsInt({ message: 'event_count must be a whole number.' }) event_count!: number; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; - @IsDefined({ message: 'has_slack_integration is required.' }) has_slack_integration!: boolean; - @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) usage_hours!: UsageHourInfo[]; - @ValidateNested({ message: 'usage must be a valid nested object.' }) usage!: UsageInfo[]; + @IsInt({ message: 'event_count must be a whole number.' }) + event_count!: number; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; + @IsDefined({ message: 'has_slack_integration is required.' }) + has_slack_integration!: boolean; + @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) + usage_hours!: UsageHourInfo[]; + @ValidateNested({ message: 'usage must be a valid nested object.' }) + usage!: UsageInfo[]; } export class ViewToken { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsOptional() @IsMongoId({ message: 'user_id must be a valid ObjectId.' }) user_id?: string | null; - @IsOptional() @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) default_project_id?: string | null; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsOptional() + @IsMongoId({ message: 'user_id must be a valid ObjectId.' }) + user_id?: string | null; + @IsOptional() + @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) + default_project_id?: string | null; @IsDefined({ message: 'scopes is required.' }) scopes!: string[]; /** @format date-time */ - @IsOptional() @IsDate({ message: 'expires_utc must be a valid date and time.' }) expires_utc?: string | null; + @IsOptional() + @IsDate({ message: 'expires_utc must be a valid date and time.' }) + expires_utc?: string | null; @IsOptional() notes?: string | null; @IsDefined({ message: 'is_disabled is required.' }) is_disabled!: boolean; @IsDefined({ message: 'is_suspended is required.' }) is_suspended!: boolean; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; } export class ViewUser { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsDefined({ message: 'full_name is required.' }) full_name!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'is_invite is required.' }) is_invite!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; @@ -497,14 +617,17 @@ export class ViewUser { export class WebHook { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsUrl({}, { message: 'url must be a valid URL.' }) url!: string; @IsDefined({ message: 'event_types is required.' }) event_types!: string[]; @IsDefined({ message: 'is_enabled is required.' }) is_enabled!: boolean; @IsDefined({ message: 'version is required.' }) version!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; } export class WorkInProgressResult { 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 01daebb6bc..f34a303b10 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 @@ -1,6 +1,8 @@ -{#if organizations && organizations.length > 1} +{#if organizations.length > 0 || isImpersonating} {#snippet child({ props })} - - - {getInitials(activeOrganization?.name)} + + + + {getInitials(activeOrganization?.name ?? '?')} +
    - {activeOrganization?.name} + {activeOrganization?.name ?? 'Select an organization'} + + + {#if isImpersonating} + Impersonating + {:else} + {activeOrganization?.plan_name ?? 'No organization selected'} + {/if} - {activeOrganization?.plan_name}
    @@ -65,11 +102,11 @@ sideOffset={4} > Organizations - {#if organizations} + {#if organizations.length > 0} {#each organizations as organization, index (organization.name)} onOrganizationSelected(organization)} - data-active={organization.id === selected} + data-active={organization.id === currentOrganizationId && !isImpersonating} class="data-[active=true]:bg-accent data-[active=true]:text-accent-foreground gap-2 p-2" > @@ -79,11 +116,22 @@ ⌘{index + 1} {/each} + {:else} + +
    +
    + No organizations available +
    {/if} {#if activeOrganization?.id} - +
    @@ -91,10 +139,9 @@ ⇧⌘go
    - {/if} - +
    @@ -102,6 +149,27 @@ ⇧⌘gn
    + + + Admin + {#if isImpersonating} + +
    +
    + Stop Impersonating +
    + {:else} + (openImpersonateDialog = true)} class="gap-2 p-2"> +
    +
    + Impersonate Organization +
    + {/if} +
    @@ -121,3 +189,11 @@
    {/if} + +{#if openImpersonateDialog} + o.id)} + /> +{/if} 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 69de4db86c..4294ef2eb3 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 @@ -1,13 +1,18 @@ {#if isLoading} @@ -99,12 +119,12 @@ - Account + Account ⇧⌘ga - Notifications + Notifications ⇧⌘gn {#if organization.current} @@ -112,7 +132,7 @@ @@ -124,7 +144,7 @@ @@ -135,7 +155,7 @@ {:else} - + Add organization ⇧⌘gn @@ -174,10 +194,27 @@ + + + {#if isImpersonating} + + + Stop Impersonating + + {:else} + (openImpersonateDialog = true)}> + + Impersonate Organization + + {/if} + - Log out + Log out ⇧⌘Q @@ -185,3 +222,7 @@ {/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 e9c7d65445..850e52fed0 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,17 +8,23 @@ import ChevronRight from '@lucide/svelte/icons/chevron-right'; import Settings from '@lucide/svelte/icons/settings-2'; - import type { NavigationItem } from '../../../routes.svelte'; + import type { NavigationItem, NavigationItemContext } from '../../../routes.svelte'; type Props = ComponentProps & { footer?: Snippet; header?: Snippet; + impersonating?: boolean; routes: NavigationItem[]; }; - let { footer, header, routes, ...props }: Props = $props(); + let { footer, header, impersonating = false, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); - const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); + + // Settings routes need additional filtering based on navigation context + const navigationContext: NavigationItemContext = $derived({ authenticated: true, impersonating }); + const settingsRoutes = $derived( + routes.filter((route) => route.group === 'Settings').filter((route) => (route.show ? route.show(navigationContext) : true)) + ); const settingsIsActive = $derived(settingsRoutes.some((route) => route.href === page.url.pathname)); const sidebar = useSidebar(); 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 a71b22cc8d..c18d465817 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 @@ -6,12 +6,14 @@ let { open = $bindable(), routes }: { open: boolean; routes: NavigationItem[] } = $props(); - const groupedRoutes: Record = Object.entries(Object.groupBy(routes, (item: NavigationItem) => item.group)).reduce( - (acc, [key, value]) => { - if (value) acc[key] = value; - return acc; - }, - {} as Record + const groupedRoutes = $derived( + Object.entries(Object.groupBy(routes, (item: NavigationItem) => item.group)).reduce( + (acc, [key, value]) => { + if (value) acc[key] = value; + return acc; + }, + {} as Record + ) ); function closeCommandWindow() { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 82545f8284..075a128cb6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -8,7 +8,7 @@ import { env } from '$env/dynamic/public'; import { accessToken, gotoLogin } from '$features/auth/index.svelte'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; - import { getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; + import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; import OrganizationNotifications from '$features/organizations/components/organization-notifications.svelte'; import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte'; import { invalidateProjectQueries } from '$features/projects/api.svelte'; @@ -121,6 +121,11 @@ }); $effect(() => { + // Direct read of accessToken.current establishes reactive dependency, working around PersistedState reactivity bug + const currentToken = accessToken.current; + // Track page.url to ensure effect re-runs on navigation + void page.url.pathname; + function handleKeydown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -128,7 +133,8 @@ } } - if (!isAuthenticated) { + // Check token directly instead of using derived isAuthenticated + if (!currentToken) { queryClient.cancelQueries(); queryClient.invalidateQueries(); @@ -160,26 +166,53 @@ const meQuery = getMeQuery(); const gravatar = getGravatarFromCurrentUser(meQuery); + const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); const organizationsQuery = getOrganizationsQuery({}); + const organizations = $derived(organizationsQuery.data?.data ?? []); + + const impersonatingOrganizationId = $derived.by(() => { + const isUserOrganization = meQuery.data?.organization_ids.includes(organization.current ?? ''); + return isUserOrganization ? undefined : organization.current; + }); + + const impersonatedOrganizationQuery = getOrganizationQuery({ + route: { + get id() { + return impersonatingOrganizationId; + } + } + }); + const impersonatedOrganization = $derived(impersonatingOrganizationId ? impersonatedOrganizationQuery.data : undefined); + + // Simple organization selection - pick first available if none selected $effect(() => { if (!organizationsQuery.isSuccess) { return; } - if (!organizationsQuery.data.data || organizationsQuery.data.data.length === 0) { + const hasOrganizations = organizations.length > 0; + if (!hasOrganizations) { organization.current = undefined; - goto(resolve(`/(app)/organization/add`)); + + // Redirect non-admins to add organization page + if (!isGlobalAdmin && !organizationsQuery.isLoading) { + goto(resolve(`/(app)/organization/add`)); + } + return; } - if (!organizationsQuery.data.data.find((org) => org.id === organization.current)) { - organization.current = organizationsQuery.data.data[0]!.id; + // Select first organization if none selected + if (!organization.current) { + organization.current = organizations[0]!.id; } }); + const isImpersonating = $derived(!!impersonatedOrganization); + const filteredRoutes = $derived.by(() => { - const context: NavigationItemContext = { authenticated: isAuthenticated, user: meQuery.data }; + const context: NavigationItemContext = { authenticated: isAuthenticated, impersonating: isImpersonating, user: meQuery.data }; return routes().filter((route) => (route.show ? route.show(context) : true)); }); @@ -191,18 +224,19 @@ {#if isAuthenticated} - + {#snippet header()} {/snippet} {#snippet footer()} - + {/snippet}
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 13eef9e694..403c2a72d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -77,6 +77,7 @@ } }); + // NOTE: This might be applying query string parameters when redirecting away. watch( () => organization.current, () => { @@ -302,7 +303,11 @@ Event Details Event Details diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte index b116860e46..30a65c3125 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/appearance/(components)/theme-preview.svelte @@ -7,7 +7,7 @@ let { mode }: Props = $props(); - let resolvedMode = $state(mode !== 'system' ? mode : (systemPrefersMode.current ?? 'dark')); + const resolvedMode = $derived(mode !== 'system' ? mode : (systemPrefersMode.current ?? 'dark')); {#if resolvedMode === 'light'} @@ -16,7 +16,7 @@
    -
    +
    @@ -36,7 +36,7 @@
    -
    +
    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 50734e1ccb..4e7e749b43 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,4 +1,5 @@