diff --git a/.github/skills/accessibility/SKILL.md b/.agents/skills/accessibility/SKILL.md similarity index 86% rename from .github/skills/accessibility/SKILL.md rename to .agents/skills/accessibility/SKILL.md index ac21a4157e..0db396127f 100644 --- a/.github/skills/accessibility/SKILL.md +++ b/.agents/skills/accessibility/SKILL.md @@ -1,10 +1,11 @@ --- -name: Accessibility -description: | - WCAG 2.2 AA accessibility standards for the Exceptionless frontend. Semantic HTML, keyboard - navigation, ARIA patterns, focus management, and form accessibility. - Keywords: WCAG, accessibility, a11y, ARIA, semantic HTML, keyboard navigation, focus management, - screen reader, alt text, aria-label, aria-describedby, skip links, focus trap +name: accessibility +description: > + Use this skill when building or reviewing frontend components for accessibility compliance. + Covers WCAG 2.2 AA standards including semantic HTML, keyboard navigation, ARIA patterns, + focus management, screen reader support, and form accessibility. Apply when creating new + UI components, fixing accessibility bugs, adding skip links or focus traps, or ensuring + inclusive markup — even if the user doesn't explicitly mention "a11y" or "WCAG." --- # Accessibility (WCAG 2.2 AA) @@ -110,7 +111,7 @@ description: | // When dialog opens, focus first interactive element $effect(() => { if (open) { - dialogRef?.querySelector('input, button')?.focus(); + dialogRef?.querySelector("input, button")?.focus(); } }); @@ -240,10 +241,10 @@ npm run test:e2e ```typescript // In Playwright tests -import AxeBuilder from '@axe-core/playwright'; +import AxeBuilder from "@axe-core/playwright"; -test('page is accessible', async ({ page }) => { - await page.goto('/dashboard'); +test("page is accessible", async ({ page }) => { + await page.goto("/dashboard"); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); diff --git a/.agents/skills/agent-browser/SKILL.md b/.agents/skills/agent-browser/SKILL.md index ab3ea3c6b9..8cd7b7a79b 100644 --- a/.agents/skills/agent-browser/SKILL.md +++ b/.agents/skills/agent-browser/SKILL.md @@ -1,339 +1,508 @@ --- name: agent-browser -description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages. -allowed-tools: Bash(agent-browser:*) +description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. +allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*) --- # Browser Automation with agent-browser -## Quick start +## Core Workflow + +Every browser automation follows this pattern: + +1. **Navigate**: `agent-browser open ` +2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`) +3. **Interact**: Use refs to click, fill, select +4. **Re-snapshot**: After navigation or DOM changes, get fresh refs ```bash -agent-browser open # Navigate to page -agent-browser snapshot -i # Get interactive elements with refs -agent-browser click @e1 # Click element by ref -agent-browser fill @e2 "text" # Fill input by ref -agent-browser close # Close browser +agent-browser open https://example.com/form +agent-browser snapshot -i +# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit" + +agent-browser fill @e1 "user@example.com" +agent-browser fill @e2 "password123" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i # Check result ``` -## Core workflow +## Command Chaining -1. Navigate: `agent-browser open ` -2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) -3. Interact using refs from the snapshot -4. Re-snapshot after navigation or significant DOM changes +Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls. -## Commands +```bash +# Chain open + wait + snapshot in one call +agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i -### Navigation +# Chain multiple interactions +agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3 -```bash -agent-browser open # Navigate to URL (aliases: goto, navigate) - # Supports: https://, http://, file://, about:, data:// - # Auto-prepends https:// if no protocol given -agent-browser back # Go back -agent-browser forward # Go forward -agent-browser reload # Reload page -agent-browser close # Close browser (aliases: quit, exit) -agent-browser connect 9222 # Connect to browser via CDP port +# Navigate and capture +agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png ``` -### Snapshot (page analysis) +**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs). + +## Essential Commands ```bash -agent-browser snapshot # Full accessibility tree -agent-browser snapshot -i # Interactive elements only (recommended) -agent-browser snapshot -c # Compact output -agent-browser snapshot -d 3 # Limit depth to 3 -agent-browser snapshot -s "#main" # Scope to CSS selector +# Navigation +agent-browser open # Navigate (aliases: goto, navigate) +agent-browser close # Close browser + +# Snapshot +agent-browser snapshot -i # Interactive elements with refs (recommended) +agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer) +agent-browser snapshot -s "#selector" # Scope to CSS selector + +# Interaction (use @refs from snapshot) +agent-browser click @e1 # Click element +agent-browser click @e1 --new-tab # Click and open in new tab +agent-browser fill @e2 "text" # Clear and type text +agent-browser type @e2 "text" # Type without clearing +agent-browser select @e1 "option" # Select dropdown option +agent-browser check @e1 # Check checkbox +agent-browser press Enter # Press key +agent-browser keyboard type "text" # Type at current focus (no selector) +agent-browser keyboard inserttext "text" # Insert without key events +agent-browser scroll down 500 # Scroll page +agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container + +# Get information +agent-browser get text @e1 # Get element text +agent-browser get url # Get current URL +agent-browser get title # Get page title + +# Wait +agent-browser wait @e1 # Wait for element +agent-browser wait --load networkidle # Wait for network idle +agent-browser wait --url "**/page" # Wait for URL pattern +agent-browser wait 2000 # Wait milliseconds + +# Downloads +agent-browser download @e1 ./file.pdf # Click element to trigger download +agent-browser wait --download ./output.zip # Wait for any download to complete +agent-browser --download-path ./downloads open # Set default download directory + +# Capture +agent-browser screenshot # Screenshot to temp dir +agent-browser screenshot --full # Full page screenshot +agent-browser screenshot --annotate # Annotated screenshot with numbered element labels +agent-browser pdf output.pdf # Save as PDF + +# Diff (compare page states) +agent-browser diff snapshot # Compare current vs last snapshot +agent-browser diff snapshot --baseline before.txt # Compare current vs saved file +agent-browser diff screenshot --baseline before.png # Visual pixel diff +agent-browser diff url # Compare two pages +agent-browser diff url --wait-until networkidle # Custom wait strategy +agent-browser diff url --selector "#main" # Scope to element ``` -### Interactions (use @refs from snapshot) +## Common Patterns + +### Form Submission ```bash -agent-browser click @e1 # Click -agent-browser dblclick @e1 # Double-click -agent-browser focus @e1 # Focus element -agent-browser fill @e2 "text" # Clear and type -agent-browser type @e2 "text" # Type without clearing -agent-browser press Enter # Press key (alias: key) -agent-browser press Control+a # Key combination -agent-browser keydown Shift # Hold key down -agent-browser keyup Shift # Release key -agent-browser hover @e1 # Hover -agent-browser check @e1 # Check checkbox -agent-browser uncheck @e1 # Uncheck checkbox -agent-browser select @e1 "value" # Select dropdown option -agent-browser select @e1 "a" "b" # Select multiple options -agent-browser scroll down 500 # Scroll page (default: down 300px) -agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto) -agent-browser drag @e1 @e2 # Drag and drop -agent-browser upload @e1 file.pdf # Upload files +agent-browser open https://example.com/signup +agent-browser snapshot -i +agent-browser fill @e1 "Jane Doe" +agent-browser fill @e2 "jane@example.com" +agent-browser select @e3 "California" +agent-browser check @e4 +agent-browser click @e5 +agent-browser wait --load networkidle ``` -### Get information +### Authentication with Auth Vault (Recommended) ```bash -agent-browser get text @e1 # Get element text -agent-browser get html @e1 # Get innerHTML -agent-browser get value @e1 # Get input value -agent-browser get attr @e1 href # Get attribute -agent-browser get title # Get page title -agent-browser get url # Get current URL -agent-browser get count ".item" # Count matching elements -agent-browser get box @e1 # Get bounding box -agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.) +# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY) +# Recommended: pipe password via stdin to avoid shell history exposure +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + +# Login using saved profile (LLM never sees password) +agent-browser auth login github + +# List/show/delete profiles +agent-browser auth list +agent-browser auth show github +agent-browser auth delete github ``` -### Check state +### Authentication with State Persistence ```bash -agent-browser is visible @e1 # Check if visible -agent-browser is enabled @e1 # Check if enabled -agent-browser is checked @e1 # Check if checked +# Login once and save state +agent-browser open https://app.example.com/login +agent-browser snapshot -i +agent-browser fill @e1 "$USERNAME" +agent-browser fill @e2 "$PASSWORD" +agent-browser click @e3 +agent-browser wait --url "**/dashboard" +agent-browser state save auth.json + +# Reuse in future sessions +agent-browser state load auth.json +agent-browser open https://app.example.com/dashboard ``` -### Screenshots & PDF +### Session Persistence ```bash -agent-browser screenshot # Save to a temporary directory -agent-browser screenshot path.png # Save to a specific path -agent-browser screenshot --full # Full page -agent-browser pdf output.pdf # Save as PDF +# Auto-save/restore cookies and localStorage across browser restarts +agent-browser --session-name myapp open https://app.example.com/login +# ... login flow ... +agent-browser close # State auto-saved to ~/.agent-browser/sessions/ + +# Next time, state is auto-loaded +agent-browser --session-name myapp open https://app.example.com/dashboard + +# Encrypt state at rest +export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32) +agent-browser --session-name secure open https://app.example.com + +# Manage saved states +agent-browser state list +agent-browser state show myapp-default.json +agent-browser state clear myapp +agent-browser state clean --older-than 7 ``` -### Video recording +### Data Extraction ```bash -agent-browser record start ./demo.webm # Start recording (uses current URL + state) -agent-browser click @e1 # Perform actions -agent-browser record stop # Stop and save video -agent-browser record restart ./take2.webm # Stop current + start new recording -``` +agent-browser open https://example.com/products +agent-browser snapshot -i +agent-browser get text @e5 # Get specific element text +agent-browser get text body > page.txt # Get all page text -Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it -automatically returns to your current page. For smooth demos, explore first, then start recording. +# JSON output for parsing +agent-browser snapshot -i --json +agent-browser get text @e1 --json +``` -### Wait +### Parallel Sessions ```bash -agent-browser wait @e1 # Wait for element -agent-browser wait 2000 # Wait milliseconds -agent-browser wait --text "Success" # Wait for text (or -t) -agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u) -agent-browser wait --load networkidle # Wait for network idle (or -l) -agent-browser wait --fn "window.ready" # Wait for JS condition (or -f) +agent-browser --session site1 open https://site-a.com +agent-browser --session site2 open https://site-b.com + +agent-browser --session site1 snapshot -i +agent-browser --session site2 snapshot -i + +agent-browser session list ``` -### Mouse control +### Connect to Existing Chrome ```bash -agent-browser mouse move 100 200 # Move mouse -agent-browser mouse down left # Press button -agent-browser mouse up left # Release button -agent-browser mouse wheel 100 # Scroll wheel +# Auto-discover running Chrome with remote debugging enabled +agent-browser --auto-connect open https://example.com +agent-browser --auto-connect snapshot + +# Or with explicit CDP port +agent-browser --cdp 9222 snapshot ``` -### Semantic locators (alternative to refs) +### Color Scheme (Dark Mode) ```bash -agent-browser find role button click --name "Submit" -agent-browser find text "Sign In" click -agent-browser find text "Sign In" click --exact # Exact match only -agent-browser find label "Email" fill "user@test.com" -agent-browser find placeholder "Search" type "query" -agent-browser find alt "Logo" click -agent-browser find title "Close" click -agent-browser find testid "submit-btn" click -agent-browser find first ".item" click -agent-browser find last ".item" click -agent-browser find nth 2 "a" hover +# Persistent dark mode via flag (applies to all pages and new tabs) +agent-browser --color-scheme dark open https://example.com + +# Or via environment variable +AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com + +# Or set during session (persists for subsequent commands) +agent-browser set media dark ``` -### Browser settings +### Visual Browser (Debugging) ```bash -agent-browser set viewport 1920 1080 # Set viewport size -agent-browser set device "iPhone 14" # Emulate device -agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation) -agent-browser set offline on # Toggle offline mode -agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers -agent-browser set credentials user pass # HTTP basic auth (alias: auth) -agent-browser set media dark # Emulate color scheme -agent-browser set media light reduced-motion # Light mode + reduced motion +agent-browser --headed open https://example.com +agent-browser highlight @e1 # Highlight element +agent-browser record start demo.webm # Record session +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop trace.json # Stop and save profile (path optional) ``` -### Cookies & Storage +Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode. + +### Local Files (PDFs, HTML) ```bash -agent-browser cookies # Get all cookies -agent-browser cookies set name value # Set cookie -agent-browser cookies clear # Clear cookies -agent-browser storage local # Get all localStorage -agent-browser storage local key # Get specific key -agent-browser storage local set k v # Set value -agent-browser storage local clear # Clear all +# Open local files with file:// URLs +agent-browser --allow-file-access open file:///path/to/document.pdf +agent-browser --allow-file-access open file:///path/to/page.html +agent-browser screenshot output.png ``` -### Network +### iOS Simulator (Mobile Safari) ```bash -agent-browser network route # Intercept requests -agent-browser network route --abort # Block requests -agent-browser network route --body '{}' # Mock response -agent-browser network unroute [url] # Remove routes -agent-browser network requests # View tracked requests -agent-browser network requests --filter api # Filter requests +# List available iOS simulators +agent-browser device list + +# Launch Safari on a specific device +agent-browser -p ios --device "iPhone 16 Pro" open https://example.com + +# Same workflow as desktop - snapshot, interact, re-snapshot +agent-browser -p ios snapshot -i +agent-browser -p ios tap @e1 # Tap (alias for click) +agent-browser -p ios fill @e2 "text" +agent-browser -p ios swipe up # Mobile-specific gesture + +# Take screenshot +agent-browser -p ios screenshot mobile.png + +# Close session (shuts down simulator) +agent-browser -p ios close ``` -### Tabs & Windows +**Requirements:** macOS with Xcode, Appium (`npm install -g appium && appium driver install xcuitest`) + +**Real devices:** Works with physical iOS devices if pre-configured. Use `--device ""` where UDID is from `xcrun xctrace list devices`. + +## Security + +All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. + +### Content Boundaries (Recommended for AI Agents) + +Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content: ```bash -agent-browser tab # List tabs -agent-browser tab new [url] # New tab -agent-browser tab 2 # Switch to tab by index -agent-browser tab close # Close current tab -agent-browser tab close 2 # Close tab by index -agent-browser window new # New window +export AGENT_BROWSER_CONTENT_BOUNDARIES=1 +agent-browser snapshot +# Output: +# --- AGENT_BROWSER_PAGE_CONTENT nonce= origin=https://example.com --- +# [accessibility tree] +# --- END_AGENT_BROWSER_PAGE_CONTENT nonce= --- ``` -### Frames +### Domain Allowlist + +Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on: ```bash -agent-browser frame "#iframe" # Switch to iframe -agent-browser frame main # Back to main frame +export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" +agent-browser open https://example.com # OK +agent-browser open https://malicious.com # Blocked ``` -### Dialogs +### Action Policy + +Use a policy file to gate destructive actions: ```bash -agent-browser dialog accept [text] # Accept dialog -agent-browser dialog dismiss # Dismiss dialog +export AGENT_BROWSER_ACTION_POLICY=./policy.json +``` + +Example `policy.json`: +```json +{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]} ``` -### JavaScript +Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies. + +### Output Limits + +Prevent context flooding from large pages: ```bash -agent-browser eval "document.title" # Run JavaScript +export AGENT_BROWSER_MAX_OUTPUT=50000 ``` -## Global options +## Diffing (Verifying Changes) + +Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session. ```bash -agent-browser --session ... # Isolated browser session -agent-browser --json ... # JSON output for parsing -agent-browser --headed ... # Show browser window (not headless) -agent-browser --full ... # Full page screenshot (-f) -agent-browser --cdp ... # Connect via Chrome DevTools Protocol -agent-browser -p ... # Cloud browser provider (--provider) -agent-browser --proxy ... # Use proxy server -agent-browser --headers ... # HTTP headers scoped to URL's origin -agent-browser --executable-path

# Custom browser executable -agent-browser --extension ... # Load browser extension (repeatable) -agent-browser --help # Show help (-h) -agent-browser --version # Show version (-V) -agent-browser --help # Show detailed help for a command +# Typical workflow: snapshot -> action -> diff +agent-browser snapshot -i # Take baseline snapshot +agent-browser click @e2 # Perform action +agent-browser diff snapshot # See what changed (auto-compares to last snapshot) ``` -### Proxy support +For visual regression testing or monitoring: ```bash -agent-browser --proxy http://proxy.com:8080 open example.com -agent-browser --proxy http://user:pass@proxy.com:8080 open example.com -agent-browser --proxy socks5://proxy.com:1080 open example.com +# Save a baseline screenshot, then compare later +agent-browser screenshot baseline.png +# ... time passes or changes are made ... +agent-browser diff screenshot --baseline baseline.png + +# Compare staging vs production +agent-browser diff url https://staging.example.com https://prod.example.com --screenshot ``` -## Environment variables +`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage. + +## Timeouts and Slow Pages + +The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout: ```bash -AGENT_BROWSER_SESSION="mysession" # Default session name -AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path -AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths -AGENT_BROWSER_PROVIDER="your-cloud-browser-provider" # Cloud browser provider (select browseruse or browserbase) -AGENT_BROWSER_STREAM_PORT="9223" # WebSocket streaming port -AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location (for daemon.js) +# Wait for network activity to settle (best for slow pages) +agent-browser wait --load networkidle + +# Wait for a specific element to appear +agent-browser wait "#content" +agent-browser wait @e1 + +# Wait for a specific URL pattern (useful after redirects) +agent-browser wait --url "**/dashboard" + +# Wait for a JavaScript condition +agent-browser wait --fn "document.readyState === 'complete'" + +# Wait a fixed duration (milliseconds) as a last resort +agent-browser wait 5000 ``` -## Example: Form submission +When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait ` or `wait @ref`. + +## Session Management and Cleanup + +When running multiple agents or automations concurrently, always use named sessions to avoid conflicts: ```bash -agent-browser open https://example.com/form -agent-browser snapshot -i -# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] +# Each agent gets its own isolated session +agent-browser --session agent1 open site-a.com +agent-browser --session agent2 open site-b.com -agent-browser fill @e1 "user@example.com" -agent-browser fill @e2 "password123" -agent-browser click @e3 -agent-browser wait --load networkidle -agent-browser snapshot -i # Check result +# Check active sessions +agent-browser session list ``` -## Example: Authentication with saved state +Always close your browser session when done to avoid leaked processes: ```bash -# Login once -agent-browser open https://app.example.com/login -agent-browser snapshot -i -agent-browser fill @e1 "username" -agent-browser fill @e2 "password" -agent-browser click @e3 -agent-browser wait --url "**/dashboard" -agent-browser state save auth.json +agent-browser close # Close default session +agent-browser --session agent1 close # Close specific session +``` -# Later sessions: load saved state -agent-browser state load auth.json -agent-browser open https://app.example.com/dashboard +If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work. + +## Ref Lifecycle (Important) + +Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after: + +- Clicking links or buttons that navigate +- Form submissions +- Dynamic content loading (dropdowns, modals) + +```bash +agent-browser click @e5 # Navigates to new page +agent-browser snapshot -i # MUST re-snapshot +agent-browser click @e1 # Use new refs ``` -## Sessions (parallel browsers) +## Annotated Screenshots (Vision Mode) + +Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot. ```bash -agent-browser --session test1 open site-a.com -agent-browser --session test2 open site-b.com -agent-browser session list +agent-browser screenshot --annotate +# Output includes the image path and a legend: +# [1] @e1 button "Submit" +# [2] @e2 link "Home" +# [3] @e3 textbox "Email" +agent-browser click @e2 # Click using ref from annotated screenshot ``` -## JSON output (for parsing) +Use annotated screenshots when: +- The page has unlabeled icon buttons or visual-only elements +- You need to verify visual layout or styling +- Canvas or chart elements are present (invisible to text snapshots) +- You need spatial reasoning about element positions -Add `--json` for machine-readable output: +## Semantic Locators (Alternative to Refs) + +When refs are unavailable or unreliable, use semantic locators: ```bash -agent-browser snapshot -i --json -agent-browser get text @e1 --json +agent-browser find text "Sign In" click +agent-browser find label "Email" fill "user@test.com" +agent-browser find role button click --name "Submit" +agent-browser find placeholder "Search" type "query" +agent-browser find testid "submit-btn" click ``` -## Debugging +## JavaScript Evaluation (eval) + +Use `eval` to run JavaScript in the browser context. **Shell quoting can corrupt complex expressions** -- use `--stdin` or `-b` to avoid issues. ```bash -agent-browser --headed open example.com # Show browser window -agent-browser --cdp 9222 snapshot # Connect via CDP port -agent-browser connect 9222 # Alternative: connect command -agent-browser console # View console messages -agent-browser console --clear # Clear console -agent-browser errors # View page errors -agent-browser errors --clear # Clear errors -agent-browser highlight @e1 # Highlight element -agent-browser trace start # Start recording trace -agent-browser trace stop trace.zip # Stop and save trace -agent-browser record start ./debug.webm # Record video from current page -agent-browser record stop # Save recording +# Simple expressions work with regular quoting +agent-browser eval 'document.title' +agent-browser eval 'document.querySelectorAll("img").length' + +# Complex JS: use --stdin with heredoc (RECOMMENDED) +agent-browser eval --stdin <<'EVALEOF' +JSON.stringify( + Array.from(document.querySelectorAll("img")) + .filter(i => !i.alt) + .map(i => ({ src: i.src.split("/").pop(), width: i.width })) +) +EVALEOF + +# Alternative: base64 encoding (avoids all shell escaping issues) +agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)" ``` -## Deep-dive documentation +**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely. + +**Rules of thumb:** +- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine +- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'` +- Programmatic/generated scripts -> use `eval -b` with base64 + +## Configuration File -For detailed patterns and best practices, see: +Create `agent-browser.json` in the project root for persistent settings: -| Reference | Description | +```json +{ + "headed": true, + "proxy": "http://localhost:8080", + "profile": "./browser-data" +} +``` + +Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config ` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced. + +## Deep-Dive Documentation + +| Reference | When to Use | |-----------|-------------| +| [references/commands.md](references/commands.md) | Full command reference with all options | | [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting | | [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping | | [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse | | [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation | +| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis | | [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies | -## Ready-to-use templates +## Experimental: Native Mode + +agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet. + +```bash +# Enable via flag +agent-browser --native open example.com + +# Enable via environment variable (avoids passing --native every time) +export AGENT_BROWSER_NATIVE=1 +agent-browser open example.com +``` + +The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session. -Executable workflow scripts for common patterns: +## Ready-to-Use Templates | Template | Description | |----------|-------------| @@ -341,16 +510,8 @@ Executable workflow scripts for common patterns: | [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state | | [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots | -Usage: ```bash ./templates/form-automation.sh https://example.com/form ./templates/authenticated-session.sh https://app.example.com/login ./templates/capture-workflow.sh https://example.com ./output ``` - -## HTTPS Certificate Errors - -For sites with self-signed or invalid certificates: -```bash -agent-browser open https://localhost:8443 --ignore-https-errors -``` diff --git a/.agents/skills/agent-browser/references/authentication.md b/.agents/skills/agent-browser/references/authentication.md index 5d801f6a82..12ef5e41be 100644 --- a/.agents/skills/agent-browser/references/authentication.md +++ b/.agents/skills/agent-browser/references/authentication.md @@ -1,6 +1,20 @@ # Authentication Patterns -Patterns for handling login flows, session persistence, and authenticated browsing. +Login flows, session persistence, OAuth, 2FA, and authenticated browsing. + +**Related**: [session-management.md](session-management.md) for state persistence details, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Basic Login Flow](#basic-login-flow) +- [Saving Authentication State](#saving-authentication-state) +- [Restoring Authentication](#restoring-authentication) +- [OAuth / SSO Flows](#oauth--sso-flows) +- [Two-Factor Authentication](#two-factor-authentication) +- [HTTP Basic Auth](#http-basic-auth) +- [Cookie-Based Auth](#cookie-based-auth) +- [Token Refresh Handling](#token-refresh-handling) +- [Security Best Practices](#security-best-practices) ## Basic Login Flow diff --git a/.github/.agents/skills/agent-browser/references/commands.md b/.agents/skills/agent-browser/references/commands.md similarity index 97% rename from .github/.agents/skills/agent-browser/references/commands.md rename to .agents/skills/agent-browser/references/commands.md index 8744accf75..e77196cdd3 100644 --- a/.github/.agents/skills/agent-browser/references/commands.md +++ b/.agents/skills/agent-browser/references/commands.md @@ -29,6 +29,7 @@ agent-browser snapshot -s "#main" # Scope to CSS selector ```bash agent-browser click @e1 # Click +agent-browser click @e1 --new-tab # Click and open in new tab agent-browser dblclick @e1 # Double-click agent-browser focus @e1 # Focus element agent-browser fill @e2 "text" # Clear and type @@ -223,6 +224,7 @@ agent-browser --full ... # Full page screenshot (-f) agent-browser --cdp ... # Connect via Chrome DevTools Protocol agent-browser -p ... # Cloud browser provider (--provider) agent-browser --proxy ... # Use proxy server +agent-browser --proxy-bypass # Hosts to bypass proxy agent-browser --headers ... # HTTP headers scoped to URL's origin agent-browser --executable-path

# Custom browser executable agent-browser --extension ... # Load browser extension (repeatable) @@ -245,6 +247,8 @@ agent-browser errors --clear # Clear errors agent-browser highlight @e1 # Highlight element agent-browser trace start # Start recording trace agent-browser trace stop trace.zip # Stop and save trace +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop trace.json # Stop and save profile ``` ## Environment Variables diff --git a/.agents/skills/agent-browser/references/profiling.md b/.agents/skills/agent-browser/references/profiling.md new file mode 100644 index 0000000000..bd47eaa0ce --- /dev/null +++ b/.agents/skills/agent-browser/references/profiling.md @@ -0,0 +1,120 @@ +# Profiling + +Capture Chrome DevTools performance profiles during browser automation for performance analysis. + +**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Basic Profiling](#basic-profiling) +- [Profiler Commands](#profiler-commands) +- [Categories](#categories) +- [Use Cases](#use-cases) +- [Output Format](#output-format) +- [Viewing Profiles](#viewing-profiles) +- [Limitations](#limitations) + +## Basic Profiling + +```bash +# Start profiling +agent-browser profiler start + +# Perform actions +agent-browser navigate https://example.com +agent-browser click "#button" +agent-browser wait 1000 + +# Stop and save +agent-browser profiler stop ./trace.json +``` + +## Profiler Commands + +```bash +# Start profiling with default categories +agent-browser profiler start + +# Start with custom trace categories +agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing" + +# Stop profiling and save to file +agent-browser profiler stop ./trace.json +``` + +## Categories + +The `--categories` flag accepts a comma-separated list of Chrome trace categories. Default categories include: + +- `devtools.timeline` -- standard DevTools performance traces +- `v8.execute` -- time spent running JavaScript +- `blink` -- renderer events +- `blink.user_timing` -- `performance.mark()` / `performance.measure()` calls +- `latencyInfo` -- input-to-latency tracking +- `renderer.scheduler` -- task scheduling and execution +- `toplevel` -- broad-spectrum basic events + +Several `disabled-by-default-*` categories are also included for detailed timeline, call stack, and V8 CPU profiling data. + +## Use Cases + +### Diagnosing Slow Page Loads + +```bash +agent-browser profiler start +agent-browser navigate https://app.example.com +agent-browser wait --load networkidle +agent-browser profiler stop ./page-load-profile.json +``` + +### Profiling User Interactions + +```bash +agent-browser navigate https://app.example.com +agent-browser profiler start +agent-browser click "#submit" +agent-browser wait 2000 +agent-browser profiler stop ./interaction-profile.json +``` + +### CI Performance Regression Checks + +```bash +#!/bin/bash +agent-browser profiler start +agent-browser navigate https://app.example.com +agent-browser wait --load networkidle +agent-browser profiler stop "./profiles/build-${BUILD_ID}.json" +``` + +## Output Format + +The output is a JSON file in Chrome Trace Event format: + +```json +{ + "traceEvents": [ + { "cat": "devtools.timeline", "name": "RunTask", "ph": "X", "ts": 12345, "dur": 100, ... }, + ... + ], + "metadata": { + "clock-domain": "LINUX_CLOCK_MONOTONIC" + } +} +``` + +The `metadata.clock-domain` field is set based on the host platform (Linux or macOS). On Windows it is omitted. + +## Viewing Profiles + +Load the output JSON file in any of these tools: + +- **Chrome DevTools**: Performance panel > Load profile (Ctrl+Shift+I > Performance) +- **Perfetto UI**: https://ui.perfetto.dev/ -- drag and drop the JSON file +- **Trace Viewer**: `chrome://tracing` in any Chromium browser + +## Limitations + +- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit. +- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest. +- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail. diff --git a/.agents/skills/agent-browser/references/proxy-support.md b/.agents/skills/agent-browser/references/proxy-support.md index 05fcec26d9..e86a8fe33e 100644 --- a/.agents/skills/agent-browser/references/proxy-support.md +++ b/.agents/skills/agent-browser/references/proxy-support.md @@ -1,13 +1,29 @@ # Proxy Support -Configure proxy servers for browser automation, useful for geo-testing, rate limiting avoidance, and corporate environments. +Proxy configuration for geo-testing, rate limiting avoidance, and corporate environments. + +**Related**: [commands.md](commands.md) for global options, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Basic Proxy Configuration](#basic-proxy-configuration) +- [Authenticated Proxy](#authenticated-proxy) +- [SOCKS Proxy](#socks-proxy) +- [Proxy Bypass](#proxy-bypass) +- [Common Use Cases](#common-use-cases) +- [Verifying Proxy Connection](#verifying-proxy-connection) +- [Troubleshooting](#troubleshooting) +- [Best Practices](#best-practices) ## Basic Proxy Configuration -Set proxy via environment variable before starting: +Use the `--proxy` flag or set proxy via environment variable: ```bash -# HTTP proxy +# Via CLI flag +agent-browser --proxy "http://proxy.example.com:8080" open https://example.com + +# Via environment variable export HTTP_PROXY="http://proxy.example.com:8080" agent-browser open https://example.com @@ -45,10 +61,13 @@ agent-browser open https://example.com ## Proxy Bypass -Skip proxy for specific domains: +Skip proxy for specific domains using `--proxy-bypass` or `NO_PROXY`: ```bash -# Bypass proxy for local addresses +# Via CLI flag +agent-browser --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com + +# Via environment variable export NO_PROXY="localhost,127.0.0.1,.internal.company.com" agent-browser open https://internal.company.com # Direct connection agent-browser open https://external.com # Via proxy diff --git a/.agents/skills/agent-browser/references/session-management.md b/.agents/skills/agent-browser/references/session-management.md index cfc3362455..bb5312dbdb 100644 --- a/.agents/skills/agent-browser/references/session-management.md +++ b/.agents/skills/agent-browser/references/session-management.md @@ -1,6 +1,18 @@ # Session Management -Run multiple isolated browser sessions concurrently with state persistence. +Multiple isolated browser sessions with state persistence and concurrent browsing. + +**Related**: [authentication.md](authentication.md) for login patterns, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Named Sessions](#named-sessions) +- [Session Isolation Properties](#session-isolation-properties) +- [Session State Persistence](#session-state-persistence) +- [Common Patterns](#common-patterns) +- [Default Session](#default-session) +- [Session Cleanup](#session-cleanup) +- [Best Practices](#best-practices) ## Named Sessions diff --git a/.agents/skills/agent-browser/references/snapshot-refs.md b/.agents/skills/agent-browser/references/snapshot-refs.md index 0b17a4d43f..c5868d51cf 100644 --- a/.agents/skills/agent-browser/references/snapshot-refs.md +++ b/.agents/skills/agent-browser/references/snapshot-refs.md @@ -1,21 +1,29 @@ -# Snapshot + Refs Workflow +# Snapshot and Refs -The core innovation of agent-browser: compact element references that reduce context usage dramatically for AI agents. +Compact element references that reduce context usage dramatically for AI agents. -## How It Works +**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. -### The Problem -Traditional browser automation sends full DOM to AI agents: +## Contents + +- [How Refs Work](#how-refs-work) +- [Snapshot Command](#the-snapshot-command) +- [Using Refs](#using-refs) +- [Ref Lifecycle](#ref-lifecycle) +- [Best Practices](#best-practices) +- [Ref Notation Details](#ref-notation-details) +- [Troubleshooting](#troubleshooting) + +## How Refs Work + +Traditional approach: ``` -Full DOM/HTML sent → AI parses → Generates CSS selector → Executes action -~3000-5000 tokens per interaction +Full DOM/HTML → AI parses → CSS selector → Action (~3000-5000 tokens) ``` -### The Solution -agent-browser uses compact snapshots with refs: +agent-browser approach: ``` -Compact snapshot → @refs assigned → Direct ref interaction -~200-400 tokens per interaction +Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens) ``` ## The Snapshot Command @@ -166,8 +174,8 @@ agent-browser snapshot -i ### Element Not Visible in Snapshot ```bash -# Scroll to reveal element -agent-browser scroll --bottom +# Scroll down to reveal element +agent-browser scroll down 1000 agent-browser snapshot -i # Or wait for dynamic content diff --git a/.agents/skills/agent-browser/references/video-recording.md b/.agents/skills/agent-browser/references/video-recording.md index 98e6b0a16e..e6a9fb4e2f 100644 --- a/.agents/skills/agent-browser/references/video-recording.md +++ b/.agents/skills/agent-browser/references/video-recording.md @@ -1,6 +1,17 @@ # Video Recording -Capture browser automation sessions as video for debugging, documentation, or verification. +Capture browser automation as video for debugging, documentation, or verification. + +**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. + +## Contents + +- [Basic Recording](#basic-recording) +- [Recording Commands](#recording-commands) +- [Use Cases](#use-cases) +- [Best Practices](#best-practices) +- [Output Format](#output-format) +- [Limitations](#limitations) ## Basic Recording diff --git a/.agents/skills/agent-browser/templates/authenticated-session.sh b/.agents/skills/agent-browser/templates/authenticated-session.sh index e44aaad5d5..b66c9289c9 100755 --- a/.agents/skills/agent-browser/templates/authenticated-session.sh +++ b/.agents/skills/agent-browser/templates/authenticated-session.sh @@ -1,67 +1,81 @@ #!/bin/bash # Template: Authenticated Session Workflow -# Login once, save state, reuse for subsequent runs +# Purpose: Login once, save state, reuse for subsequent runs +# Usage: ./authenticated-session.sh [state-file] # -# Usage: -# ./authenticated-session.sh [state-file] +# RECOMMENDED: Use the auth vault instead of this template: +# echo "" | agent-browser auth save myapp --url --username --password-stdin +# agent-browser auth login myapp +# The auth vault stores credentials securely and the LLM never sees passwords. # -# Setup: -# 1. Run once to see your form structure -# 2. Note the @refs for your fields -# 3. Uncomment LOGIN FLOW section and update refs +# Environment variables: +# APP_USERNAME - Login username/email +# APP_PASSWORD - Login password +# +# Two modes: +# 1. Discovery mode (default): Shows form structure so you can identify refs +# 2. Login mode: Performs actual login after you update the refs +# +# Setup steps: +# 1. Run once to see form structure (discovery mode) +# 2. Update refs in LOGIN FLOW section below +# 3. Set APP_USERNAME and APP_PASSWORD +# 4. Delete the DISCOVERY section set -euo pipefail LOGIN_URL="${1:?Usage: $0 [state-file]}" STATE_FILE="${2:-./auth-state.json}" -echo "Authentication workflow for: $LOGIN_URL" +echo "Authentication workflow: $LOGIN_URL" -# ══════════════════════════════════════════════════════════════ -# SAVED STATE: Skip login if we have valid saved state -# ══════════════════════════════════════════════════════════════ +# ================================================================ +# SAVED STATE: Skip login if valid saved state exists +# ================================================================ if [[ -f "$STATE_FILE" ]]; then - echo "Loading saved authentication state..." - agent-browser state load "$STATE_FILE" - agent-browser open "$LOGIN_URL" - agent-browser wait --load networkidle + echo "Loading saved state from $STATE_FILE..." + if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then + agent-browser wait --load networkidle - CURRENT_URL=$(agent-browser get url) - if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then - echo "Session restored successfully!" - agent-browser snapshot -i - exit 0 + CURRENT_URL=$(agent-browser get url) + if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then + echo "Session restored successfully" + agent-browser snapshot -i + exit 0 + fi + echo "Session expired, performing fresh login..." + agent-browser close 2>/dev/null || true + else + echo "Failed to load state, re-authenticating..." fi - echo "Session expired, performing fresh login..." rm -f "$STATE_FILE" fi -# ══════════════════════════════════════════════════════════════ -# DISCOVERY MODE: Show form structure (remove after setup) -# ══════════════════════════════════════════════════════════════ +# ================================================================ +# DISCOVERY MODE: Shows form structure (delete after setup) +# ================================================================ echo "Opening login page..." agent-browser open "$LOGIN_URL" agent-browser wait --load networkidle echo "" -echo "┌─────────────────────────────────────────────────────────┐" -echo "│ LOGIN FORM STRUCTURE │" -echo "├─────────────────────────────────────────────────────────┤" +echo "Login form structure:" +echo "---" agent-browser snapshot -i -echo "└─────────────────────────────────────────────────────────┘" +echo "---" echo "" echo "Next steps:" -echo " 1. Note refs: @e? = username, @e? = password, @e? = submit" -echo " 2. Uncomment LOGIN FLOW section below" -echo " 3. Replace @e1, @e2, @e3 with your refs" +echo " 1. Note the refs: username=@e?, password=@e?, submit=@e?" +echo " 2. Update the LOGIN FLOW section below with your refs" +echo " 3. Set: export APP_USERNAME='...' APP_PASSWORD='...'" echo " 4. Delete this DISCOVERY MODE section" echo "" agent-browser close exit 0 -# ══════════════════════════════════════════════════════════════ +# ================================================================ # LOGIN FLOW: Uncomment and customize after discovery -# ══════════════════════════════════════════════════════════════ +# ================================================================ # : "${APP_USERNAME:?Set APP_USERNAME environment variable}" # : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}" # @@ -78,14 +92,14 @@ exit 0 # # Verify login succeeded # FINAL_URL=$(agent-browser get url) # if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then -# echo "ERROR: Login failed - still on login page" +# echo "Login failed - still on login page" # agent-browser screenshot /tmp/login-failed.png # agent-browser close # exit 1 # fi # # # Save state for future runs -# echo "Saving authentication state to: $STATE_FILE" +# echo "Saving state to $STATE_FILE" # agent-browser state save "$STATE_FILE" -# echo "Login successful!" +# echo "Login successful" # agent-browser snapshot -i diff --git a/.agents/skills/agent-browser/templates/capture-workflow.sh b/.agents/skills/agent-browser/templates/capture-workflow.sh index a4eae751ef..3bc93ad0c1 100755 --- a/.agents/skills/agent-browser/templates/capture-workflow.sh +++ b/.agents/skills/agent-browser/templates/capture-workflow.sh @@ -1,68 +1,69 @@ #!/bin/bash # Template: Content Capture Workflow -# Extract content from web pages with optional authentication +# Purpose: Extract content from web pages (text, screenshots, PDF) +# Usage: ./capture-workflow.sh [output-dir] +# +# Outputs: +# - page-full.png: Full page screenshot +# - page-structure.txt: Page element structure with refs +# - page-text.txt: All text content +# - page.pdf: PDF version +# +# Optional: Load auth state for protected pages set -euo pipefail TARGET_URL="${1:?Usage: $0 [output-dir]}" OUTPUT_DIR="${2:-.}" -echo "Capturing content from: $TARGET_URL" +echo "Capturing: $TARGET_URL" mkdir -p "$OUTPUT_DIR" -# Optional: Load authentication state if needed +# Optional: Load authentication state # if [[ -f "./auth-state.json" ]]; then +# echo "Loading authentication state..." # agent-browser state load "./auth-state.json" # fi -# Navigate to target page +# Navigate to target agent-browser open "$TARGET_URL" agent-browser wait --load networkidle -# Get page metadata -echo "Page title: $(agent-browser get title)" -echo "Page URL: $(agent-browser get url)" +# Get metadata +TITLE=$(agent-browser get title) +URL=$(agent-browser get url) +echo "Title: $TITLE" +echo "URL: $URL" # Capture full page screenshot agent-browser screenshot --full "$OUTPUT_DIR/page-full.png" -echo "Screenshot saved: $OUTPUT_DIR/page-full.png" +echo "Saved: $OUTPUT_DIR/page-full.png" -# Get page structure +# Get page structure with refs agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt" -echo "Structure saved: $OUTPUT_DIR/page-structure.txt" +echo "Saved: $OUTPUT_DIR/page-structure.txt" -# Extract main content -# Adjust selector based on target site structure -# agent-browser get text @e1 > "$OUTPUT_DIR/main-content.txt" - -# Extract specific elements (uncomment as needed) -# agent-browser get text "article" > "$OUTPUT_DIR/article.txt" -# agent-browser get text "main" > "$OUTPUT_DIR/main.txt" -# agent-browser get text ".content" > "$OUTPUT_DIR/content.txt" - -# Get full page text +# Extract all text content agent-browser get text body > "$OUTPUT_DIR/page-text.txt" -echo "Text content saved: $OUTPUT_DIR/page-text.txt" +echo "Saved: $OUTPUT_DIR/page-text.txt" -# Optional: Save as PDF +# Save as PDF agent-browser pdf "$OUTPUT_DIR/page.pdf" -echo "PDF saved: $OUTPUT_DIR/page.pdf" +echo "Saved: $OUTPUT_DIR/page.pdf" + +# Optional: Extract specific elements using refs from structure +# agent-browser get text @e5 > "$OUTPUT_DIR/main-content.txt" -# Optional: Capture with scrolling for infinite scroll pages -# scroll_and_capture() { -# local count=0 -# while [[ $count -lt 5 ]]; do -# agent-browser scroll down 1000 -# agent-browser wait 1000 -# ((count++)) -# done -# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png" -# } -# scroll_and_capture +# Optional: Handle infinite scroll pages +# for i in {1..5}; do +# agent-browser scroll down 1000 +# agent-browser wait 1000 +# done +# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png" # Cleanup agent-browser close echo "" -echo "Capture complete! Files saved to: $OUTPUT_DIR" +echo "Capture complete:" ls -la "$OUTPUT_DIR" diff --git a/.agents/skills/agent-browser/templates/form-automation.sh b/.agents/skills/agent-browser/templates/form-automation.sh index 02a7c81154..6784fcd3a5 100755 --- a/.agents/skills/agent-browser/templates/form-automation.sh +++ b/.agents/skills/agent-browser/templates/form-automation.sh @@ -1,64 +1,62 @@ #!/bin/bash # Template: Form Automation Workflow -# Fills and submits web forms with validation +# Purpose: Fill and submit web forms with validation +# Usage: ./form-automation.sh +# +# This template demonstrates the snapshot-interact-verify pattern: +# 1. Navigate to form +# 2. Snapshot to get element refs +# 3. Fill fields using refs +# 4. Submit and verify result +# +# Customize: Update the refs (@e1, @e2, etc.) based on your form's snapshot output set -euo pipefail FORM_URL="${1:?Usage: $0 }" -echo "Automating form at: $FORM_URL" +echo "Form automation: $FORM_URL" -# Navigate to form page +# Step 1: Navigate to form agent-browser open "$FORM_URL" agent-browser wait --load networkidle -# Get interactive snapshot to identify form fields -echo "Analyzing form structure..." +# Step 2: Snapshot to discover form elements +echo "" +echo "Form structure:" agent-browser snapshot -i -# Example: Fill common form fields -# Uncomment and modify refs based on snapshot output - -# Text inputs -# agent-browser fill @e1 "John Doe" # Name field -# agent-browser fill @e2 "user@example.com" # Email field -# agent-browser fill @e3 "+1-555-123-4567" # Phone field - -# Password fields -# agent-browser fill @e4 "SecureP@ssw0rd!" - -# Dropdowns -# agent-browser select @e5 "Option Value" - -# Checkboxes -# agent-browser check @e6 # Check -# agent-browser uncheck @e7 # Uncheck - -# Radio buttons -# agent-browser click @e8 # Select radio option - -# Text areas -# agent-browser fill @e9 "Multi-line text content here" - -# File uploads -# agent-browser upload @e10 /path/to/file.pdf - -# Submit form -# agent-browser click @e11 # Submit button - -# Wait for response +# Step 3: Fill form fields (customize these refs based on snapshot output) +# +# Common field types: +# agent-browser fill @e1 "John Doe" # Text input +# agent-browser fill @e2 "user@example.com" # Email input +# agent-browser fill @e3 "SecureP@ss123" # Password input +# agent-browser select @e4 "Option Value" # Dropdown +# agent-browser check @e5 # Checkbox +# agent-browser click @e6 # Radio button +# agent-browser fill @e7 "Multi-line text" # Textarea +# agent-browser upload @e8 /path/to/file.pdf # File upload +# +# Uncomment and modify: +# agent-browser fill @e1 "Test User" +# agent-browser fill @e2 "test@example.com" +# agent-browser click @e3 # Submit button + +# Step 4: Wait for submission # agent-browser wait --load networkidle -# agent-browser wait --url "**/success" # Or wait for redirect +# agent-browser wait --url "**/success" # Or wait for redirect -# Verify submission -echo "Form submission result:" +# Step 5: Verify result +echo "" +echo "Result:" agent-browser get url agent-browser snapshot -i -# Take screenshot of result +# Optional: Capture evidence agent-browser screenshot /tmp/form-result.png +echo "Screenshot saved: /tmp/form-result.png" # Cleanup agent-browser close - -echo "Form automation complete" +echo "Done" diff --git a/.github/skills/backend-architecture/SKILL.md b/.agents/skills/backend-architecture/SKILL.md similarity index 95% rename from .github/skills/backend-architecture/SKILL.md rename to .agents/skills/backend-architecture/SKILL.md index 2f197285d7..a6042e9a9d 100644 --- a/.github/skills/backend-architecture/SKILL.md +++ b/.agents/skills/backend-architecture/SKILL.md @@ -1,10 +1,10 @@ --- -name: Backend Architecture -description: | - Backend architecture for Exceptionless. Project layering, repositories, validation, - controllers, authorization, WebSockets, configuration, and Aspire orchestration. - Keywords: Core, Insulation, repositories, FluentValidation, MiniValidator, controllers, - AuthorizationRoles, ProblemDetails, Aspire, WebSockets, AppOptions +name: backend-architecture +description: > + Use this skill when working on the ASP.NET Core backend — adding controllers, repositories, + validators, authorization, WebSocket endpoints, or Aspire orchestration. Apply when modifying + project layering (Core, Insulation, Web, Job), configuring services, returning ProblemDetails + errors, or understanding how the backend is structured. --- # Backend Architecture @@ -337,7 +337,7 @@ var esConnection = builder.Configuration.GetConnectionString("elasticsearch"); ## Dependencies -- NuGet feeds configured in [NuGet.Config](NuGet.Config) +- NuGet feeds configured in [NuGet.Config](../../../NuGet.Config) - Version alignment in `src/Directory.Build.props` - Avoid deprecated APIs — check for alternatives before using legacy methods diff --git a/.github/skills/backend-testing/SKILL.md b/.agents/skills/backend-testing/SKILL.md similarity index 92% rename from .github/skills/backend-testing/SKILL.md rename to .agents/skills/backend-testing/SKILL.md index e86cbe071e..990a00f8ca 100644 --- a/.github/skills/backend-testing/SKILL.md +++ b/.agents/skills/backend-testing/SKILL.md @@ -1,10 +1,10 @@ --- -name: Backend Testing -description: | - Backend testing with xUnit, Foundatio.Xunit, integration tests with AppWebHostFactory, - FluentClient, ProxyTimeProvider for time manipulation, and test data builders. - Keywords: xUnit, Fact, Theory, integration tests, AppWebHostFactory, FluentClient, - ProxyTimeProvider, TimeProvider, Foundatio.Xunit, TestWithLoggingBase, test data builders +name: backend-testing +description: > + Use this skill when writing or modifying C# tests — unit tests, integration tests, or + test fixtures. Covers xUnit patterns, AppWebHostFactory for integration testing, FluentClient + for API assertions, ProxyTimeProvider for time manipulation, and test data builders. Apply + when adding new test cases, debugging test failures, or setting up test infrastructure. --- # Backend Testing @@ -106,15 +106,15 @@ public abstract class IntegrationTestsBase : TestWithLoggingBase, IAsyncLifetime protected FluentClient CreateFluentClient() { - var settings = GetService(); - return new FluentClient(CreateHttpClient(), new NewtonsoftJsonSerializer(settings)); + var settings = GetService(); + return new FluentClient(CreateHttpClient(), new JsonContentSerializer(settings)); } } ``` ## Real Test Example -From [EventControllerTests.cs](tests/Exceptionless.Tests/Controllers/EventControllerTests.cs): +From [EventControllerTests.cs](../../../tests/Exceptionless.Tests/Controllers/EventControllerTests.cs): ```csharp public class EventControllerTests : IntegrationTestsBase diff --git a/.agents/skills/dogfood/SKILL.md b/.agents/skills/dogfood/SKILL.md new file mode 100644 index 0000000000..be25ce58d5 --- /dev/null +++ b/.agents/skills/dogfood/SKILL.md @@ -0,0 +1,216 @@ +--- +name: dogfood +description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams. +allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +--- + +# Dogfood + +Systematically explore a web application, find issues, and produce a report with full reproduction evidence for every finding. + +## Setup + +Only the **Target URL** is required. Everything else has sensible defaults -- use them unless the user explicitly provides an override. + +| Parameter | Default | Example override | +|-----------|---------|-----------------| +| **Target URL** | _(required)_ | `vercel.com`, `http://localhost:3000` | +| **Session name** | Slugified domain (e.g., `vercel.com` -> `vercel-com`) | `--session my-session` | +| **Output directory** | `./dogfood-output/` | `Output directory: /tmp/qa` | +| **Scope** | Full app | `Focus on the billing page` | +| **Authentication** | None | `Sign in to user@example.com` | + +If the user says something like "dogfood vercel.com", start immediately with defaults. Do not ask clarifying questions unless authentication is mentioned but credentials are missing. + +Always use `agent-browser` directly -- never `npx agent-browser`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower. + +## Workflow + +``` +1. Initialize Set up session, output dirs, report file +2. Authenticate Sign in if needed, save state +3. Orient Navigate to starting point, take initial snapshot +4. Explore Systematically visit pages and test features +5. Document Screenshot + record each issue as found +6. Wrap up Update summary counts, close session +``` + +### 1. Initialize + +```bash +mkdir -p {OUTPUT_DIR}/screenshots {OUTPUT_DIR}/videos +``` + +Copy the report template into the output directory and fill in the header fields: + +```bash +cp {SKILL_DIR}/templates/dogfood-report-template.md {OUTPUT_DIR}/report.md +``` + +Start a named session: + +```bash +agent-browser --session {SESSION} open {TARGET_URL} +agent-browser --session {SESSION} wait --load networkidle +``` + +### 2. Authenticate + +If the app requires login: + +```bash +agent-browser --session {SESSION} snapshot -i +# Identify login form refs, fill credentials +agent-browser --session {SESSION} fill @e1 "{EMAIL}" +agent-browser --session {SESSION} fill @e2 "{PASSWORD}" +agent-browser --session {SESSION} click @e3 +agent-browser --session {SESSION} wait --load networkidle +``` + +For OTP/email codes: ask the user, wait for their response, then enter the code. + +After successful login, save state for potential reuse: + +```bash +agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json +``` + +### 3. Orient + +Take an initial annotated screenshot and snapshot to understand the app structure: + +```bash +agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png +agent-browser --session {SESSION} snapshot -i +``` + +Identify the main navigation elements and map out the sections to visit. + +### 4. Explore + +Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full list of what to look for and the exploration checklist. + +**Strategy -- work through the app systematically:** + +- Start from the main navigation. Visit each top-level section. +- Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals. +- Check edge cases: empty states, error handling, boundary inputs. +- Try realistic end-to-end workflows (create, edit, delete flows). +- Check the browser console for errors periodically. + +**At each page:** + +```bash +agent-browser --session {SESSION} snapshot -i +agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png +agent-browser --session {SESSION} errors +agent-browser --session {SESSION} console +``` + +Use your judgment on how deep to go. Spend more time on core features and less on peripheral pages. If you find a cluster of issues in one area, investigate deeper. + +### 5. Document Issues (Repro-First) + +Steps 4 and 5 happen together -- explore and document in a single pass. When you find an issue, stop exploring and document it immediately before moving on. Do not explore the whole app first and document later. + +Every issue must be reproducible. When you find something wrong, do not just note it -- prove it with evidence. The goal is that someone reading the report can see exactly what happened and replay it. + +**Choose the right level of evidence for the issue:** + +#### Interactive / behavioral issues (functional, ux, console errors on action) + +These require user interaction to reproduce -- use full repro with video and step-by-step screenshots: + +1. **Start a repro video** _before_ reproducing: + +```bash +agent-browser --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm +``` + +2. **Walk through the steps at human pace.** Pause 1-2 seconds between actions so the video is watchable. Take a screenshot at each step: + +```bash +agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png +sleep 1 +# Perform action (click, fill, etc.) +sleep 1 +agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png +sleep 1 +# ...continue until the issue manifests +``` + +3. **Capture the broken state.** Pause so the viewer can see it, then take an annotated screenshot: + +```bash +sleep 2 +agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png +``` + +4. **Stop the video:** + +```bash +agent-browser --session {SESSION} record stop +``` + +5. Write numbered repro steps in the report, each referencing its screenshot. + +#### Static / visible-on-load issues (typos, placeholder text, clipped text, misalignment, console errors on load) + +These are visible without interaction -- a single annotated screenshot is sufficient. No video, no multi-step repro: + +```bash +agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png +``` + +Write a brief description and reference the screenshot in the report. Set **Repro Video** to `N/A`. + +--- + +**For all issues:** + +1. **Append to the report immediately.** Do not batch issues for later. Write each one as you find it so nothing is lost if the session is interrupted. + +2. **Increment the issue counter** (ISSUE-001, ISSUE-002, ...). + +### 6. Wrap Up + +Aim to find **5-10 well-documented issues**, then wrap up. Depth of evidence matters more than total count -- 5 issues with full repro beats 20 with vague descriptions. + +After exploring: + +1. Re-read the report and update the summary severity counts so they match the actual issues. Every `### ISSUE-` block must be reflected in the totals. +2. Close the session: + +```bash +agent-browser --session {SESSION} close +``` + +3. Tell the user the report is ready and summarize findings: total issues, breakdown by severity, and the most critical items. + +## Guidance + +- **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot. +- **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes. +- **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence. +- **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser. +- **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate. +- **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end. +- **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward. +- **Never read the target app's source code.** You are testing as a user, not auditing code. Do not read HTML, JS, or config files of the app under test. All findings must come from what you observe in the browser. +- **Check the console.** Many issues are invisible in the UI but show up as JS errors or failed requests. +- **Test like a user, not a robot.** Try common workflows end-to-end. Click things a real user would click. Enter realistic data. +- **Type like a human.** When filling form fields during video recording, use `type` instead of `fill` -- it types character-by-character. Use `fill` only outside of video recording when speed matters. +- **Pace repro videos for humans.** Add `sleep 1` between actions and `sleep 2` before the final result screenshot. Videos should be watchable at 1x speed -- a human reviewing the report needs to see what happened, not a blur of instant state changes. +- **Be efficient with commands.** Batch multiple `agent-browser` commands in a single shell call when they are independent (e.g., `agent-browser ... screenshot ... && agent-browser ... console`). Use `agent-browser --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll. + +## References + +| Reference | When to Read | +|-----------|--------------| +| [references/issue-taxonomy.md](references/issue-taxonomy.md) | Start of session -- calibrate what to look for, severity levels, exploration checklist | + +## Templates + +| Template | Purpose | +|----------|---------| +| [templates/dogfood-report-template.md](templates/dogfood-report-template.md) | Copy into output directory as the report file | diff --git a/.agents/skills/dogfood/references/issue-taxonomy.md b/.agents/skills/dogfood/references/issue-taxonomy.md new file mode 100644 index 0000000000..c3edbe5733 --- /dev/null +++ b/.agents/skills/dogfood/references/issue-taxonomy.md @@ -0,0 +1,109 @@ +# Issue Taxonomy + +Reference for categorizing issues found during dogfooding. Read this at the start of a dogfood session to calibrate what to look for. + +## Contents + +- [Severity Levels](#severity-levels) +- [Categories](#categories) +- [Exploration Checklist](#exploration-checklist) + +## Severity Levels + +| Severity | Definition | +|----------|------------| +| **critical** | Blocks a core workflow, causes data loss, or crashes the app | +| **high** | Major feature broken or unusable, no workaround | +| **medium** | Feature works but with noticeable problems, workaround exists | +| **low** | Minor cosmetic or polish issue | + +## Categories + +### Visual / UI + +- Layout broken or misaligned elements +- Overlapping or clipped text +- Inconsistent spacing, padding, or margins +- Missing or broken icons/images +- Dark mode / light mode rendering issues +- Responsive layout problems (viewport sizes) +- Z-index stacking issues (elements hidden behind others) +- Font rendering issues (wrong font, size, weight) +- Color contrast problems +- Animation glitches or jank + +### Functional + +- Broken links (404, wrong destination) +- Buttons or controls that do nothing on click +- Form validation that rejects valid input or accepts invalid input +- Incorrect redirects +- Features that fail silently +- State not persisted when expected (lost on refresh, navigation) +- Race conditions (double-submit, stale data) +- Broken search or filtering +- Pagination issues +- File upload/download failures + +### UX + +- Confusing or unclear navigation +- Missing loading indicators or feedback after actions +- Slow or unresponsive interactions (>300ms perceived delay) +- Unclear error messages +- Missing confirmation for destructive actions +- Dead ends (no way to go back or proceed) +- Inconsistent patterns across similar features +- Missing keyboard shortcuts or focus management +- Unintuitive defaults +- Missing empty states or unhelpful empty states + +### Content + +- Typos or grammatical errors +- Outdated or incorrect text +- Placeholder or lorem ipsum content left in +- Truncated text without tooltip or expansion +- Missing or wrong labels +- Inconsistent terminology + +### Performance + +- Slow page loads (>3s) +- Janky scrolling or animations +- Large layout shifts (content jumping) +- Excessive network requests (check via console/network) +- Memory leaks (page slows over time) +- Unoptimized images (large file sizes) + +### Console / Errors + +- JavaScript exceptions in console +- Failed network requests (4xx, 5xx) +- Deprecation warnings +- CORS errors +- Mixed content warnings +- Unhandled promise rejections + +### Accessibility + +- Missing alt text on images +- Unlabeled form inputs +- Poor keyboard navigation (can't tab to elements) +- Focus traps +- Insufficient color contrast +- Missing ARIA attributes on dynamic content +- Screen reader incompatible patterns + +## Exploration Checklist + +Use this as a guide for what to test on each page/feature: + +1. **Visual scan** -- Take an annotated screenshot. Look for layout, alignment, and rendering issues. +2. **Interactive elements** -- Click every button, link, and control. Do they work? Is there feedback? +3. **Forms** -- Fill and submit. Test empty submission, invalid input, and edge cases. +4. **Navigation** -- Follow all navigation paths. Check breadcrumbs, back button, deep links. +5. **States** -- Check empty states, loading states, error states, and full/overflow states. +6. **Console** -- Check for JS errors, failed requests, and warnings. +7. **Responsiveness** -- If relevant, test at different viewport sizes. +8. **Auth boundaries** -- Test what happens when not logged in, with different roles if applicable. diff --git a/.agents/skills/dogfood/templates/dogfood-report-template.md b/.agents/skills/dogfood/templates/dogfood-report-template.md new file mode 100644 index 0000000000..a7732a409f --- /dev/null +++ b/.agents/skills/dogfood/templates/dogfood-report-template.md @@ -0,0 +1,53 @@ +# Dogfood Report: {APP_NAME} + +| Field | Value | +|-------|-------| +| **Date** | {DATE} | +| **App URL** | {URL} | +| **Session** | {SESSION_NAME} | +| **Scope** | {SCOPE} | + +## Summary + +| Severity | Count | +|----------|-------| +| Critical | 0 | +| High | 0 | +| Medium | 0 | +| Low | 0 | +| **Total** | **0** | + +## Issues + + + +### ISSUE-001: {Short title} + +| Field | Value | +|-------|-------| +| **Severity** | critical / high / medium / low | +| **Category** | visual / functional / ux / content / performance / console / accessibility | +| **URL** | {page URL where issue was found} | +| **Repro Video** | {path to video, or N/A for static issues} | + +**Description** + +{What is wrong, what was expected, and what actually happened.} + +**Repro Steps** + + + +1. Navigate to {URL} + ![Step 1](screenshots/issue-001-step-1.png) + +2. {Action -- e.g., click "Settings" in the sidebar} + ![Step 2](screenshots/issue-001-step-2.png) + +3. {Action -- e.g., type "test" in the search field and press Enter} + ![Step 3](screenshots/issue-001-step-3.png) + +4. **Observe:** {what goes wrong -- e.g., the page shows a blank white screen instead of search results} + ![Result](screenshots/issue-001-result.png) + +--- diff --git a/.github/skills/dotnet-cli/SKILL.md b/.agents/skills/dotnet-cli/SKILL.md similarity index 80% rename from .github/skills/dotnet-cli/SKILL.md rename to .agents/skills/dotnet-cli/SKILL.md index 0e17874d3e..b3e3a1ddb0 100644 --- a/.github/skills/dotnet-cli/SKILL.md +++ b/.agents/skills/dotnet-cli/SKILL.md @@ -1,10 +1,10 @@ --- -name: .NET CLI -description: | - .NET command-line tools for building, testing, and formatting. Common dotnet commands - and development workflow. - Keywords: dotnet build, dotnet restore, dotnet test, dotnet format, dotnet run, - NuGet, package restore, CLI commands, build system +name: dotnet-cli +description: > + Use this skill when running .NET CLI commands — building, testing, restoring packages, + formatting code, or running projects. Covers dotnet build, test, restore, format, run, + and NuGet package management. Apply when troubleshooting build errors, running the backend, + or executing any dotnet command-line operation. --- # .NET CLI @@ -66,7 +66,7 @@ dotnet format --verify-no-changes ## NuGet Configuration -Feeds are defined in [NuGet.Config](NuGet.Config) — do not add new sources unless explicitly requested. +Feeds are defined in [NuGet.Config](../../../NuGet.Config) — do not add new sources unless explicitly requested. ## Directory.Build.props diff --git a/.github/skills/dotnet-conventions/SKILL.md b/.agents/skills/dotnet-conventions/SKILL.md similarity index 80% rename from .github/skills/dotnet-conventions/SKILL.md rename to .agents/skills/dotnet-conventions/SKILL.md index 88a3d66976..6648545aac 100644 --- a/.github/skills/dotnet-conventions/SKILL.md +++ b/.agents/skills/dotnet-conventions/SKILL.md @@ -1,10 +1,10 @@ --- -name: .NET Conventions -description: | - C# coding standards for the Exceptionless codebase. Naming conventions, async patterns, - structured logging, nullable reference types, and formatting rules. - Keywords: C# style, naming conventions, _camelCase, PascalCase, async suffix, - CancellationToken, nullable annotations, structured logging, ExceptionlessState +name: dotnet-conventions +description: > + Use this skill when writing or reviewing C# code to follow project conventions. Covers + naming standards, async patterns, CancellationToken usage, structured logging, nullable + reference types, and formatting rules. Apply when authoring new C# classes, reviewing + code style, or ensuring consistency with existing patterns. --- # .NET Conventions @@ -18,13 +18,13 @@ description: | ## Naming Conventions -| Element | Convention | Example | -|---------|------------|---------| -| Private fields | `_camelCase` | `_organizationRepository` | -| Public members | PascalCase | `GetByIdAsync` | -| Local variables | camelCase | `organizationId` | -| Constants | PascalCase | `MaxRetryCount` | -| Type parameters | `T` prefix | `TModel` | +| Element | Convention | Example | +| --------------- | ------------ | ------------------------- | +| Private fields | `_camelCase` | `_organizationRepository` | +| Public members | PascalCase | `GetByIdAsync` | +| Local variables | camelCase | `organizationId` | +| Constants | PascalCase | `MaxRetryCount` | +| Type parameters | `T` prefix | `TModel` | ## Formatting Rules @@ -170,4 +170,4 @@ public async Task ProcessAsync(string id) ### Domain Validation -See [backend-architecture](backend-architecture/SKILL.md) for validation patterns (FluentValidation for domain models, MiniValidator for API requests). +See [backend-architecture](../backend-architecture/SKILL.md) for validation patterns (FluentValidation for domain models, MiniValidator for API requests). diff --git a/.github/skills/e2e-testing/SKILL.md b/.agents/skills/e2e-testing/SKILL.md similarity index 54% rename from .github/skills/e2e-testing/SKILL.md rename to .agents/skills/e2e-testing/SKILL.md index 5122b69835..4543bdc782 100644 --- a/.github/skills/e2e-testing/SKILL.md +++ b/.agents/skills/e2e-testing/SKILL.md @@ -1,10 +1,10 @@ --- -name: E2E Testing (Frontend) -description: | - End-to-end frontend testing with Playwright. Page Object Model, selectors, fixtures, - accessibility audits. Limited E2E coverage currently - area for improvement. - Keywords: Playwright, E2E, Page Object Model, POM, data-testid, getByRole, getByLabel, - getByText, fixtures, axe-playwright, frontend testing +name: e2e-testing +description: > + Use this skill when writing or running end-to-end browser tests with Playwright. Covers + Page Object Model patterns, selector strategies (data-testid, getByRole, getByLabel), + fixtures, and accessibility audits with axe-playwright. Apply when adding E2E test coverage, + debugging flaky tests, or testing user flows through the browser. --- # E2E Testing (Frontend) @@ -24,7 +24,7 @@ Create page objects for reusable page interactions: ```typescript // e2e/pages/login-page.ts -import { type Page, type Locator, expect } from '@playwright/test'; +import { type Page, type Locator, expect } from "@playwright/test"; export class LoginPage { readonly page: Page; @@ -35,14 +35,14 @@ export class LoginPage { constructor(page: Page) { this.page = page; - this.emailInput = page.getByLabel('Email'); - this.passwordInput = page.getByLabel('Password'); - this.submitButton = page.getByRole('button', { name: /log in/i }); - this.errorMessage = page.getByRole('alert'); + this.emailInput = page.getByLabel("Email"); + this.passwordInput = page.getByLabel("Password"); + this.submitButton = page.getByRole("button", { name: /log in/i }); + this.errorMessage = page.getByRole("alert"); } async goto() { - await this.page.goto('/login'); + await this.page.goto("/login"); } async login(email: string, password: string) { @@ -61,26 +61,26 @@ export class LoginPage { ```typescript // e2e/auth/login.spec.ts -import { test, expect } from '@playwright/test'; -import { LoginPage } from '../pages/login-page'; +import { test, expect } from "@playwright/test"; +import { LoginPage } from "../pages/login-page"; -test.describe('Login', () => { - test('successful login redirects to dashboard', async ({ page }) => { +test.describe("Login", () => { + test("successful login redirects to dashboard", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); - await loginPage.login('user@example.com', 'password123'); + await loginPage.login("user@example.com", "password123"); - await expect(page).toHaveURL('/'); + await expect(page).toHaveURL("/"); }); - test('invalid credentials shows error', async ({ page }) => { + test("invalid credentials shows error", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); - await loginPage.login('wrong@example.com', 'wrongpassword'); + await loginPage.login("wrong@example.com", "wrongpassword"); - await loginPage.expectError('Invalid email or password'); + await loginPage.expectError("Invalid email or password"); }); }); ``` @@ -89,28 +89,28 @@ test.describe('Login', () => { 1. **Semantic selectors first**: - ```typescript - page.getByRole('button', { name: /submit/i }); - page.getByLabel('Email address'); - page.getByText('Welcome back'); - ``` + ```typescript + page.getByRole("button", { name: /submit/i }); + page.getByLabel("Email address"); + page.getByText("Welcome back"); + ``` 2. **Fallback to test IDs**: - ```typescript - page.getByTestId('stack-trace'); - ``` + ```typescript + page.getByTestId("stack-trace"); + ``` 3. **Avoid implementation details**: - ```typescript - // ❌ Avoid CSS classes and IDs - page.locator('.btn-primary'); - ``` + ```typescript + // ❌ Avoid CSS classes and IDs + page.locator(".btn-primary"); + ``` ## Backend Data Setup -E2E tests run against the full Aspire stack. The backend uses the same `AppWebHostFactory` infrastructure from [backend-testing](backend-testing/SKILL.md). +E2E tests run against the full Aspire stack. The backend uses the same `AppWebHostFactory` infrastructure from [backend-testing](../backend-testing/SKILL.md). For tests requiring specific data, consider: @@ -122,13 +122,13 @@ For tests requiring specific data, consider: ```typescript test.beforeEach(async ({ request }) => { // Set up test data via API - await request.post('/api/test/seed', { - data: { scenario: 'events-with-errors' } + await request.post("/api/test/seed", { + data: { scenario: "events-with-errors" }, }); }); test.afterEach(async ({ request }) => { - await request.delete('/api/test/cleanup'); + await request.delete("/api/test/cleanup"); }); ``` @@ -137,15 +137,15 @@ test.afterEach(async ({ request }) => { ## Accessibility Audits ```typescript -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; -test('login page has no accessibility violations', async ({ page }) => { - await page.goto('/login'); +test("login page has no accessibility violations", async ({ page }) => { + await page.goto("/login"); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); ``` -See [accessibility](accessibility/SKILL.md) for WCAG guidelines. +See [accessibility](../accessibility/SKILL.md) for WCAG guidelines. diff --git a/.agents/skills/foundatio-repositories/SKILL.md b/.agents/skills/foundatio-repositories/SKILL.md new file mode 100644 index 0000000000..d15821625a --- /dev/null +++ b/.agents/skills/foundatio-repositories/SKILL.md @@ -0,0 +1,356 @@ +--- +name: foundatio-repositories +description: > + Use this skill when querying, counting, patching, or paginating data through Foundatio.Repositories + Elasticsearch abstractions. Covers filter expressions, aggregation queries, partial and script + patches, and search-after pagination. Apply when working with any repository method — never use + raw IElasticClient directly. +--- + +# Foundatio Repositories + +Foundatio.Repositories provides a high-level Elasticsearch abstraction. **Never use raw `IElasticClient` directly** — always use repository methods. + +> **Documentation:** / + +## Repository Hierarchy + +```text +IRepository — CRUD, Patch, Remove + └─ ISearchableRepository — FindAsync, CountAsync, aggregations + └─ IRepositoryOwnedByOrganization + └─ IRepositoryOwnedByProject + └─ IRepositoryOwnedByOrganizationAndProject +``` + +Exceptionless repos: + +| Interface | Entity | Index Type | +| --------------------------- | ----------------- | ------------------------------- | +| `IEventRepository` | `PersistentEvent` | `DailyIndex` (date-partitioned) | +| `IStackRepository` | `Stack` | `VersionedIndex` (single index) | +| `IProjectRepository` | `Project` | `VersionedIndex` | +| `IOrganizationRepository` | `Organization` | `VersionedIndex` | +| `IUserRepository` | `User` | `VersionedIndex` | +| `ITokenRepository` | `Token` | `VersionedIndex` | +| `IMigrationStateRepository` | `MigrationState` | `VersionedIndex` | + +**Important:** `.Index(start, end)` only routes to correct daily shards for `DailyIndex` (events). It is a no-op for `VersionedIndex` (stacks, orgs, projects). + +## CountAsync + AggregationsExpression + +`CountAsync` returns a `CountResult` with `.Total` (long) and `.Aggregations` (AggregationsHelper). + +### AggregationsExpression DSL + +| Expression | Meaning | +| ------------------------------ | ---------------------------------------- | +| `cardinality:field` | Distinct count | +| `terms:field` | Terms aggregation | +| `terms:(field~SIZE)` | Terms with bucket size limit | +| `terms:(field~SIZE sub_agg)` | Terms with nested aggregation | +| `terms:(field @include:VALUE)` | Terms with include filter | +| `date:field` | Date histogram (auto interval) | +| `date:field~1d` | Date histogram, daily interval | +| `date:field~1M` | Date histogram, monthly interval | +| `date:(field sub_agg)` | Date histogram with nested agg | +| `sum:field~DEFAULT` | Sum with default value | +| `min:field` / `max:field` | Min/Max aggregation | +| `avg:field` | Average aggregation | +| `-sum:field~1` | Sort descending by this agg (prefix `-`) | + +Multiple aggregations are space-separated: `"cardinality:stack_id terms:type sum:count~1"` + +### Accessing Aggregation Results + +Naming convention: `{type}_{field}` — the aggregation type prefix + underscore + field name. + +```csharp +// Cardinality +result.Aggregations.Cardinality("cardinality_stack_id").Value + +// Terms +result.Aggregations.Terms("terms_type").Buckets // .Key, .Total + +// Date histogram +result.Aggregations.DateHistogram("date_date").Buckets // .Date, .Total + +// Sum / Min / Max / Avg +result.Aggregations.Sum("sum_count").Value +result.Aggregations.Min("min_date").Value +result.Aggregations.Max("max_date").Value +result.Aggregations.Average("avg_value").Value + +// Nested aggs inside buckets +var terms = result.Aggregations.Terms("terms_stack_id"); +foreach (var bucket in terms.Buckets) +{ + var nested = bucket.Aggregations.Cardinality("cardinality_user").Value; +} +``` + +### Examples + +**Simple cardinality:** + +```csharp +var result = await _eventRepository.CountAsync(q => q + .FilterExpression($"project:{projectId}") + .AggregationsExpression("cardinality:stack_id cardinality:id")); +long uniqueStacks = result.Aggregations.Cardinality("cardinality_stack_id").Value.GetValueOrDefault(); +``` + +**Date histogram + nested cardinality:** + +```csharp +var result = await _eventRepository.CountAsync(q => q + .FilterExpression($"project:{projectId}") + .AggregationsExpression("date:(date cardinality:id) cardinality:id")); +var buckets = result.Aggregations.DateHistogram("date_date").Buckets; +``` + +**Date histogram with monthly interval:** + +```csharp +var result = await _eventRepository.CountAsync(q => q + .Organization(organizationId) + .AggregationsExpression("date:date~1M")); +foreach (var bucket in result.Aggregations.DateHistogram("date_date").Buckets) +{ + // bucket.Date, bucket.Total +} +``` + +**Terms with nested min/max:** + +```csharp +var result = await _eventRepository.CountAsync(q => q + .AggregationsExpression($"terms:(stack_id~{stackSize} min:date max:date)")); +var buckets = result.Aggregations.Terms("terms_stack_id").Buckets; +foreach (var b in buckets) +{ + DateTime first = b.Aggregations.Min("min_date").Value; + DateTime last = b.Aggregations.Max("max_date").Value; +} +``` + +**Complex multi-aggregation (DailySummaryJob):** + +```csharp +var result = await _eventRepository.CountAsync(q => q + .SystemFilter(systemFilter) + .FilterExpression(filter) + .EnforceEventStackFilter() + .AggregationsExpression("terms:(first @include:true) terms:(stack_id~3) cardinality:stack_id sum:count~1")); +double total = result.Aggregations.Sum("sum_count")?.Value ?? result.Total; +double uniqueTotal = result.Aggregations.Cardinality("cardinality_stack_id")?.Value ?? 0; +``` + +**Stack mode aggregations with sort prefix:** + +```csharp +string aggs = mode switch +{ + "stack_recent" => "cardinality:user sum:count~1 min:date -max:date", + "stack_frequent" => "cardinality:user -sum:count~1 min:date max:date", + _ => null +}; +var result = await _repository.CountAsync(q => q + .SystemFilter(systemFilter) + .FilterExpression(filter) + .EnforceEventStackFilter() + .AggregationsExpression($"terms:(stack_id~{limit} {aggs})")); +``` + +## FilterExpression (Lucene-style) + +FilterExpression accepts Lucene query syntax parsed by Foundatio Parsers: + +```csharp +.FilterExpression("type:error (status:open OR status:regressed)") +.FilterExpression($"project:{projectId}") +.FilterExpression($"stack:{stackId}") +.FilterExpression("status:open OR status:regressed") +.FilterExpression($"signature_hash:{signature}") +.FilterExpression("is_deleted:false") +``` + +**Building OR filters from collections:** + +```csharp +string filter = String.Join(" OR ", stackIds.Select(id => $"stack:{id}")); +``` + +## Query Extension Methods + +Custom extensions on `IRepositoryQuery`: + +| Method | Purpose | File | +| ------------------------------- | ----------------------------------- | ------------------------ | +| `.Organization(id)` | Filter by organization_id | OrganizationQuery.cs | +| `.Organization(ids)` | Filter by multiple org IDs | OrganizationQuery.cs | +| `.Project(id)` | Filter by project_id | ProjectQuery.cs | +| `.Stack(id)` / `.Stack(ids)` | Filter by stack_id | StackQuery.cs | +| `.ExcludeStack(id)` | Exclude stack_id | StackQuery.cs | +| `.AppFilter(sf)` | Apply app-level system filter | AppFilterQuery.cs | +| `.SystemFilter(query)` | Chain a pre-built query | Foundatio built-in | +| `.EnforceEventStackFilter()` | Resolve stack filters to event IDs | EventStackFilterQuery.cs | +| `.DateRange(start, end, field)` | Date range filter | Foundatio built-in | +| `.Index(start, end)` | Route to daily shards (events only) | Foundatio built-in | +| `.FieldEquals(expr, value)` | Exact field match | Foundatio built-in | +| `.SortExpression(sort)` | Sort expression | Foundatio built-in | + +## Pagination + +### Standard do/while Pattern (preferred) + +```csharp +var results = await _repository.GetAllAsync(o => o.SearchAfterPaging().PageLimit(500)); +do +{ + foreach (var doc in results.Documents) + { + // process document + } +} while (!cancellationToken.IsCancellationRequested && await results.NextPageAsync()); +``` + +### While-loop Pattern (when processing before checking) + +```csharp +var results = await _repository.GetAllAsync(o => o.SearchAfterPaging().PageLimit(5)); +while (results.Documents.Count > 0 && !cancellationToken.IsCancellationRequested) +{ + foreach (var doc in results.Documents) + { + // process document + } + + if (cancellationToken.IsCancellationRequested || !await results.NextPageAsync()) + break; +} +``` + +**Key rules:** + +- `NextPageAsync()` returns `Task` and mutates results in-place +- **Never** use `while(true) { ... break; }` — use `do/while` or `while(condition)` +- Always use `SearchAfterPaging()` for deep pagination (not offset-based) +- Always check `CancellationToken` in the loop condition + +### Collecting All IDs + +```csharp +var results = await _stackRepository.GetIdsByQueryAsync( + q => systemFilterQuery.As(), + o => o.PageLimit(10000).SearchAfterPaging()); + +var stackIds = new List(); +if (results?.Hits is not null) +{ + do + { + stackIds.AddRange(results.Hits.Select(h => h.Id)); + } while (await results.NextPageAsync()); +} +``` + +## PatchAllAsync / PatchAsync + +### PartialPatch (field-level update) + +```csharp +// Suspend all tokens for an org +await _tokenRepository.PatchAllAsync( + q => q.Organization(orgId).FieldEquals(t => t.IsSuspended, false), + new PartialPatch(new { is_suspended = true }), + o => o.ImmediateConsistency()); +``` + +### ScriptPatch (Painless script) + +```csharp +const string script = @" +ctx._source.total_occurrences += params.count; +ctx._source.last_occurrence = params.maxOccurrenceDateUtc;"; + +var patch = new ScriptPatch(script.TrimScript()) +{ + Params = new Dictionary + { + { "count", count }, + { "maxOccurrenceDateUtc", maxDate } + } +}; + +await _stackRepository.PatchAsync(stackId, patch, o => o.Notifications(false)); +``` + +### PatchAsync with Array of IDs + +```csharp +string script = $"ctx._source.next_summary_end_of_day_ticks += {TimeSpan.TicksPerDay}L;"; +await PatchAsync(projects.Select(p => p.Id).ToArray(), new ScriptPatch(script), o => o.Notifications(false)); +``` + +### Soft Delete + +```csharp +await PatchAllAsync( + q => q.Organization(orgId).Project(projectId), + new PartialPatch(new { is_deleted = true, updated_utc = _timeProvider.GetUtcNow().UtcDateTime })); +``` + +## RemoveAllAsync + +```csharp +// By organization + date range +await _eventRepository.RemoveAllAsync(organizationId, clientIpAddress, utcStart, utcEnd); + +// By stack IDs +await _eventRepository.RemoveAllByStackIdsAsync(stackIds); + +// With inline filter +await _repository.RemoveAllAsync(q => q.Organization(orgId)); +``` + +## GetByIdsAsync (Batch Existence Checks) + +```csharp +// Batch fetch — returns only found documents +var stacks = await _stackRepository.GetByIdsAsync(stackIds); + +// With cache +var users = await _userRepository.GetByIdsAsync(userIds, o => o.Cache()); + +// Check existence by comparing returned set +var found = (await _organizationRepository.GetByIdsAsync(orgIds)).Select(o => o.Id).ToHashSet(); +var missing = orgIds.Where(id => !found.Contains(id)).ToArray(); +``` + +## CommandOptions + +| Option | Purpose | +| ------------------------------- | -------------------------------------------- | +| `o => o.Cache()` | Enable cache read/write | +| `o => o.Cache("key")` | Cache with specific key | +| `o => o.ReadCache()` | Only read from cache | +| `o => o.ImmediateConsistency()` | ES refresh after write (tests) | +| `o => o.SearchAfterPaging()` | Deep pagination with search_after | +| `o => o.PageLimit(N)` | Page size | +| `o => o.PageNumber(N)` | Page number (use SearchAfterPaging for deep) | +| `o => o.SoftDeleteMode(mode)` | `All`, `ActiveOnly`, `DeletedOnly` | +| `o => o.Notifications(false)` | Suppress change notifications | +| `o => o.Originals()` | Track original values for change detection | +| `o => o.OnlyIds()` | Return only IDs (no source) | + +## Anti-Patterns + +**NEVER do these:** + +- Use `_elasticClient.SearchAsync(...)` — use `CountAsync` or `FindAsync` +- Use `_elasticClient.MultiGetAsync(...)` — use `GetByIdsAsync` +- Use `_elasticClient.DeleteByQueryAsync(...)` — use `RemoveAllAsync` +- Use `_elasticClient.UpdateByQueryAsync(...)` — use `PatchAllAsync` +- Use `_elasticClient.Indices.RefreshAsync(...)` — use `o => o.ImmediateConsistency()` +- Use `while(true) { ... break; }` for pagination — use `do/while` or `while(condition)` diff --git a/.github/skills/foundatio/SKILL.md b/.agents/skills/foundatio/SKILL.md similarity index 85% rename from .github/skills/foundatio/SKILL.md rename to .agents/skills/foundatio/SKILL.md index 5975511c00..4a00b7921b 100644 --- a/.github/skills/foundatio/SKILL.md +++ b/.agents/skills/foundatio/SKILL.md @@ -1,10 +1,10 @@ --- -name: Foundatio -description: | - Foundatio infrastructure abstractions for caching, queuing, messaging, file storage, - locking, jobs, and resilience. Use context7 for complete API documentation. - Keywords: Foundatio, ICacheClient, IQueue, IMessageBus, IFileStorage, ILockProvider, - IJob, QueueJobBase, resilience, retry, Redis, Elasticsearch +name: foundatio +description: > + Use this skill when working with Foundatio infrastructure abstractions — caching, queuing, + messaging, file storage, locking, or background jobs. Apply when using ICacheClient, IQueue, + IMessageBus, IFileStorage, ILockProvider, or IJob, or when implementing retry/resilience + patterns. Covers both in-memory and production (Redis, Elasticsearch) implementations. --- # Foundatio @@ -15,14 +15,14 @@ Foundatio provides pluggable infrastructure abstractions. Use context7 MCP for c ## Core Abstractions -| Interface | Purpose | In-Memory | Production | -| --------- | ------- | --------- | ---------- | -| `ICacheClient` | Distributed caching | `InMemoryCacheClient` | Redis | -| `IQueue` | Message queuing | `InMemoryQueue` | Redis/SQS | -| `IMessageBus` | Pub/sub messaging | `InMemoryMessageBus` | Redis | -| `IFileStorage` | File storage | `InMemoryFileStorage` | S3/Azure | -| `ILockProvider` | Distributed locking | `InMemoryLockProvider` | Redis | -| `IResiliencePolicyProvider` | Retry/circuit breaker | N/A | Polly-based | +| Interface | Purpose | In-Memory | Production | +| --------------------------- | --------------------- | ---------------------- | ----------- | +| `ICacheClient` | Distributed caching | `InMemoryCacheClient` | Redis | +| `IQueue` | Message queuing | `InMemoryQueue` | Redis/SQS | +| `IMessageBus` | Pub/sub messaging | `InMemoryMessageBus` | Redis | +| `IFileStorage` | File storage | `InMemoryFileStorage` | S3/Azure | +| `ILockProvider` | Distributed locking | `InMemoryLockProvider` | Redis | +| `IResiliencePolicyProvider` | Retry/circuit breaker | N/A | Polly-based | ## ICacheClient @@ -241,7 +241,7 @@ services.AddSingleton(); services.AddSingleton(typeof(IQueue<>), typeof(InMemoryQueue<>)); ``` -See [backend-testing](backend-testing/SKILL.md) for `ProxyTimeProvider` patterns. +See [backend-testing](../backend-testing/SKILL.md) for `ProxyTimeProvider` patterns. ## Resilience & Reliability diff --git a/.github/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md similarity index 58% rename from .github/skills/frontend-architecture/SKILL.md rename to .agents/skills/frontend-architecture/SKILL.md index df1f636c56..f28b12c4a5 100644 --- a/.github/skills/frontend-architecture/SKILL.md +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -1,10 +1,10 @@ --- -name: Frontend Architecture -description: | - Svelte SPA architecture for Exceptionless. Route groups, lib structure, API client, - feature slices, and barrel exports. - Keywords: route groups, $lib, feature slices, api-client, barrel exports, index.ts, - vertical slices, shared components, generated models, ClientApp structure +name: frontend-architecture +description: > + Use this skill when working on the Svelte SPA's project structure — adding routes, creating + feature slices, organizing shared components, or understanding the ClientApp directory layout. + Covers route groups, $lib conventions, barrel exports, API client organization, and vertical + slice architecture. Apply when deciding where to place new files or components. --- # Frontend Architecture @@ -80,20 +80,25 @@ Centralize API calls per feature: ```typescript // features/organizations/api.svelte.ts -import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'; -import { useFetchClient } from '@exceptionless/fetchclient'; -import type { Organization, CreateOrganizationRequest } from './models'; +import { + createQuery, + createMutation, + useQueryClient, +} from "@tanstack/svelte-query"; +import { useFetchClient } from "@exceptionless/fetchclient"; +import type { Organization, CreateOrganizationRequest } from "./models"; export function getOrganizationsQuery() { const client = useFetchClient(); return createQuery(() => ({ - queryKey: ['organizations'], + queryKey: ["organizations"], queryFn: async () => { - const response = await client.getJSON('/organizations'); + const response = + await client.getJSON("/organizations"); if (!response.ok) throw response.problem; return response.data!; - } + }, })); } @@ -103,13 +108,16 @@ export function postOrganizationMutation() { return createMutation(() => ({ mutationFn: async (data: CreateOrganizationRequest) => { - const response = await client.postJSON('/organizations', data); + const response = await client.postJSON( + "/organizations", + data, + ); if (!response.ok) throw response.problem; return response.data!; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['organizations'] }); - } + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + }, })); } ``` @@ -123,8 +131,8 @@ Re-export generated models through feature model folders: export type { Organization, CreateOrganizationRequest, - UpdateOrganizationRequest -} from '$lib/generated'; + UpdateOrganizationRequest, +} from "$lib/generated"; // Add feature-specific types export interface OrganizationWithStats extends Organization { @@ -139,9 +147,9 @@ Use `index.ts` for clean imports: ```typescript // features/organizations/index.ts -export { getOrganizationsQuery, postOrganizationMutation } from './api.svelte'; -export type { Organization, CreateOrganizationRequest } from './models'; -export { organizationSchema } from './schemas'; +export { getOrganizationsQuery, postOrganizationMutation } from "./api.svelte"; +export type { Organization, CreateOrganizationRequest } from "./models"; +export { organizationSchema } from "./schemas"; ``` ## Shared Components @@ -152,7 +160,7 @@ Place truly shared components in appropriate locations: lib/ ├── features/shared/ # Shared between features │ ├── components/ -│ │ ├── formatters/ # Boolean, date, number formatters +│ │ ├── formatters/ # Boolean, date, number, bytes, duration, currency, percentage, time-ago formatters │ │ ├── loading/ │ │ └── error/ │ └── utils/ @@ -162,6 +170,35 @@ lib/ └── dialogs/ # Global dialogs ``` +### Formatter Components (MUST use — never write custom formatting functions) + +The `formatters/` directory contains Svelte components for displaying formatted values. **Always use these instead of writing custom formatting functions like `formatDateTime()` or `formatBytes()`.** + +| Component | Use For | +|-----------|---------| +| `` | Date and time display | +| `` | Relative time ("3 hours ago") | +| `` | Time durations | +| `` | File sizes, memory | +| `` | Numeric values with locale formatting | +| `` | True/false display | +| `` | Money amounts | +| `` | Percentage values | +| `` | Elasticsearch date math expressions | + +```svelte + + + + + + +{formatDateTime(event.date)} +{new Date(event.date).toLocaleString()} +``` + +**Consistency rule**: If a formatter component exists for a data type, you MUST use it. Creating a custom formatting function when a component already exists is a code review BLOCKER. + ## Generated Types When API contracts change: @@ -176,11 +213,22 @@ Prefer regeneration over hand-writing DTOs. Generated types live in `$lib/genera ```typescript // Configured in svelte.config.js -import { Button } from '$comp/ui/button'; // $lib/components -import { User } from '$features/users/models'; // $lib/features -import { formatDate } from '$shared/formatters'; // $lib/features/shared +import { Button } from "$comp/ui/button"; // $lib/components +import { User } from "$features/users/models"; // $lib/features +import { formatDate } from "$shared/formatters"; // $lib/features/shared ``` +## Consistency Rule + +**Before creating anything new, search the codebase for existing patterns.** Consistency is the most important quality of a codebase: + +1. Find the closest existing implementation of what you're building +2. Match its patterns exactly — file structure, naming, imports, component composition +3. Reuse shared utilities and components from `$lib/features/shared/` and `$comp/` +4. If an existing utility almost does what you need, extend it — don't create a parallel one + +Pattern divergence is a code review **BLOCKER**, not a nit. + ## Composite Component Pattern Study existing components before creating new ones: diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md new file mode 100644 index 0000000000..c34fc3081c --- /dev/null +++ b/.agents/skills/frontend-testing/SKILL.md @@ -0,0 +1,258 @@ +--- +name: frontend-testing +description: > + Use this skill when writing or running frontend unit and component tests with Vitest and + Testing Library. Covers render/screen/fireEvent patterns, vi.mock for mocking, and the + AAA (Arrange-Act-Assert) test structure. Apply when adding test coverage for Svelte + components, debugging test failures, or setting up test utilities. +--- + +# Frontend Testing + +> **Documentation:** [vitest.dev](https://vitest.dev) | [testing-library.com](https://testing-library.com/docs/svelte-testing-library/intro) + +## Running Tests + +```bash +npm run test:unit +``` + +## Framework & Location + +- **Framework**: Vitest + @testing-library/svelte +- **Location**: Co-locate with code as `.test.ts` or `.spec.ts` +- **TDD workflow**: When fixing bugs or adding features, write a failing test first + +## AAA Pattern + +Use explicit Arrange, Act, Assert regions: + +```typescript +import { describe, expect, it } from "vitest"; + +describe("Calculator", () => { + it("should add two numbers correctly", () => { + // Arrange + const a = 5; + const b = 3; + + // Act + const result = add(a, b); + + // Assert + expect(result).toBe(8); + }); + + it("should handle negative numbers", () => { + // Arrange + const a = -5; + const b = 3; + + // Act + const result = add(a, b); + + // Assert + expect(result).toBe(-2); + }); +}); +``` + +## Test Patterns from Codebase + +### Unit Tests with AAA + +From [dates.test.ts](../../../src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts): + +```typescript +import { describe, expect, it } from "vitest"; +import { getDifferenceInSeconds, getRelativeTimeFormatUnit } from "./dates"; + +describe("getDifferenceInSeconds", () => { + it("should calculate difference in seconds correctly", () => { + // Arrange + const now = new Date(); + const past = new Date(now.getTime() - 5000); + + // Act + const result = getDifferenceInSeconds(past); + + // Assert + expect(result).toBeCloseTo(5, 0); + }); +}); + +describe("getRelativeTimeFormatUnit", () => { + it("should return correct unit for given seconds", () => { + // Arrange & Act & Assert (simple value tests) + expect(getRelativeTimeFormatUnit(30)).toBe("seconds"); + expect(getRelativeTimeFormatUnit(1800)).toBe("minutes"); + expect(getRelativeTimeFormatUnit(7200)).toBe("hours"); + }); + + it("should handle boundary cases correctly", () => { + // Arrange & Act & Assert + expect(getRelativeTimeFormatUnit(59)).toBe("seconds"); + expect(getRelativeTimeFormatUnit(60)).toBe("minutes"); + }); +}); +``` + +### Testing with Spies + +From [cached-persisted-state.svelte.test.ts](../../../src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.test.ts): + +```typescript +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CachedPersistedState } from "./cached-persisted-state.svelte"; + +describe("CachedPersistedState", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should initialize with default value when storage is empty", () => { + // Arrange & Act + const state = new CachedPersistedState("test-key", "default"); + + // Assert + expect(state.current).toBe("default"); + }); + + it("should return cached value without reading storage repeatedly", () => { + // Arrange + const getItemSpy = vi.spyOn(Storage.prototype, "getItem"); + localStorage.setItem("test-key", "value1"); + const state = new CachedPersistedState("test-key", "default"); + getItemSpy.mockClear(); + + // Act + const val1 = state.current; + const val2 = state.current; + + // Assert + expect(val1).toBe("value1"); + expect(val2).toBe("value1"); + expect(getItemSpy).not.toHaveBeenCalled(); + }); +}); +``` + +### Testing String Transformations + +From [helpers.svelte.test.ts](../../../src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts): + +```typescript +import { describe, expect, it } from "vitest"; +import { quoteIfSpecialCharacters } from "./helpers.svelte"; + +describe("helpers.svelte", () => { + it("quoteIfSpecialCharacters handles tabs and newlines", () => { + // Arrange & Act & Assert + expect(quoteIfSpecialCharacters("foo\tbar")).toBe('"foo\tbar"'); + expect(quoteIfSpecialCharacters("foo\nbar")).toBe('"foo\nbar"'); + }); + + it("quoteIfSpecialCharacters handles empty string and undefined/null", () => { + // Arrange & Act & Assert + expect(quoteIfSpecialCharacters("")).toBe(""); + expect(quoteIfSpecialCharacters(undefined)).toBeUndefined(); + expect(quoteIfSpecialCharacters(null)).toBeNull(); + }); + + it("quoteIfSpecialCharacters quotes all Lucene special characters", () => { + // Arrange + const luceneSpecials = [ + "+", + "-", + "!", + "(", + ")", + "{", + "}", + "[", + "]", + "^", + '"', + "~", + "*", + "?", + ":", + "\\", + "/", + ]; + + // Act & Assert + for (const char of luceneSpecials) { + expect(quoteIfSpecialCharacters(char)).toBe(`"${char}"`); + } + }); +}); +``` + +## Query Selection Priority + +Use accessible queries (not implementation details): + +```typescript +// ✅ Role-based +screen.getByRole("button", { name: /submit/i }); +screen.getByRole("textbox", { name: /email/i }); + +// ✅ Label-based +screen.getByLabelText("Email address"); + +// ✅ Text-based +screen.getByText("Welcome back"); + +// ⚠️ Fallback: Test ID +screen.getByTestId("complex-chart"); + +// ❌ Avoid: Implementation details +screen.getByClassName("btn-primary"); +``` + +## Mocking Modules + +```typescript +import { vi, describe, it, beforeEach, expect } from "vitest"; +import { render, screen } from "@testing-library/svelte"; + +vi.mock("$lib/api/organizations", () => ({ + getOrganizations: vi.fn(), +})); + +import { getOrganizations } from "$lib/api/organizations"; +import OrganizationList from "./organization-list.svelte"; + +describe("OrganizationList", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("displays organizations from API", async () => { + // Arrange + const mockOrganizations = [{ id: "1", name: "Org One" }]; + vi.mocked(getOrganizations).mockResolvedValue(mockOrganizations); + + // Act + render(OrganizationList); + + // Assert + expect(await screen.findByText("Org One")).toBeInTheDocument(); + }); +}); +``` + +## Snapshot Testing (Use Sparingly) + +```typescript +it("matches snapshot", () => { + // Arrange & Act + const { container } = render(StaticComponent); + + // Assert + expect(container).toMatchSnapshot(); +}); +``` + +Use snapshots only for stable, static components. Prefer explicit assertions for dynamic content. diff --git a/.github/.agents/skills/releasenotes/SKILL.md b/.agents/skills/releasenotes/README.md similarity index 66% rename from .github/.agents/skills/releasenotes/SKILL.md rename to .agents/skills/releasenotes/README.md index e2d1c5c5fe..cba536149b 100644 --- a/.github/.agents/skills/releasenotes/SKILL.md +++ b/.agents/skills/releasenotes/README.md @@ -1,9 +1,14 @@ ---- -name: releasenotes -description: Generate formatted changelogs from git history since the last release tag. Use when preparing release notes that categorize changes into breaking changes, features, fixes, and other sections. -triggers: -- /releasenotes ---- +# Releasenotes + +Generate formatted changelogs from git history since the last release tag. Use when preparing release notes that categorize changes into breaking changes, features, fixes, and other sections. + +## Triggers + +This skill is activated by the following keywords: + +- `/releasenotes` + +## Details Generate a changelog for all changes from the most recent release until now. diff --git a/.github/skills/security-principles/SKILL.md b/.agents/skills/security-principles/SKILL.md similarity index 84% rename from .github/skills/security-principles/SKILL.md rename to .agents/skills/security-principles/SKILL.md index f7652ec0e3..2fbb49b1cb 100644 --- a/.github/skills/security-principles/SKILL.md +++ b/.agents/skills/security-principles/SKILL.md @@ -1,10 +1,10 @@ --- -name: Security Principles -description: | - Security best practices for the Exceptionless codebase. Secrets management, input validation, - secure defaults, and avoiding common vulnerabilities. - Keywords: security, secrets, encryption, PII, logging, input validation, secure defaults, - environment variables, OWASP, cryptography +name: security-principles +description: > + Use this skill when handling secrets, credentials, PII, input validation, or any + security-sensitive code. Covers secrets management, secure defaults, encryption, logging + safety, and common vulnerability prevention. Apply when adding authentication, configuring + environment variables, reviewing code for security issues, or working with sensitive data. --- # Security Principles diff --git a/.github/skills/shadcn-svelte/SKILL.md b/.agents/skills/shadcn-svelte/SKILL.md similarity index 89% rename from .github/skills/shadcn-svelte/SKILL.md rename to .agents/skills/shadcn-svelte/SKILL.md index cd64feda11..8e7cc0fe6a 100644 --- a/.github/skills/shadcn-svelte/SKILL.md +++ b/.agents/skills/shadcn-svelte/SKILL.md @@ -1,10 +1,10 @@ --- -name: shadcn-svelte Components -description: | - UI components with shadcn-svelte and bits-ui. Component patterns, trigger snippets, - dialog handling, and accessibility. - Keywords: shadcn-svelte, bits-ui, Button, Dialog, Sheet, Popover, DropdownMenu, - Tooltip, Form, Input, Select, child snippet, trigger pattern, cn utility +name: shadcn-svelte +description: > + Use this skill when building UI with shadcn-svelte or bits-ui components — buttons, dialogs, + sheets, popovers, dropdowns, tooltips, forms, inputs, or selects. Covers import patterns, + trigger snippets, child snippet composition, and the cn utility. Apply when adding or + customizing any shadcn-svelte component in the frontend. --- # shadcn-svelte Components @@ -173,18 +173,18 @@ When using trigger components with custom elements like Button, **always use the ```typescript // options.ts -import type { DropdownItem } from '$shared/types'; +import type { DropdownItem } from "$shared/types"; export enum Status { - Active = 'active', - Inactive = 'inactive', - Pending = 'pending' + Active = "active", + Inactive = "inactive", + Pending = "pending", } export const statusOptions: DropdownItem[] = [ - { value: Status.Active, label: 'Active' }, - { value: Status.Inactive, label: 'Inactive' }, - { value: Status.Pending, label: 'Pending' } + { value: Status.Active, label: "Active" }, + { value: Status.Inactive, label: "Inactive" }, + { value: Status.Pending, label: "Pending" }, ]; ``` diff --git a/.agents/skills/skill-evolution/SKILL.md b/.agents/skills/skill-evolution/SKILL.md new file mode 100644 index 0000000000..28abd56116 --- /dev/null +++ b/.agents/skills/skill-evolution/SKILL.md @@ -0,0 +1,113 @@ +--- +name: skill-evolution +description: > + Protocol for making skills self-improving over time. Use when you encounter a gap + in an existing skill, when reviewing skill effectiveness, or when the docs agent + processes accumulated skill gaps. Defines the observe-inspect-amend-evaluate cycle + for skill maintenance. +--- + +# Skill Evolution + +Skills are living documents, not static prompt files. This protocol defines how skills improve based on real usage patterns. + +## The Problem + +Skills that worked last month can silently degrade when: +- The codebase changes (new patterns, deprecated APIs, renamed modules) +- The kinds of tasks shift (new features, different complexity) +- Review findings reveal repeated gaps in guidance + +Without a feedback loop, these failures are invisible until output quality drops. + +## The Cycle + +``` +SKILL → RUN → OBSERVE → INSPECT → AMEND → EVALUATE → SKILL (improved) +``` + +### 1. Observe — Gap Detection + +When any agent encounters something not covered by a skill during normal work, append a gap marker to the relevant skill file: + +```markdown + +``` + +**Rules:** +- Be specific — "missing CancellationToken propagation guidance" not "missing async stuff" +- Include the file where the gap was encountered +- Include the date for tracking +- Do NOT fix the skill inline during other work — just mark the gap + +**Example:** +```markdown + +``` + +### 2. Inspect — Pattern Recognition + +Periodically (or on request via the `docs` agent), scan for accumulated gaps: + +```bash +grep -r "SKILL-GAP" .agents/skills/ --include="*.md" +``` + +Group gaps by: +- **Frequency**: Same gap appearing 3+ times = high priority +- **Skill**: Multiple gaps in one skill = skill needs major update +- **Recency**: Recent gaps in previously stable skills = environment changed + +### 3. Amend — Propose Changes + +When enough evidence exists (3+ gaps on same topic, or 1 critical gap), update the skill: + +1. Add the missing guidance to the appropriate section +2. Mark the amendment with a structured comment: + ```markdown + + ``` +3. Remove the resolved `SKILL-GAP` comments +4. Add a changelog entry + +**Amendment types:** +- **Add**: New section or checklist item for uncovered pattern +- **Tighten**: More specific trigger conditions to reduce false matches +- **Reorder**: Move frequently-needed guidance higher +- **Update**: Change guidance that no longer matches codebase reality + +### 4. Evaluate — Verify Improvement + +Git history IS the evaluation system. After an amendment: + +- If the `reviewer` agent stops flagging the pattern → amendment worked +- If the same gap reappears → amendment was insufficient, revisit +- If new issues appear in the amended area → amendment may have introduced confusion, consider rollback + +To roll back: `git revert` the amendment commit. The original skill is preserved in history. + +## Changelog Format + +Every skill should have a `## Changelog` section at the bottom: + +```markdown +## Changelog +- YYYY-MM-DD: Description of change (evidence: N gap markers / review finding / codebase change) +``` + +## Constraints + +- **Never modify third-party skills** — check `skills-lock.json` before editing +- **One amendment per commit** — makes rollback granular +- **Human review for major changes** — if an amendment touches >30% of a skill, flag for review +- **Preserve existing structure** — amend within the skill's existing organization, don't restructure + +## When to Trigger + +| Trigger | Action | +|---------|--------| +| During any agent work, encounter undocumented pattern | Add `SKILL-GAP` comment | +| User asks to review/improve skills | Run full inspect cycle | +| `docs` agent runs | Check for gaps, propose amendments | +| After 5+ gaps accumulate in one skill | Priority amendment needed | +| After major codebase change (new framework, migration) | Audit all affected skills | diff --git a/.github/skills/storybook/SKILL.md b/.agents/skills/storybook/SKILL.md similarity index 82% rename from .github/skills/storybook/SKILL.md rename to .agents/skills/storybook/SKILL.md index b40b348914..bdd6e56d72 100644 --- a/.github/skills/storybook/SKILL.md +++ b/.agents/skills/storybook/SKILL.md @@ -1,9 +1,10 @@ --- -name: Storybook -description: | - Component stories using Storybook with Svelte CSF. Story patterns, defineMeta, argTypes, - snippet-based customization, and visual testing. - Keywords: storybook, stories.svelte, defineMeta, Story, args, argTypes, autodocs +name: storybook +description: > + Use this skill when creating or updating Storybook stories for Svelte components. Covers + Svelte CSF story format, defineMeta, argTypes, snippet-based customization, and autodocs. + Apply when adding visual documentation for components, setting up story files, or running + Storybook for development. --- # Storybook @@ -22,7 +23,7 @@ Co-locate stories with components as `*.stories.svelte`. ## Basic Story Pattern -From [stack-status-badge.stories.svelte](src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-status-badge.stories.svelte): +From [stack-status-badge.stories.svelte](../../../src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-status-badge.stories.svelte): ```svelte -``` - -**Via npm:** -```bash -npm install @stripe/stripe-js -``` - -Major npm versions correspond to specific Stripe.js versions. - -### API Version Pairing - -Each Stripe.js version automatically pairs with its corresponding API version. For instance: -- Clover Stripe.js uses `2025-12-15.clover` API -- Acacia Stripe.js uses `2024-12-18.acacia` API - -You cannot override this association. - -### Migrating from v3 - -1. Identify your current API version in code -2. Review the changelog for relevant changes -3. Consider gradually updating your API version before switching Stripe.js versions -4. Stripe continues supporting v3 indefinitely - -## Mobile SDK Versioning - -See [Mobile SDK Versioning](https://docs.stripe.com/sdks/mobile-sdk-versioning.md) for details. - -### iOS and Android SDKs - -Both platforms follow **semantic versioning** (MAJOR.MINOR.PATCH): -- **MAJOR**: Breaking API changes -- **MINOR**: New functionality (backward-compatible) -- **PATCH**: Bug fixes (backward-compatible) - -New features and fixes release only on the latest major version. Upgrade regularly to access improvements. - -### React Native SDK - -Uses a different model (0.x.y schema): -- **Minor version changes** (x): Breaking changes AND new features -- **Patch updates** (y): Critical bug fixes only - -### Backend Compatibility - -All mobile SDKs work with any Stripe API version you use on your backend unless documentation specifies otherwise. - -## Upgrade Checklist - -1. Review the [API Changelog](https://docs.stripe.com/changelog.md) for changes between your current and target versions -2. Check [Upgrades Guide](https://docs.stripe.com/upgrades.md) for migration guidance -3. Update server-side SDK package version (e.g., `npm update stripe`, `pip install --upgrade stripe`) -4. Update the `apiVersion` parameter in your Stripe client initialization -5. Test your integration against the new API version using the `Stripe-Version` header -6. Update webhook handlers to handle new event structures -7. Update Stripe.js script tag or npm package version if needed -8. Update mobile SDK versions in your package manager if needed -9. Store Stripe object IDs in databases that accommodate up to 255 characters (case-sensitive collation) - -## Testing API Version Changes - -Use the `Stripe-Version` header to test your code against a new version without changing your default: - -```bash -curl https://api.stripe.com/v1/customers \ - -u sk_test_xxx: \ - -H "Stripe-Version: 2025-12-15.clover" -``` - -Or in code: - -```javascript -const stripe = require('stripe')('sk_test_xxx', { - apiVersion: '2025-12-15.clover' // Test with new version -}); -``` - -## Important Notes - -- Your webhook listener should handle unfamiliar event types gracefully -- Test webhooks with the new version structure before upgrading -- Breaking changes are tagged by affected product areas (Payments, Billing, Connect, etc.) -- Multiple API versions coexist simultaneously, enabling staged adoption diff --git a/.github/.github/skills/agent-browser b/.github/.github/skills/agent-browser deleted file mode 120000 index e298b7be3c..0000000000 --- a/.github/.github/skills/agent-browser +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/agent-browser \ No newline at end of file diff --git a/.github/.github/skills/frontend-design b/.github/.github/skills/frontend-design deleted file mode 120000 index 712f694a13..0000000000 --- a/.github/.github/skills/frontend-design +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/frontend-design \ No newline at end of file diff --git a/.github/.github/skills/releasenotes b/.github/.github/skills/releasenotes deleted file mode 120000 index ee96d1ae41..0000000000 --- a/.github/.github/skills/releasenotes +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/releasenotes \ No newline at end of file diff --git a/.github/.github/skills/skill-creator b/.github/.github/skills/skill-creator deleted file mode 120000 index b87455490f..0000000000 --- a/.github/.github/skills/skill-creator +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/skill-creator \ No newline at end of file diff --git a/.github/.github/skills/stripe-best-practices b/.github/.github/skills/stripe-best-practices deleted file mode 120000 index 6e25ed9dbc..0000000000 --- a/.github/.github/skills/stripe-best-practices +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/stripe-best-practices \ No newline at end of file diff --git a/.github/.github/skills/upgrade-stripe b/.github/.github/skills/upgrade-stripe deleted file mode 120000 index 3e50980012..0000000000 --- a/.github/.github/skills/upgrade-stripe +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/upgrade-stripe \ No newline at end of file diff --git a/.github/skills/agent-browser b/.github/skills/agent-browser deleted file mode 120000 index e298b7be3c..0000000000 --- a/.github/skills/agent-browser +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/agent-browser \ No newline at end of file diff --git a/.github/skills/foundatio-repositories/SKILL.md b/.github/skills/foundatio-repositories/SKILL.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/frontend-design b/.github/skills/frontend-design deleted file mode 120000 index 712f694a13..0000000000 --- a/.github/skills/frontend-design +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/frontend-design \ No newline at end of file diff --git a/.github/skills/frontend-testing/SKILL.md b/.github/skills/frontend-testing/SKILL.md deleted file mode 100644 index ada25aaff4..0000000000 --- a/.github/skills/frontend-testing/SKILL.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -name: Frontend Testing -description: | - Unit and component testing for the frontend with Vitest and Testing Library. - Keywords: Vitest, @testing-library/svelte, component tests, vi.mock, render, screen, - fireEvent, userEvent, test.ts, spec.ts, describe, it, AAA pattern ---- - -# Frontend Testing - -> **Documentation:** [vitest.dev](https://vitest.dev) | [testing-library.com](https://testing-library.com/docs/svelte-testing-library/intro) - -## Running Tests - -```bash -npm run test:unit -``` - -## Framework & Location - -- **Framework**: Vitest + @testing-library/svelte -- **Location**: Co-locate with code as `.test.ts` or `.spec.ts` -- **TDD workflow**: When fixing bugs or adding features, write a failing test first - -## AAA Pattern - -Use explicit Arrange, Act, Assert regions: - -```typescript -import { describe, expect, it } from 'vitest'; - -describe('Calculator', () => { - it('should add two numbers correctly', () => { - // Arrange - const a = 5; - const b = 3; - - // Act - const result = add(a, b); - - // Assert - expect(result).toBe(8); - }); - - it('should handle negative numbers', () => { - // Arrange - const a = -5; - const b = 3; - - // Act - const result = add(a, b); - - // Assert - expect(result).toBe(-2); - }); -}); -``` - -## Test Patterns from Codebase - -### Unit Tests with AAA - -From [dates.test.ts](src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts): - -```typescript -import { describe, expect, it } from 'vitest'; -import { getDifferenceInSeconds, getRelativeTimeFormatUnit } from './dates'; - -describe('getDifferenceInSeconds', () => { - it('should calculate difference in seconds correctly', () => { - // Arrange - const now = new Date(); - const past = new Date(now.getTime() - 5000); - - // Act - const result = getDifferenceInSeconds(past); - - // Assert - expect(result).toBeCloseTo(5, 0); - }); -}); - -describe('getRelativeTimeFormatUnit', () => { - it('should return correct unit for given seconds', () => { - // Arrange & Act & Assert (simple value tests) - expect(getRelativeTimeFormatUnit(30)).toBe('seconds'); - expect(getRelativeTimeFormatUnit(1800)).toBe('minutes'); - expect(getRelativeTimeFormatUnit(7200)).toBe('hours'); - }); - - it('should handle boundary cases correctly', () => { - // Arrange & Act & Assert - expect(getRelativeTimeFormatUnit(59)).toBe('seconds'); - expect(getRelativeTimeFormatUnit(60)).toBe('minutes'); - }); -}); -``` - -### Testing with Spies - -From [cached-persisted-state.svelte.test.ts](src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/cached-persisted-state.svelte.test.ts): - -```typescript -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CachedPersistedState } from './cached-persisted-state.svelte'; - -describe('CachedPersistedState', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should initialize with default value when storage is empty', () => { - // Arrange & Act - const state = new CachedPersistedState('test-key', 'default'); - - // Assert - expect(state.current).toBe('default'); - }); - - it('should return cached value without reading storage repeatedly', () => { - // Arrange - const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); - localStorage.setItem('test-key', 'value1'); - const state = new CachedPersistedState('test-key', 'default'); - getItemSpy.mockClear(); - - // Act - const val1 = state.current; - const val2 = state.current; - - // Assert - expect(val1).toBe('value1'); - expect(val2).toBe('value1'); - expect(getItemSpy).not.toHaveBeenCalled(); - }); -}); -``` - -### Testing String Transformations - -From [helpers.svelte.test.ts](src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts): - -```typescript -import { describe, expect, it } from 'vitest'; -import { quoteIfSpecialCharacters } from './helpers.svelte'; - -describe('helpers.svelte', () => { - it('quoteIfSpecialCharacters handles tabs and newlines', () => { - // Arrange & Act & Assert - expect(quoteIfSpecialCharacters('foo\tbar')).toBe('"foo\tbar"'); - expect(quoteIfSpecialCharacters('foo\nbar')).toBe('"foo\nbar"'); - }); - - it('quoteIfSpecialCharacters handles empty string and undefined/null', () => { - // Arrange & Act & Assert - expect(quoteIfSpecialCharacters('')).toBe(''); - expect(quoteIfSpecialCharacters(undefined)).toBeUndefined(); - expect(quoteIfSpecialCharacters(null)).toBeNull(); - }); - - it('quoteIfSpecialCharacters quotes all Lucene special characters', () => { - // Arrange - const luceneSpecials = ['+', '-', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\', '/']; - - // Act & Assert - for (const char of luceneSpecials) { - expect(quoteIfSpecialCharacters(char)).toBe(`"${char}"`); - } - }); -}); -``` - -## Query Selection Priority - -Use accessible queries (not implementation details): - -```typescript -// ✅ Role-based -screen.getByRole('button', { name: /submit/i }); -screen.getByRole('textbox', { name: /email/i }); - -// ✅ Label-based -screen.getByLabelText('Email address'); - -// ✅ Text-based -screen.getByText('Welcome back'); - -// ⚠️ Fallback: Test ID -screen.getByTestId('complex-chart'); - -// ❌ Avoid: Implementation details -screen.getByClassName('btn-primary'); -``` - -## Mocking Modules - -```typescript -import { vi, describe, it, beforeEach, expect } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; - -vi.mock('$lib/api/organizations', () => ({ - getOrganizations: vi.fn() -})); - -import { getOrganizations } from '$lib/api/organizations'; -import OrganizationList from './organization-list.svelte'; - -describe('OrganizationList', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('displays organizations from API', async () => { - // Arrange - const mockOrganizations = [{ id: '1', name: 'Org One' }]; - vi.mocked(getOrganizations).mockResolvedValue(mockOrganizations); - - // Act - render(OrganizationList); - - // Assert - expect(await screen.findByText('Org One')).toBeInTheDocument(); - }); -}); -``` - -## Snapshot Testing (Use Sparingly) - -```typescript -it('matches snapshot', () => { - // Arrange & Act - const { container } = render(StaticComponent); - - // Assert - expect(container).toMatchSnapshot(); -}); -``` - -Use snapshots only for stable, static components. Prefer explicit assertions for dynamic content. diff --git a/.github/skills/releasenotes b/.github/skills/releasenotes deleted file mode 120000 index ee96d1ae41..0000000000 --- a/.github/skills/releasenotes +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/releasenotes \ No newline at end of file diff --git a/.github/skills/stripe-best-practices b/.github/skills/stripe-best-practices deleted file mode 120000 index 6e25ed9dbc..0000000000 --- a/.github/skills/stripe-best-practices +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/stripe-best-practices \ No newline at end of file diff --git a/.github/skills/upgrade-stripe b/.github/skills/upgrade-stripe deleted file mode 120000 index 3e50980012..0000000000 --- a/.github/skills/upgrade-stripe +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/upgrade-stripe \ No newline at end of file diff --git a/.github/update-skills.ps1 b/.github/update-skills.ps1 deleted file mode 100644 index 21e8aeda26..0000000000 --- a/.github/update-skills.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS -Updates GitHub Copilot Agent Skills -#> - -Write-Host "🔄 Updating GitHub Copilot Agent Skills..." -ForegroundColor Cyan - -npx skills add OpenHands/skills --skill releasenotes --agent github-copilot --yes -Write-Host "✅ Added OpenHands releasenotes skill" -ForegroundColor Green - -npx skills add stripe/ai --agent github-copilot --yes -Write-Host "✅ Added Stripe AI best-practices" -ForegroundColor Green - -npx skills add vercel-labs/agent-browser --agent github-copilot --yes -Write-Host "✅ Added Vercel Labs agent-browser" -ForegroundColor Green - -npx skills add anthropics/skills --skill frontend-design --agent github-copilot --yes -Write-Host "✅ Added Anthropic frontend-design skill" -ForegroundColor Green - -Write-Host "`n✨ GitHub Copilot Agent Skills update complete!" -ForegroundColor Cyan diff --git a/.gitignore b/.gitignore index 4d374fb0d0..f647a6846a 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ src/Exceptionless\.Web/ClientApp.angular/dist/ *.DotSettings coverage/ +nul +tmpclaude* diff --git a/.vscode/settings.json b/.vscode/settings.json index a6a03567f2..3a3040926c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,17 +12,24 @@ "cSpell.words": [ "acacode", "autodocs", + "azurestorage", + "Backdoors", + "Bisectable", + "Bootstrapper", "circleci", "classvalidator", "clsx", "cmdk", "colour", + "CONNECTIONSTRING", "Contoso", "datemath", "dockerignore", "editorconfig", "elasticsearch", "Exceptionless", + "exfiltration", + "fairwinds", "fetchclient", "formsnap", "Foundatio", @@ -30,16 +37,21 @@ "Guid", "haserror", "iconify", + "IDOR", "keyof", + "KUBECOST", "layerchart", "LDAP", "legos", "lucene", "lucide", + "mypass", "nameof", "navigatetofirstpage", "oidc", + "oneline", "promotedtabs", + "releasenotes", "rowclick", "runed", "satellizer", @@ -48,15 +60,22 @@ "shadcn", "shiki", "shikijs", + "signoz", "sonner", "standardjs", "superforms", "tailwindcss", "tanstack", + "TOCTOU", + "triaging", "typeschema", "unsuspended", + "Vite", + "vitest", + "VNET", "WCAG", "websockets", + "winget", "Writeline", "Xunit" ], @@ -76,6 +95,7 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "json.schemaDownload.enable": false, "prettier.documentSelectors": [ "**/*.svelte" ], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index baa0d0c9af..7357d3923e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,25 @@ "version": "2.0.0", "tasks": [ { - "label": "Build", + "label": "build (active file context)", + "type": "process", + "command": "pwsh", + "args": [ + "-NoProfile", + "-Command", + "$ext='${fileExtname}'; if ($ext -in @('.svelte', '.ts', '.js', '.tsx', '.jsx', '.css', '.html')) { Write-Host \"Frontend file detected ($ext). Running npm run build...\"; Set-Location 'src/Exceptionless.Web/ClientApp'; npm run build } elseif ($ext -in @('.cs', '.csproj', '.sln', '.slnx')) { Write-Host \"Backend file detected ($ext). Running dotnet build...\"; dotnet build /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary } else { Write-Host \"Unknown or no active file extension ($ext). Defaulting to dotnet build...\"; dotnet build /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary }" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always" + }, + "problemMatcher": [] + }, + { + "label": "dotnet build", "command": "dotnet", "type": "shell", "args": [ @@ -12,7 +30,7 @@ ], "group": { "kind": "build", - "isDefault": true + "isDefault": false }, "presentation": { "reveal": "silent" @@ -138,7 +156,7 @@ ], "group": { "kind": "build", - "isDefault": true + "isDefault": false }, "presentation": { "reveal": "always" @@ -188,7 +206,7 @@ "script": "lint", "group": { "kind": "build", - "isDefault": true + "isDefault": false }, "presentation": { "reveal": "always" @@ -220,7 +238,7 @@ ], "group": { "kind": "build", - "isDefault": true + "isDefault": false }, "presentation": { "reveal": "never" @@ -252,7 +270,7 @@ ], "group": { "kind": "build", - "isDefault": true + "isDefault": false }, "presentation": { "reveal": "never" diff --git a/AGENTS.md b/AGENTS.md index 6a179bb43c..30d6e030e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ Run `Exceptionless.AppHost` from your IDE. Aspire starts all services (Elasticse | Frontend test | `npm run test:unit` | | E2E test | `npm run test:e2e` | +Test filtering note: the backend test project uses Microsoft Testing Platform, so targeted runs use test-app options after `--`, for example `dotnet test -- --filter-class Exceptionless.Tests.Controllers.EventControllerTests`. + ## Project Structure ```text @@ -30,26 +32,53 @@ tests/ # C# tests + HTTP samples ## Continuous Improvement -Each time you complete a task or learn important information about the project, you must update the `AGENTS.md`, `README.md`, or relevant skill files. **Only update skills if they are owned by us** (verify via `.github/update-skills.ps1` which lists third-party skills). You are **forbidden** from updating skills, configurations, or instructions maintained by third parties/external libraries. +Each time you complete a task or learn important information about the project, you must update the `AGENTS.md`, `README.md`, or relevant skill files. **Only update skills if they are owned by us** (verify via `skills-lock.json` which lists third-party skills). You are **forbidden** from updating skills, configurations, or instructions maintained by third parties/external libraries. If you encounter recurring questions or patterns during planning, document them: - Project-specific knowledge → `AGENTS.md` or relevant skill file -- Reusable domain patterns → Create/update appropriate skill in `.github/skills/` +- Reusable domain patterns → Create/update appropriate skill in `.agents/skills/` ## Skills -Load from `.github/skills//SKILL.md` when working in that domain: +Load from `.agents/skills//SKILL.md` when working in that domain: + +| Domain | Skills | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Backend | dotnet-conventions, backend-architecture, dotnet-cli, backend-testing, foundatio | +| Frontend | svelte-components, tanstack-form, tanstack-query, shadcn-svelte, typescript-conventions, frontend-architecture, storybook, accessibility, frontend-design | +| Testing | frontend-testing, e2e-testing | +| Cross-cutting | security-principles, releasenotes | +| Billing | stripe-best-practices, upgrade-stripe | +| Agents | agent-browser, dogfood | +| Meta | skill-evolution | + +## Agents + +Available in `.claude/agents/`. Use `@agent-name` to invoke: + +- `engineer`: Use for implementing features, fixing bugs, or making code changes — plans, TDD, implements, verify loop, ships end-to-end +- `reviewer`: Use for reviewing code quality — adversarial 4-pass analysis (security → build → correctness → style). Read-only. +- `triage`: Use for analyzing issues, investigating bugs, or answering codebase questions — impact assessment, RCA, reproduction, implementation plans +- `pr-reviewer`: Use for end-to-end PR review — zero-trust security pre-screen, dependency audit, delegates to @reviewer, delivers verdict -| Domain | Skills | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Backend | dotnet-conventions, backend-architecture, dotnet-cli, backend-testing, foundatio | -| Frontend | svelte-components, tanstack-form, tanstack-query, shadcn-svelte, typescript-conventions, frontend-architecture, storybook, accessibility | -| Testing | frontend-testing, e2e-testing | -| Cross-cutting | security-principles | +### Orchestration Flow + +```text +engineer → TDD → implement → verify (loop until clean) + → @reviewer (loop until 0 blockers) → commit → push → PR + → @copilot review → CI checks → resolve feedback → merge + +triage → impact assessment → deep research → RCA → reproduce + → implementation plan → post to GitHub → @engineer + +pr-reviewer → security pre-screen (before build!) → dependency audit + → build → @reviewer (4-pass) → verdict +``` ## Constraints - Use `npm ci` (not `npm install`) - Never commit secrets — use environment variables - NuGet feeds are in `NuGet.Config` — don't add sources +- Prefer additive documentation updates — don't replace strategic docs wholesale, extend them diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000000..eaf5c34e7b --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "skills": { + "agent-browser": { + "source": "vercel-labs/agent-browser", + "sourceType": "github", + "computedHash": "57bfa07d34f966bccf791d49c9b6ce507fbba08f7dbf9e2ad15809e2dd29041e" + }, + "dogfood": { + "source": "vercel-labs/agent-browser", + "sourceType": "github", + "computedHash": "17a3fc2c1a49df9debce572c7b47521caa96f793597d944c4e648b3aa90738cf" + }, + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67" + }, + "releasenotes": { + "source": "OpenHands/skills", + "sourceType": "github", + "computedHash": "1b12297eacd1cd24b9f1eda16dada205b96d9c75fd3bf07474a54775559fff09" + }, + "stripe-best-practices": { + "source": "stripe/ai", + "sourceType": "github", + "computedHash": "91b23c09854900c0ed1e85a8a3856b4a7294af2076f20b2cf9ca97f6f21dcb7b" + }, + "upgrade-stripe": { + "source": "stripe/ai", + "sourceType": "github", + "computedHash": "83b0068e099b50bd71febd20373e13dc55b2b20cf6daaf36f45089e2fd7c92bb" + } + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 475cef6ebe..95bbe00242 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -21,7 +21,7 @@ - + diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index 5282bd1ffa..615a62ee7b 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -7,9 +7,9 @@ a9c2ddcc-e51d-4cd1-9782-96e1d74eec87 - - - + + + @@ -17,5 +17,4 @@ - diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 87e56cd3cb..f264439dca 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core.Authentication; using Exceptionless.Core.Billing; using Exceptionless.Core.Configuration; @@ -102,14 +101,16 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => { var handlers = new WorkItemHandlers(); + handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); - handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); - handlers.Register(s.GetRequiredService); - handlers.Register(s.GetRequiredService); - handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); return handlers; }); @@ -197,21 +198,6 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddTransient(); - - services.AddTransient(); - services.AddSingleton(s => - { - var profiles = s.GetServices(); - var c = new MapperConfiguration(cfg => - { - cfg.ConstructServicesUsing(s.GetRequiredService); - - foreach (var profile in profiles) - cfg.AddProfile(profile); - }); - - return c.CreateMapper(); - }); } public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger) diff --git a/src/Exceptionless.Core/Configuration/CacheOptions.cs b/src/Exceptionless.Core/Configuration/CacheOptions.cs index 0f6c0cf96c..a06c7c778b 100644 --- a/src/Exceptionless.Core/Configuration/CacheOptions.cs +++ b/src/Exceptionless.Core/Configuration/CacheOptions.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Foundatio.Repositories.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; diff --git a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs index 63508262e6..88a2d019e3 100644 --- a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs +++ b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Foundatio.Repositories.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; diff --git a/src/Exceptionless.Core/Configuration/MetricOptions.cs b/src/Exceptionless.Core/Configuration/MetricOptions.cs index d3875b5c19..730ad17358 100644 --- a/src/Exceptionless.Core/Configuration/MetricOptions.cs +++ b/src/Exceptionless.Core/Configuration/MetricOptions.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Foundatio.Repositories.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; diff --git a/src/Exceptionless.Core/Configuration/QueueOptions.cs b/src/Exceptionless.Core/Configuration/QueueOptions.cs index c67ed035a7..e8463f6651 100644 --- a/src/Exceptionless.Core/Configuration/QueueOptions.cs +++ b/src/Exceptionless.Core/Configuration/QueueOptions.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Foundatio.Repositories.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; diff --git a/src/Exceptionless.Core/Configuration/StorageOptions.cs b/src/Exceptionless.Core/Configuration/StorageOptions.cs index 713767660f..a6d28b8d27 100644 --- a/src/Exceptionless.Core/Configuration/StorageOptions.cs +++ b/src/Exceptionless.Core/Configuration/StorageOptions.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Extensions; -using Foundatio.Repositories.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 3db2f58214..a706da2023 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -20,22 +20,21 @@ - - - + + - - - + + + - + - + diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index f502a0d7cc..c0901a893f 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -14,6 +14,8 @@ public static void CopyDataToIndex(this PersistentEvent ev, string[]? keysToCopy if (ev.Data is null) return; + ev.Idx ??= new DataDictionary(); + keysToCopy = keysToCopy?.Length > 0 ? keysToCopy : ev.Data.Keys.ToArray(); foreach (string key in keysToCopy.Where(k => !String.IsNullOrEmpty(k) && ev.Data.ContainsKey(k))) @@ -170,7 +172,7 @@ public static bool UpdateSessionStart(this PersistentEvent ev, DateTime lastActi else { ev.Data.Remove(Event.KnownDataKeys.SessionEnd); - ev.Idx.Remove(Event.KnownDataKeys.SessionEnd + "-d"); + ev.Idx?.Remove(Event.KnownDataKeys.SessionEnd + "-d"); } return true; diff --git a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs index fade8ec24e..c968f02623 100644 --- a/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs +++ b/src/Exceptionless.Core/Jobs/CloseInactiveSessionsJob.cs @@ -57,10 +57,9 @@ protected override async Task RunInternalAsync(JobContext context) var cacheKeysToRemove = new List(results.Documents.Count * 2); var existingSessionHeartbeatIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var sessionStart in results.Documents) + foreach (var (sessionStart, heartbeatResult) in await GetHeartbeatsBatchAsync(results.Documents)) { var lastActivityUtc = sessionStart.Date.UtcDateTime.AddSeconds((double)sessionStart.Value.GetValueOrDefault()); - var heartbeatResult = await GetHeartbeatAsync(sessionStart); bool closeDuplicate = heartbeatResult?.CacheKey is not null && existingSessionHeartbeatIds.Contains(heartbeatResult.CacheKey); if (heartbeatResult?.CacheKey is not null && !closeDuplicate) @@ -112,33 +111,85 @@ protected override async Task RunInternalAsync(JobContext context) return JobResult.Success; } - private async Task GetHeartbeatAsync(PersistentEvent sessionStart) + private async Task<(PersistentEvent Session, HeartbeatResult? Heartbeat)[]> GetHeartbeatsBatchAsync(IReadOnlyCollection sessionCollection) { - string? sessionId = sessionStart.GetSessionId(); - if (!String.IsNullOrWhiteSpace(sessionId)) + var sessions = sessionCollection.ToList(); + var allHeartbeatKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var sessionKeyMap = new (string? SessionIdKey, string? UserIdentityKey)[sessions.Count]; + + for (int i = 0; i < sessions.Count; i++) { - var result = await GetLastHeartbeatActivityUtcAsync($"Project:{sessionStart.ProjectId}:heartbeat:{sessionId.ToSHA1()}"); - if (result is not null) - return result; + var session = sessions[i]; + string? sessionIdKey = null; + string? userIdentityKey = null; + + string? sessionId = session.GetSessionId(); + if (!String.IsNullOrWhiteSpace(sessionId)) + { + sessionIdKey = $"Project:{session.ProjectId}:heartbeat:{sessionId.ToSHA1()}"; + allHeartbeatKeys.Add(sessionIdKey); + } + + var user = session.GetUserIdentity(_jsonOptions); + if (!String.IsNullOrWhiteSpace(user?.Identity)) + { + userIdentityKey = $"Project:{session.ProjectId}:heartbeat:{user.Identity.ToSHA1()}"; + allHeartbeatKeys.Add(userIdentityKey); + } + + sessionKeyMap[i] = (sessionIdKey, userIdentityKey); } - var user = sessionStart.GetUserIdentity(_jsonOptions); - if (String.IsNullOrWhiteSpace(user?.Identity)) - return null; + if (allHeartbeatKeys.Count == 0) + return sessions.Select(s => (s, (HeartbeatResult?)null)).ToArray(); - return await GetLastHeartbeatActivityUtcAsync($"Project:{sessionStart.ProjectId}:heartbeat:{user.Identity.ToSHA1()}"); - } + var heartbeatValues = await _cache.GetAllAsync(allHeartbeatKeys); - private async Task GetLastHeartbeatActivityUtcAsync(string cacheKey) - { - var cacheValue = await _cache.GetAsync(cacheKey); - if (cacheValue.HasValue) + var closeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var resolved = new (DateTime ActivityUtc, string CacheKey)?[sessions.Count]; + + for (int i = 0; i < sessionKeyMap.Length; i++) + { + var (sessionIdKey, userIdentityKey) = sessionKeyMap[i]; + string? matchedKey = null; + DateTime activityUtc = default; + + if (sessionIdKey is not null && heartbeatValues.TryGetValue(sessionIdKey, out var sidVal) && sidVal.HasValue) + { + matchedKey = sessionIdKey; + activityUtc = sidVal.Value; + } + else if (userIdentityKey is not null && heartbeatValues.TryGetValue(userIdentityKey, out var uidVal) && uidVal.HasValue) + { + matchedKey = userIdentityKey; + activityUtc = uidVal.Value; + } + + if (matchedKey is not null) + { + resolved[i] = (activityUtc, matchedKey); + closeKeys.Add($"{matchedKey}-close"); + } + } + + IDictionary> closeValues = closeKeys.Count > 0 + ? await _cache.GetAllAsync(closeKeys) + : new Dictionary>(); + + var results = new (PersistentEvent Session, HeartbeatResult? Heartbeat)[sessions.Count]; + for (int i = 0; i < sessions.Count; i++) { - bool close = await _cache.GetAsync($"{cacheKey}-close", false); - return new HeartbeatResult { ActivityUtc = cacheValue.Value, Close = close, CacheKey = cacheKey }; + if (resolved[i] is not { } r) + { + results[i] = (sessions[i], null); + continue; + } + + bool close = closeValues.TryGetValue($"{r.CacheKey}-close", out var closeVal) && closeVal.HasValue && closeVal.Value; + results[i] = (sessions[i], new HeartbeatResult { ActivityUtc = r.ActivityUtc, Close = close, CacheKey = r.CacheKey }); } - return null; + return results; } public TimeSpan DefaultInactivePeriod { get; set; } = TimeSpan.FromMinutes(5); diff --git a/src/Exceptionless.Core/Jobs/DailySummaryJob.cs b/src/Exceptionless.Core/Jobs/DailySummaryJob.cs index 3879e9b85b..8ccdd02eab 100644 --- a/src/Exceptionless.Core/Jobs/DailySummaryJob.cs +++ b/src/Exceptionless.Core/Jobs/DailySummaryJob.cs @@ -179,7 +179,16 @@ private async Task SendSummaryNotificationAsync(Project project, SummaryNo IReadOnlyCollection? newest = null; if (newTotal > 0) - newest = (await _stackRepository.FindAsync(q => q.AppFilter(sf).FilterExpression(filter).SortExpression("-first").DateRange(data.UtcStartTime, data.UtcEndTime, "first"), o => o.PageLimit(3))).Documents; + { + var stackResults = await _stackRepository.FindAsync( + q => q.AppFilter(sf) + .FilterExpression(filter) + .SortExpression("-first") + .DateRange(data.UtcStartTime, data.UtcEndTime, "first"), + o => o.PageLimit(3)); + + newest = stackResults.Documents; + } foreach (var user in users) { diff --git a/src/Exceptionless.Core/Jobs/EventPostsJob.cs b/src/Exceptionless.Core/Jobs/EventPostsJob.cs index 19027d53cb..605ee3ee6c 100644 --- a/src/Exceptionless.Core/Jobs/EventPostsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventPostsJob.cs @@ -5,7 +5,7 @@ using Exceptionless.Core.Plugins.EventParser; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Base; +using Foundatio.Repositories.Exceptions; using Exceptionless.Core.Services; using Exceptionless.Core.Validation; using FluentValidation; diff --git a/src/Exceptionless.Core/Jobs/EventUserDescriptionsJob.cs b/src/Exceptionless.Core/Jobs/EventUserDescriptionsJob.cs index c4c783dd7c..8b4514b6b3 100644 --- a/src/Exceptionless.Core/Jobs/EventUserDescriptionsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventUserDescriptionsJob.cs @@ -1,7 +1,7 @@ using Exceptionless.Core.Models.Data; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; -using Exceptionless.Core.Repositories.Base; +using Foundatio.Repositories.Exceptions; using Foundatio.Jobs; using Foundatio.Queues; using Foundatio.Repositories.Extensions; diff --git a/src/Exceptionless.Core/Jobs/WorkItemHandlers/FixStackStatsWorkItemHandler.cs b/src/Exceptionless.Core/Jobs/WorkItemHandlers/FixStackStatsWorkItemHandler.cs new file mode 100644 index 0000000000..a40ad6c746 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/WorkItemHandlers/FixStackStatsWorkItemHandler.cs @@ -0,0 +1,155 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.DateTimeExtensions; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs.WorkItemHandlers; + +public class FixStackStatsWorkItemHandler : WorkItemHandlerBase +{ + private readonly IStackRepository _stackRepository; + private readonly IEventRepository _eventRepository; + private readonly ILockProvider _lockProvider; + private readonly TimeProvider _timeProvider; + + public FixStackStatsWorkItemHandler(IStackRepository stackRepository, IEventRepository eventRepository, ILockProvider lockProvider, TimeProvider timeProvider, ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _stackRepository = stackRepository; + _eventRepository = eventRepository; + _lockProvider = lockProvider; + _timeProvider = timeProvider; + } + + public override Task GetWorkItemLockAsync(object workItem, CancellationToken cancellationToken = default) + { + return _lockProvider.AcquireAsync(nameof(FixStackStatsWorkItemHandler), TimeSpan.FromHours(1), cancellationToken); + } + + public override async Task HandleItemAsync(WorkItemContext context) + { + var wi = context.GetData(); + var utcEnd = wi.UtcEnd ?? _timeProvider.GetUtcNow().UtcDateTime; + + Log.LogInformation("Starting stack stats repair for {UtcStart:O} to {UtcEnd:O}. OrganizationId={Organization}", wi.UtcStart, utcEnd, wi.OrganizationId); + await context.ReportProgressAsync(0, $"Starting stack stats repair for window {wi.UtcStart:O} – {utcEnd:O}"); + + var organizationIds = await GetOrganizationIdsAsync(wi, utcEnd); + Log.LogInformation("Found {OrganizationCount} organizations to process", organizationIds.Count); + + int repaired = 0; + int skipped = 0; + + for (int index = 0; index < organizationIds.Count; index++) + { + if (context.CancellationToken.IsCancellationRequested) + break; + + var (organizationRepaired, organizationSkipped) = await ProcessOrganizationAsync(context, organizationIds[index], wi.UtcStart, utcEnd); + repaired += organizationRepaired; + skipped += organizationSkipped; + + int percentage = (int)Math.Min(99, (index + 1) * 100.0 / organizationIds.Count); + await context.ReportProgressAsync(percentage, $"Organization {index + 1}/{organizationIds.Count} ({percentage}%): repaired {repaired}, skipped {skipped}"); + } + + Log.LogInformation("Stack stats repair complete: Repaired={Repaired} Skipped={Skipped}", repaired, skipped); + await context.ReportProgressAsync(100, $"Done. Repaired {repaired} stacks, skipped={skipped}."); + } + + private async Task> GetOrganizationIdsAsync(FixStackStatsWorkItem wi, DateTime utcEnd) + { + if (wi.OrganizationId is not null) + return [wi.OrganizationId]; + + var countResult = await _eventRepository.CountAsync(q => q + .DateRange(wi.UtcStart, utcEnd, (PersistentEvent e) => e.Date) + .Index(wi.UtcStart, utcEnd) + .AggregationsExpression("terms:(organization_id~65536)")); + + return countResult.Aggregations.Terms("terms_organization_id")?.Buckets + .Select(b => b.Key) + .ToList() ?? []; + } + + private async Task<(int Repaired, int Skipped)> ProcessOrganizationAsync(WorkItemContext context, string organizationId, DateTime utcStart, DateTime utcEnd) + { + using var _ = Log.BeginScope(new ExceptionlessState().Organization(organizationId)); + await context.RenewLockAsync(); + + var countResult = await _eventRepository.CountAsync(q => q + .Organization(organizationId) + .DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date) + .Index(utcStart, utcEnd) + .AggregationsExpression("terms:(stack_id~65536 min:date max:date)")); + + var stackBuckets = countResult.Aggregations.Terms("terms_stack_id")?.Buckets ?? []; + if (stackBuckets.Count is 0) + return (0, 0); + + var statsByStackId = new Dictionary(stackBuckets.Count); + foreach (var bucket in stackBuckets) + { + var firstOccurrence = bucket.Aggregations.Min("min_date")?.Value; + var lastOccurrence = bucket.Aggregations.Max("max_date")?.Value; + if (firstOccurrence is null || lastOccurrence is null || bucket.Total is null) + continue; + + statsByStackId[bucket.Key] = new StackEventStats(firstOccurrence.Value, lastOccurrence.Value, bucket.Total.Value); + } + + int repaired = 0; + int skipped = 0; + + foreach (string[] batch in statsByStackId.Keys.Chunk(100)) + { + if (context.CancellationToken.IsCancellationRequested) + break; + + await context.RenewLockAsync(); + + var stacks = await _stackRepository.GetByIdsAsync(batch); + foreach (var stack in stacks) + { + if (!statsByStackId.TryGetValue(stack.Id, out var stats)) + { + skipped++; + continue; + } + + bool shouldUpdateFirst = stack.FirstOccurrence.IsAfter(stats.FirstOccurrence); + bool shouldUpdateLast = stack.LastOccurrence.IsBefore(stats.LastOccurrence); + bool shouldUpdateTotal = stats.TotalOccurrences > stack.TotalOccurrences; + if (!shouldUpdateFirst && !shouldUpdateLast && !shouldUpdateTotal) + { + skipped++; + continue; + } + + var newFirst = shouldUpdateFirst ? stats.FirstOccurrence : stack.FirstOccurrence; + var newLast = shouldUpdateLast ? stats.LastOccurrence : stack.LastOccurrence; + long newTotal = shouldUpdateTotal ? stats.TotalOccurrences : stack.TotalOccurrences; + + Log.LogInformation( + "Repairing stack {StackId}: first={OldFirst:O}->{NewFirst:O} last={OldLast:O}->{NewLast:O} total={OldTotal}->{NewTotal}", + stack.Id, + stack.FirstOccurrence, newFirst, + stack.LastOccurrence, newLast, + stack.TotalOccurrences, newTotal); + + await _stackRepository.SetEventCounterAsync(stack.Id, newFirst, newLast, newTotal, sendNotifications: false); + repaired++; + } + } + + Log.LogDebug("Processed organization: Repaired={Repaired} Skipped={Skipped}", repaired, skipped); + return (repaired, skipped); + } +} + +internal record StackEventStats(DateTime FirstOccurrence, DateTime LastOccurrence, long TotalOccurrences); diff --git a/src/Exceptionless.Core/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandler.cs b/src/Exceptionless.Core/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandler.cs new file mode 100644 index 0000000000..baf839c012 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandler.cs @@ -0,0 +1,101 @@ +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Repositories; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs.WorkItemHandlers; + +public class UpdateProjectNotificationSettingsWorkItemHandler : WorkItemHandlerBase +{ + private const int BATCH_SIZE = 50; + + private readonly IOrganizationRepository _organizationRepository; + private readonly OrganizationService _organizationService; + private readonly ILockProvider _lockProvider; + private readonly TimeProvider _timeProvider; + + public UpdateProjectNotificationSettingsWorkItemHandler( + IOrganizationRepository organizationRepository, + OrganizationService organizationService, + ILockProvider lockProvider, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) : base(loggerFactory) + { + _organizationRepository = organizationRepository; + _organizationService = organizationService; + _lockProvider = lockProvider; + _timeProvider = timeProvider; + } + + public override Task GetWorkItemLockAsync(object workItem, CancellationToken cancellationToken = new()) + { + return _lockProvider.AcquireAsync(nameof(UpdateProjectNotificationSettingsWorkItemHandler), TimeSpan.FromMinutes(15), cancellationToken); + } + + public override async Task HandleItemAsync(WorkItemContext context) + { + var workItem = context.GetData(); + Log.LogInformation("Received update project notification settings work item. Organization={Organization}", workItem.OrganizationId); + + long totalNotificationSettingsRemoved = 0; + long organizationsProcessed = 0; + + if (!String.IsNullOrEmpty(workItem.OrganizationId)) + { + await context.ReportProgressAsync(0, $"Starting project notification settings update for organization {workItem.OrganizationId}"); + + var organization = await _organizationRepository.GetByIdAsync(workItem.OrganizationId); + if (organization is null) + { + Log.LogWarning("Organization {Organization} not found", workItem.OrganizationId); + return; + } + + totalNotificationSettingsRemoved += await _organizationService.CleanupProjectNotificationSettingsAsync( + organization, + [], + context.CancellationToken, + context.RenewLockAsync); + organizationsProcessed++; + } + else + { + await context.ReportProgressAsync(0, "Starting project notification settings update for all organizations"); + + var results = await _organizationRepository.FindAsync( + q => q.Include(o => o.Id), + o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + + long totalOrganizations = results.Total; + + while (results.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) + { + foreach (var organization in results.Documents) + { + totalNotificationSettingsRemoved += await _organizationService.CleanupProjectNotificationSettingsAsync( + organization, + [], + context.CancellationToken, + context.RenewLockAsync); + organizationsProcessed++; + } + + int percentage = totalOrganizations > 0 + ? (int)Math.Min(99, organizationsProcessed * 100.0 / totalOrganizations) + : 99; + await context.ReportProgressAsync(percentage, $"Processed {organizationsProcessed}/{totalOrganizations} organizations, removed {totalNotificationSettingsRemoved} invalid notification settings"); + + if (context.CancellationToken.IsCancellationRequested || !await results.NextPageAsync()) + break; + + if (results.Documents.Count > 0) + await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider); + } + } + + Log.LogInformation("Project notification settings update complete. Organizations processed: {OrganizationsProcessed}, invalid notification settings removed: {RemovedNotificationSettings}", organizationsProcessed, totalNotificationSettingsRemoved); + } +} diff --git a/src/Exceptionless.Core/Models/ClientConfiguration.cs b/src/Exceptionless.Core/Models/ClientConfiguration.cs index ffcd0b34c9..44f1c12495 100644 --- a/src/Exceptionless.Core/Models/ClientConfiguration.cs +++ b/src/Exceptionless.Core/Models/ClientConfiguration.cs @@ -3,7 +3,7 @@ public class ClientConfiguration { public int Version { get; set; } - public SettingsDictionary Settings { get; private set; } = new(); + public SettingsDictionary Settings { get; init; } = new(); public void IncrementVersion() { diff --git a/src/Exceptionless.Core/Models/CoreMappings.cs b/src/Exceptionless.Core/Models/CoreMappings.cs deleted file mode 100644 index d450665d90..0000000000 --- a/src/Exceptionless.Core/Models/CoreMappings.cs +++ /dev/null @@ -1,5 +0,0 @@ -using AutoMapper; - -namespace Exceptionless.Core.Models; - -public class CoreMappings : Profile { } diff --git a/src/Exceptionless.Core/Models/Data/EnvironmentInfo.cs b/src/Exceptionless.Core/Models/Data/EnvironmentInfo.cs index c8e601fd28..3d6bed4b81 100644 --- a/src/Exceptionless.Core/Models/Data/EnvironmentInfo.cs +++ b/src/Exceptionless.Core/Models/Data/EnvironmentInfo.cs @@ -96,7 +96,7 @@ public class EnvironmentInfo : IData ///

/// Extended data entries for this machine environment. /// - public DataDictionary? Data { get; set; } = new(); + public DataDictionary? Data { get; set; } protected bool Equals(EnvironmentInfo other) { diff --git a/src/Exceptionless.Core/Models/Data/RequestInfo.cs b/src/Exceptionless.Core/Models/Data/RequestInfo.cs index 55fc03a27c..d28aa95e60 100644 --- a/src/Exceptionless.Core/Models/Data/RequestInfo.cs +++ b/src/Exceptionless.Core/Models/Data/RequestInfo.cs @@ -47,12 +47,12 @@ public class RequestInfo : IData /// /// The header values from the request. /// - public Dictionary? Headers { get; set; } = new(); + public Dictionary? Headers { get; set; } /// /// The request cookies. /// - public Dictionary? Cookies { get; set; } = new(); + public Dictionary? Cookies { get; set; } /// /// The data that was POSTed for the request. @@ -62,12 +62,12 @@ public class RequestInfo : IData /// /// The query string values from the request. /// - public Dictionary? QueryString { get; set; } = new(); + public Dictionary? QueryString { get; set; } /// /// Extended data entries for this request. /// - public DataDictionary? Data { get; set; } = new(); + public DataDictionary? Data { get; set; } protected bool Equals(RequestInfo other) { diff --git a/src/Exceptionless.Core/Models/Messaging/ExtendedEntityChanged.cs b/src/Exceptionless.Core/Models/Messaging/ExtendedEntityChanged.cs index b0462a7885..0e8dd4a67d 100644 --- a/src/Exceptionless.Core/Models/Messaging/ExtendedEntityChanged.cs +++ b/src/Exceptionless.Core/Models/Messaging/ExtendedEntityChanged.cs @@ -6,11 +6,9 @@ namespace Exceptionless.Core.Messaging.Models; [DebuggerDisplay("{Type} {ChangeType}: Id={Id}, OrganizationId={OrganizationId}, ProjectId={ProjectId}, StackId={StackId}")] public class ExtendedEntityChanged : EntityChanged { - private ExtendedEntityChanged() { } // Ensure create is used. - - public string? OrganizationId { get; private set; } - public string? ProjectId { get; private set; } - public string? StackId { get; private set; } + public string? OrganizationId { get; set; } + public string? ProjectId { get; set; } + public string? StackId { get; set; } public static ExtendedEntityChanged Create(EntityChanged entityChanged, bool removeWhenSettingProperties = true) { diff --git a/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs b/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs index bd506cab2d..b819fcd7d1 100644 --- a/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs +++ b/src/Exceptionless.Core/Models/Messaging/ReleaseNotification.cs @@ -4,5 +4,5 @@ public record ReleaseNotification { public required bool Critical { get; set; } public required DateTime Date { get; set; } - public required string? Message { get; set; } + public string? Message { get; set; } } diff --git a/src/Exceptionless.Core/Models/PersistentEvent.cs b/src/Exceptionless.Core/Models/PersistentEvent.cs index 8fdf944555..70c3dd9c17 100644 --- a/src/Exceptionless.Core/Models/PersistentEvent.cs +++ b/src/Exceptionless.Core/Models/PersistentEvent.cs @@ -44,5 +44,5 @@ public class PersistentEvent : Event, IOwnedByOrganizationAndProjectAndStackWith /// /// Used to store primitive data type custom data values for searching the event. /// - public DataDictionary Idx { get; set; } = new(); + public DataDictionary? Idx { get; set; } } diff --git a/src/Exceptionless.Core/Models/WorkItems/FixStackStatsWorkItem.cs b/src/Exceptionless.Core/Models/WorkItems/FixStackStatsWorkItem.cs new file mode 100644 index 0000000000..0585d33536 --- /dev/null +++ b/src/Exceptionless.Core/Models/WorkItems/FixStackStatsWorkItem.cs @@ -0,0 +1,14 @@ +namespace Exceptionless.Core.Models.WorkItems; + +public record FixStackStatsWorkItem +{ + public DateTime UtcStart { get; init; } + + public DateTime? UtcEnd { get; init; } + + /// + /// When set, only stacks belonging to this organization are repaired. + /// When null, all organizations with events in the time window are processed. + /// + public string? OrganizationId { get; init; } +} diff --git a/src/Exceptionless.Core/Models/WorkItems/UpdateProjectNotificationSettingsWorkItem.cs b/src/Exceptionless.Core/Models/WorkItems/UpdateProjectNotificationSettingsWorkItem.cs new file mode 100644 index 0000000000..b58b117694 --- /dev/null +++ b/src/Exceptionless.Core/Models/WorkItems/UpdateProjectNotificationSettingsWorkItem.cs @@ -0,0 +1,6 @@ +namespace Exceptionless.Core.Models.WorkItems; + +public record UpdateProjectNotificationSettingsWorkItem +{ + public string? OrganizationId { get; init; } +} diff --git a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs index ab4deab79d..b85723efa2 100644 --- a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs +++ b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs @@ -15,7 +15,7 @@ public override Task ProcessAsync(EventContext ctx) // TODO: Do we need a pipeline action to trim keys and remove null values that may be sent by other native clients. ctx.Event.CopyDataToIndex([]); - int fieldCount = ctx.Event.Idx.Count; + int fieldCount = ctx.Event.Idx?.Count ?? 0; AppDiagnostics.EventsFieldCount.Record(fieldCount); if (fieldCount > 20 && _logger.IsEnabled(LogLevel.Warning)) { diff --git a/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganization.cs b/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganization.cs index 4cbb138b8a..0ed5a850de 100644 --- a/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganization.cs +++ b/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganization.cs @@ -11,7 +11,7 @@ namespace Exceptionless.Core.Repositories; { public RepositoryOwnedByOrganization(IIndex index, IValidator validator, AppOptions options) : base(index, validator, options) { - AddPropertyRequiredForRemove(o => o.OrganizationId); + AddRequiredField(o => o.OrganizationId); } public virtual Task> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor? options = null) diff --git a/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganizationAndProject.cs b/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganizationAndProject.cs index 01f67569fd..2df7781c74 100644 --- a/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganizationAndProject.cs +++ b/src/Exceptionless.Core/Repositories/Base/RepositoryOwnedByOrganizationAndProject.cs @@ -10,7 +10,7 @@ namespace Exceptionless.Core.Repositories; { public RepositoryOwnedByOrganizationAndProject(IIndex index, IValidator validator, AppOptions options) : base(index, validator, options) { - AddPropertyRequiredForRemove(o => o.ProjectId); + AddRequiredField(o => o.ProjectId); } public virtual Task> GetByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null) diff --git a/src/Exceptionless.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index 65e1d7ad43..c28518a735 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -28,12 +28,12 @@ public EventRepository(ExceptionlessElasticConfiguration configuration, AppOptio AddDefaultExclude(EventIndex.Alias.OperatingSystem); AddDefaultExclude(EventIndex.Alias.Error); - AddPropertyRequiredForRemove(e => e.Date); + AddRequiredField(e => e.Date); } public Task> GetOpenSessionsAsync(DateTime createdBeforeUtc, CommandOptionsDescriptor? options = null) { - var filter = Query.Term(e => e.Type, Event.KnownTypes.Session) && !Query.Exists(f => f.Field(e => e.Idx[Event.KnownDataKeys.SessionEnd + "-d"])); + var filter = Query.Term(e => e.Type, Event.KnownTypes.Session) && !Query.Exists(f => f.Field(e => e.Idx![Event.KnownDataKeys.SessionEnd + "-d"])); if (createdBeforeUtc.Ticks > 0) filter &= Query.DateRange(r => r.Field(e => e.Date).LessThanOrEquals(createdBeforeUtc)); diff --git a/src/Exceptionless.Core/Repositories/Exceptions/DocumentNotFoundException.cs b/src/Exceptionless.Core/Repositories/Exceptions/DocumentNotFoundException.cs deleted file mode 100644 index 3ff2361138..0000000000 --- a/src/Exceptionless.Core/Repositories/Exceptions/DocumentNotFoundException.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Exceptionless.Core.Repositories.Base; - -public class DocumentNotFoundException : ApplicationException -{ - public DocumentNotFoundException(string id, string? message = null) : base(message) - { - Id = id; - } - - public string Id { get; private set; } - - public override string ToString() - { - if (!String.IsNullOrEmpty(Message)) - return Message; - - if (!String.IsNullOrEmpty(Id)) - return $"Document \"{Id}\" could not be found"; - - return base.ToString(); - } -} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs index 9ec7693745..13199d28c6 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -11,6 +11,7 @@ public interface IStackRepository : IRepositoryOwnedByOrganizationAndProject> GetExpiredSnoozedStatuses(DateTime utcNow, CommandOptionsDescriptor? options = null); Task MarkAsRegressedAsync(string stackId); Task IncrementEventCounterAsync(string organizationId, string projectId, string stackId, DateTime minOccurrenceDateUtc, DateTime maxOccurrenceDateUtc, int count, bool sendNotifications = true); + Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true); Task> GetStacksForCleanupAsync(string organizationId, DateTime cutoff); Task> GetSoftDeleted(); Task SoftDeleteByProjectIdAsync(string organizationId, string projectId); diff --git a/src/Exceptionless.Core/Repositories/ProjectRepository.cs b/src/Exceptionless.Core/Repositories/ProjectRepository.cs index e4279554c7..5d7ca4ea5e 100644 --- a/src/Exceptionless.Core/Repositories/ProjectRepository.cs +++ b/src/Exceptionless.Core/Repositories/ProjectRepository.cs @@ -85,8 +85,9 @@ public async Task IncrementNextSummaryEndOfDayTicksAsync(IReadOnlyCollection p.Id).ToArray(), new ScriptPatch(script), o => o.Notifications(false)); - await InvalidateCacheAsync(projects); + long modifiedCount = await PatchAsync(projects.Select(p => p.Id).ToArray(), new ScriptPatch(script), o => o.Notifications(false)); + if (modifiedCount > 0) + await InvalidateCacheAsync(projects); } protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) diff --git a/src/Exceptionless.Core/Repositories/Queries/EventStackFilterQuery.cs b/src/Exceptionless.Core/Repositories/Queries/EventStackFilterQuery.cs index fc0cd34910..72637e5370 100644 --- a/src/Exceptionless.Core/Repositories/Queries/EventStackFilterQuery.cs +++ b/src/Exceptionless.Core/Repositories/Queries/EventStackFilterQuery.cs @@ -195,6 +195,7 @@ private IRepositoryQuery GetSystemFilterQuery(IQueryVisitorContext context, bool foreach (var range in builderContext?.Source.GetDateRanges() ?? Enumerable.Empty()) { systemFilterQuery.DateRange(range.StartDate, range.EndDate, range.Field, range.TimeZone); + // NOTE: We do not currently specify date range indexes here.. } } diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index 3b79e84296..f85dc8c9ba 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -3,9 +3,9 @@ using Exceptionless.Core.Repositories.Configuration; using FluentValidation; using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; using Foundatio.Repositories.Options; -using Microsoft.Extensions.Logging; using Nest; namespace Exceptionless.Core.Repositories; @@ -19,7 +19,7 @@ public StackRepository(ExceptionlessElasticConfiguration configuration, IValidat : base(configuration.Stacks, validator, options) { _timeProvider = configuration.TimeProvider; - AddPropertyRequiredForRemove(s => s.SignatureHash); + AddRequiredField(s => s.SignatureHash); } public Task> GetExpiredSnoozedStatuses(DateTime utcNow, CommandOptionsDescriptor? options = null) @@ -63,41 +63,94 @@ Instant parseDate(def dt) { if (ctx._source.total_occurrences == 0 || parseDate(ctx._source.first_occurrence).isAfter(parseDate(params.minOccurrenceDateUtc))) { ctx._source.first_occurrence = params.minOccurrenceDateUtc; } + if (parseDate(ctx._source.last_occurrence).isBefore(parseDate(params.maxOccurrenceDateUtc))) { ctx._source.last_occurrence = params.maxOccurrenceDateUtc; } + if (parseDate(ctx._source.updated_utc).isBefore(parseDate(params.updatedUtc))) { ctx._source.updated_utc = params.updatedUtc; } + ctx._source.total_occurrences += params.count;"; - var request = new UpdateRequest(ElasticIndex.GetIndex(stackId), stackId) + var operation = new ScriptPatch(script.TrimScript()) { - Script = new InlineScript(script.TrimScript()) + Params = new Dictionary(4) { - Params = new Dictionary(3) { - { "minOccurrenceDateUtc", minOccurrenceDateUtc }, - { "maxOccurrenceDateUtc", maxOccurrenceDateUtc }, - { "count", count }, - { "updatedUtc", _timeProvider.GetUtcNow().UtcDateTime } - } + { "minOccurrenceDateUtc", minOccurrenceDateUtc }, + { "maxOccurrenceDateUtc", maxOccurrenceDateUtc }, + { "count", count }, + { "updatedUtc", _timeProvider.GetUtcNow().UtcDateTime } } }; - var result = await _client.UpdateAsync(request); - if (!result.IsValid) + try + { + bool modified = await PatchAsync(stackId, operation, o => o.Notifications(false)); + if (!modified) + return false; + } + catch (DocumentNotFoundException) { - _logger.LogError(result.OriginalException, "Error occurred incrementing total event occurrences on stack {Stack}. Error: {Message}", stackId, result.ServerError?.Error); - return result.ServerError?.Status == 404; + return true; } - await Cache.RemoveAsync(stackId); if (sendNotifications) - await PublishMessageAsync(CreateEntityChanged(ChangeType.Saved, organizationId, projectId, null, stackId), TimeSpan.FromSeconds(1.5)); + await PublishMessageAsync(CreateEntityChanged(ChangeType.Saved, organizationId, projectId, null, stackId)); return true; } + public async Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true) + { + const string script = @" +Instant parseDate(def dt) { + if (dt != null) { + try { + return Instant.parse(dt); + } catch(DateTimeParseException e) {} + } + return Instant.MIN; +} + +if (ctx._source.total_occurrences == null || ctx._source.total_occurrences < params.totalOccurrences) { + ctx._source.total_occurrences = params.totalOccurrences; +} + +if (parseDate(ctx._source.first_occurrence).isAfter(parseDate(params.firstOccurrenceUtc))) { + ctx._source.first_occurrence = params.firstOccurrenceUtc; +} + +if (parseDate(ctx._source.last_occurrence).isBefore(parseDate(params.lastOccurrenceUtc))) { + ctx._source.last_occurrence = params.lastOccurrenceUtc; +} + +if (parseDate(ctx._source.updated_utc).isBefore(parseDate(params.updatedUtc))) { + ctx._source.updated_utc = params.updatedUtc; +}"; + + var operation = new ScriptPatch(script.TrimScript()) + { + Params = new Dictionary(4) + { + { "firstOccurrenceUtc", firstOccurrenceUtc }, + { "lastOccurrenceUtc", lastOccurrenceUtc }, + { "totalOccurrences", totalOccurrences }, + { "updatedUtc", _timeProvider.GetUtcNow().UtcDateTime } + } + }; + + try + { + return await PatchAsync(stackId, operation, o => o.Notifications(sendNotifications)); + } + catch (DocumentNotFoundException) + { + return true; + } + } + public async Task GetStackBySignatureHashAsync(string projectId, string signatureHash) { string key = GetStackSignatureCacheKey(projectId, signatureHash); diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 1b2fc17ca1..7c9fbd122e 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -18,7 +18,7 @@ public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValid { _miniValidationValidator = validator; DefaultConsistency = Consistency.Immediate; - AddPropertyRequiredForRemove(u => u.EmailAddress, u => u.OrganizationIds); + AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds); } protected override Task ValidateAndThrowAsync(User document) diff --git a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs index ffc76ac9d2..7a05de2ae7 100644 --- a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs +++ b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs @@ -20,6 +20,9 @@ public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSeri options.PropertyNamingPolicy = LowerCaseUnderscoreNamingPolicy.Instance; options.Converters.Add(new ObjectToInferredTypesConverter()); + // Ensures tuples and records are serialized with their field names instead of "Item1", "Item2", etc. + options.IncludeFields = true; + // Enforces C# nullable annotations (string vs string?) during serialization/deserialization. // If you see "cannot be null" errors, fix the model's nullability annotation or the data. options.RespectNullableAnnotations = true; diff --git a/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs index f450a87777..8d5dd5841f 100644 --- a/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs +++ b/src/Exceptionless.Core/Serialization/ObjectToInferredTypesConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Exceptionless.Core.Models; +using Newtonsoft.Json.Linq; namespace Exceptionless.Core.Serialization; @@ -74,6 +75,16 @@ public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerO return; } + // Handle Newtonsoft JToken types (stored in DataDictionary by DataObjectConverter + // when reading from Elasticsearch via NEST). Without this, STJ enumerates JToken's + // IEnumerable interface, producing nested empty arrays instead of proper JSON. + if (value is JToken jToken) + { + using var doc = JsonDocument.Parse(jToken.ToString(Newtonsoft.Json.Formatting.None)); + doc.RootElement.WriteTo(writer); + return; + } + // Serialize using the runtime type to get proper converter handling JsonSerializer.Serialize(writer, value, value.GetType(), options); } diff --git a/src/Exceptionless.Core/Services/MessageService.cs b/src/Exceptionless.Core/Services/MessageService.cs index 2592bee42b..0fe3e988a9 100644 --- a/src/Exceptionless.Core/Services/MessageService.cs +++ b/src/Exceptionless.Core/Services/MessageService.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Foundatio.Extensions.Hosting.Startup; +using Foundatio.Repositories.Elasticsearch; using Foundatio.Repositories.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -11,16 +12,37 @@ namespace Exceptionless.Core.Services; public class MessageService : IDisposable, IStartupAction { + private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; private readonly IStackRepository _stackRepository; private readonly IEventRepository _eventRepository; + private readonly ITokenRepository _tokenRepository; + private readonly IWebHookRepository _webHookRepository; private readonly IConnectionMapping _connectionMapping; private readonly AppOptions _options; private readonly ILogger _logger; + private readonly List _disposeActions = []; - public MessageService(IStackRepository stackRepository, IEventRepository eventRepository, IConnectionMapping connectionMapping, AppOptions options, ILoggerFactory loggerFactory) + public MessageService( + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + IEventRepository eventRepository, + ITokenRepository tokenRepository, + IWebHookRepository webHookRepository, + IConnectionMapping connectionMapping, + AppOptions options, + ILoggerFactory loggerFactory) { + _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + _userRepository = userRepository; _stackRepository = stackRepository; _eventRepository = eventRepository; + _tokenRepository = tokenRepository; + _webHookRepository = webHookRepository; _connectionMapping = connectionMapping; _options = options; _logger = loggerFactory.CreateLogger() ?? NullLogger.Instance; @@ -31,26 +53,34 @@ public Task RunAsync(CancellationToken shutdownToken = default) if (!_options.EnableRepositoryNotifications) return Task.CompletedTask; - if (_stackRepository is StackRepository sr) - sr.BeforePublishEntityChanged.AddHandler(BeforePublishStackEntityChanged); - if (_eventRepository is EventRepository er) - er.BeforePublishEntityChanged.AddHandler(BeforePublishEventEntityChanged); + RegisterHandler(_organizationRepository); + RegisterHandler(_userRepository); + RegisterHandler(_projectRepository); + RegisterHandler(_stackRepository); + RegisterHandler(_eventRepository); + RegisterHandler(_tokenRepository); + RegisterHandler(_webHookRepository); return Task.CompletedTask; } - private async Task BeforePublishStackEntityChanged(object sender, BeforePublishEntityChangedEventArgs args) + private void RegisterHandler(object repository) where T : class, IIdentity, new() { - args.Cancel = await GetNumberOfListeners(args.Message) == 0; - if (args.Cancel) - _logger.LogTrace("Cancelled Stack Entity Changed Message: {@Message}", args.Message); + if (repository is not ElasticRepositoryBase repo) + return; + + Func, Task> handler = OnBeforePublishEntityChangedAsync; + repo.BeforePublishEntityChanged.AddHandler(handler); + _disposeActions.Add(() => repo.BeforePublishEntityChanged.RemoveHandler(handler)); } - private async Task BeforePublishEventEntityChanged(object sender, BeforePublishEntityChangedEventArgs args) + private async Task OnBeforePublishEntityChangedAsync(object sender, BeforePublishEntityChangedEventArgs args) + where T : class, IIdentity, new() { - args.Cancel = await GetNumberOfListeners(args.Message) == 0; + int listenerCount = await GetNumberOfListeners(args.Message); + args.Cancel = listenerCount == 0; if (args.Cancel) - _logger.LogTrace("Cancelled Persistent Event Entity Changed Message: {@Message}", args.Message); + _logger.LogTrace("Cancelled {EntityType} Entity Changed Message: {@Message}", typeof(T).Name, args.Message); } private Task GetNumberOfListeners(EntityChanged message) @@ -64,9 +94,9 @@ private Task GetNumberOfListeners(EntityChanged message) public void Dispose() { - if (_stackRepository is StackRepository sr) - sr.BeforePublishEntityChanged.RemoveHandler(BeforePublishStackEntityChanged); - if (_eventRepository is EventRepository er) - er.BeforePublishEntityChanged.RemoveHandler(BeforePublishEventEntityChanged); + foreach (var disposeAction in _disposeActions) + disposeAction(); + + _disposeActions.Clear(); } } diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index 206f5cccf2..c5acf91701 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -1,6 +1,5 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; -using Foundatio.Caching; using Foundatio.Extensions.Hosting.Startup; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -11,22 +10,23 @@ namespace Exceptionless.Core.Services; public class OrganizationService : IStartupAction { + private const int BATCH_SIZE = 50; private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; private readonly ITokenRepository _tokenRepository; private readonly IUserRepository _userRepository; private readonly IWebHookRepository _webHookRepository; - private readonly ICacheClient _cache; private readonly AppOptions _appOptions; private readonly UsageService _usageService; private readonly ILogger _logger; - public OrganizationService(IOrganizationRepository organizationRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, ICacheClient cache, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory) + public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; + _projectRepository = projectRepository; _tokenRepository = tokenRepository; _userRepository = userRepository; _webHookRepository = webHookRepository; - _cache = cache; _appOptions = appOptions; _usageService = usageService; _logger = loggerFactory.CreateLogger(); @@ -72,36 +72,111 @@ public async Task CancelSubscriptionsAsync(Organization organization) public async Task RemoveUsersAsync(Organization organization, string? currentUserId) { - var users = await _userRepository.GetByOrganizationIdAsync(organization.Id, o => o.PageLimit(1000)); - foreach (var user in users.Documents) + long totalUsersAffected = 0; + var userResults = await _userRepository.GetByOrganizationIdAsync(organization.Id, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + + while (userResults.Documents.Count > 0) { - // delete the user if they are not associated to any other organizations and they are not the current user - if (user.OrganizationIds.All(oid => String.Equals(oid, organization.Id)) && !String.Equals(user.Id, currentUserId)) + var usersToDelete = new List(userResults.Documents.Count); + var usersToUpdate = new List(userResults.Documents.Count); + + foreach (var user in userResults.Documents) { - _logger.LogInformation("Removing user {User} as they do not belong to any other organizations", user.Id); - await _userRepository.RemoveAsync(user.Id); + // delete the user if they are not associated to any other organizations and they are not the current user + if (user.OrganizationIds.All(oid => String.Equals(oid, organization.Id)) && !String.Equals(user.Id, currentUserId)) + { + _logger.LogInformation("Removing user {User} as they do not belong to any other organizations", user.Id); + usersToDelete.Add(user); + } + else + { + _logger.LogInformation("Removing user {User} from organization: {OrganizationName} ({Organization})", user.Id, organization.Name, organization.Id); + user.OrganizationIds.Remove(organization.Id); + usersToUpdate.Add(user); + } } - else + + if (usersToDelete.Count > 0) + await _userRepository.RemoveAsync(usersToDelete); + + if (usersToUpdate.Count > 0) + await _userRepository.SaveAsync(usersToUpdate, o => o.Cache()); + + totalUsersAffected += usersToDelete.Count + usersToUpdate.Count; + + if (!await userResults.NextPageAsync()) + break; + } + + return totalUsersAffected; + } + + public Task CleanupProjectNotificationSettingsAsync(Organization organization, IReadOnlyCollection userIdsToRemove, CancellationToken cancellationToken = default, Func? renewWorkItemLockAsync = null) + { + ArgumentNullException.ThrowIfNull(organization); + ArgumentNullException.ThrowIfNull(userIdsToRemove); + + return CleanupProjectNotificationSettingsAsync(organization.Id, userIdsToRemove, cancellationToken, renewWorkItemLockAsync); + } + + private async Task CleanupProjectNotificationSettingsAsync(string organizationId, IReadOnlyCollection userIdsToRemove, CancellationToken cancellationToken, Func? renewWorkItemLockAsync) + { + ArgumentException.ThrowIfNullOrEmpty(organizationId); + ArgumentNullException.ThrowIfNull(userIdsToRemove); + + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(organizationId)); + + var userIdsToRemoveSet = userIdsToRemove.Count is 0 + ? new HashSet(StringComparer.Ordinal) + : new HashSet(userIdsToRemove, StringComparer.Ordinal); + + long removed = 0; + var projectResults = await _projectRepository.GetByOrganizationIdAsync(organizationId, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + while (projectResults.Documents.Count > 0 && !cancellationToken.IsCancellationRequested) + { + var candidateUserIds = projectResults.Documents + .SelectMany(project => project.NotificationSettings.Keys) + .Where(key => !IsNotificationIntegrationKey(key)) + .ToHashSet(StringComparer.Ordinal); + + var validUserIds = await GetValidNotificationUserIdsAsync(organizationId, candidateUserIds, cancellationToken); + var projectsToSave = new List(projectResults.Documents.Count); + + foreach (var project in projectResults.Documents) { - _logger.LogInformation("Removing user {User} from organization: {OrganizationName} ({OrganizationId})", user.Id, organization.Name, organization.Id); - user.OrganizationIds.Remove(organization.Id); + int removedFromProject = RemoveInvalidNotificationSettings(project, validUserIds, userIdsToRemoveSet); + if (removedFromProject <= 0) + continue; - await _userRepository.SaveAsync(user, o => o.Cache()); + removed += removedFromProject; + projectsToSave.Add(project); } + + if (projectsToSave.Count > 0) + await _projectRepository.SaveAsync(projectsToSave); + + if (renewWorkItemLockAsync is not null) + await renewWorkItemLockAsync(); + + if (!await projectResults.NextPageAsync()) + break; } - return users.Documents.Count; + if (removed > 0) + _logger.LogInformation("Removed {Count} invalid notification settings", removed); + + return removed; } public Task RemoveTokensAsync(Organization organization) { - _logger.LogInformation("Removing tokens for {OrganizationName} ({OrganizationId})", organization.Name, organization.Id); + _logger.LogInformation("Removing tokens for {OrganizationName} ({Organization})", organization.Name, organization.Id); return _tokenRepository.RemoveAllByOrganizationIdAsync(organization.Id); } public Task RemoveWebHooksAsync(Organization organization) { - _logger.LogInformation("Removing web hooks for {OrganizationName} ({OrganizationId})", organization.Name, organization.Id); + _logger.LogInformation("Removing web hooks for {OrganizationName} ({Organization})", organization.Name, organization.Id); return _webHookRepository.RemoveAllByOrganizationIdAsync(organization.Id); } @@ -114,8 +189,41 @@ public async Task SoftDeleteOrganizationAsync(Organization organization, string await RemoveWebHooksAsync(organization); await CancelSubscriptionsAsync(organization); await RemoveUsersAsync(organization, currentUserId); + await CleanupProjectNotificationSettingsAsync(organization, []); organization.IsDeleted = true; await _organizationRepository.SaveAsync(organization); } + + private async Task> GetValidNotificationUserIdsAsync(string organizationId, IReadOnlyCollection userIds, CancellationToken cancellationToken) + { + var validUserIds = new HashSet(StringComparer.Ordinal); + if (userIds.Count == 0) + return validUserIds; + + foreach (string[] batch in userIds.Chunk(BATCH_SIZE)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var users = await _userRepository.GetByIdsAsync(batch); + validUserIds.UnionWith(users.Where(user => user.OrganizationIds.Contains(organizationId)).Select(user => user.Id)); + } + + return validUserIds; + } + + private static int RemoveInvalidNotificationSettings(Project project, IReadOnlySet validUserIds, IReadOnlySet userIdsToRemove) + { + var keysToRemove = project.NotificationSettings.Keys + .Where(key => !IsNotificationIntegrationKey(key) && (userIdsToRemove.Contains(key) || !validUserIds.Contains(key))) + .ToList(); + + foreach (var key in keysToRemove) + project.NotificationSettings.Remove(key); + + return keysToRemove.Count; + } + + private static bool IsNotificationIntegrationKey(string key) => + String.Equals(key, Project.NotificationIntegrations.Slack, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Exceptionless.Core/Services/StackService.cs b/src/Exceptionless.Core/Services/StackService.cs index 1b88ca8936..84671040ea 100644 --- a/src/Exceptionless.Core/Services/StackService.cs +++ b/src/Exceptionless.Core/Services/StackService.cs @@ -5,6 +5,11 @@ namespace Exceptionless.Core.Services; +/// +/// Identifies a stack for deferred usage counter updates. +/// +public record StackUsageKey(string OrganizationId, string ProjectId, string StackId); + public class StackService { private readonly ILogger _logger; @@ -30,7 +35,7 @@ public async Task IncrementStackUsageAsync(string organizationId, string project return; await Task.WhenAll( - _cache.ListAddAsync(GetStackOccurrenceSetCacheKey(), (organizationId, projectId, stackId)), + _cache.ListAddAsync(GetStackOccurrenceSetCacheKey(), new StackUsageKey(organizationId, projectId, stackId)), _cache.IncrementAsync(GetStackOccurrenceCountCacheKey(stackId), count, _expireTimeout), _cache.SetIfLowerAsync(GetStackOccurrenceMinDateCacheKey(stackId), minOccurrenceDateUtc, _expireTimeout), _cache.SetIfHigherAsync(GetStackOccurrenceMaxDateCacheKey(stackId), maxOccurrenceDateUtc, _expireTimeout) @@ -40,16 +45,20 @@ await Task.WhenAll( public async Task SaveStackUsagesAsync(bool sendNotifications = true, CancellationToken cancellationToken = default) { string occurrenceSetCacheKey = GetStackOccurrenceSetCacheKey(); - var stackUsageSet = await _cache.GetListAsync<(string OrganizationId, string ProjectId, string StackId)>(occurrenceSetCacheKey); + var stackUsageSet = await _cache.GetListAsync(occurrenceSetCacheKey); if (!stackUsageSet.HasValue) return; - foreach ((string? organizationId, string? projectId, string? stackId) in stackUsageSet.Value) + foreach (var usage in stackUsageSet.Value) { if (cancellationToken.IsCancellationRequested) break; - var removeFromSetTask = _cache.ListRemoveAsync(occurrenceSetCacheKey, (organizationId, projectId, stackId)); + string organizationId = usage.OrganizationId; + string projectId = usage.ProjectId; + string stackId = usage.StackId; + + var removeFromSetTask = _cache.ListRemoveAsync(occurrenceSetCacheKey, new StackUsageKey(organizationId, projectId, stackId)); string countCacheKey = GetStackOccurrenceCountCacheKey(stackId); var countTask = _cache.GetAsync(countCacheKey, 0); string minDateCacheKey = GetStackOccurrenceMinDateCacheKey(stackId); diff --git a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj index 5c339fc539..007908a52e 100644 --- a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj +++ b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj @@ -2,18 +2,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/Exceptionless.Insulation/Mail/MailKitMailSender.cs b/src/Exceptionless.Insulation/Mail/MailKitMailSender.cs index a1851f7a93..4aaff40812 100644 --- a/src/Exceptionless.Insulation/Mail/MailKitMailSender.cs +++ b/src/Exceptionless.Insulation/Mail/MailKitMailSender.cs @@ -139,7 +139,7 @@ public async Task CheckHealthAsync(HealthCheckContext context await client.DisconnectAsync(true, cancellationToken); _lastSuccessfulConnection = _timeProvider.GetUtcNow().UtcDateTime; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return HealthCheckResult.Unhealthy("Email Not Working.", ex); } diff --git a/src/Exceptionless.Job/Exceptionless.Job.csproj b/src/Exceptionless.Job/Exceptionless.Job.csproj index 22d5280f34..457a18cf47 100644 --- a/src/Exceptionless.Job/Exceptionless.Job.csproj +++ b/src/Exceptionless.Job/Exceptionless.Job.csproj @@ -14,7 +14,7 @@ - + @@ -23,8 +23,8 @@ - - + + diff --git a/src/Exceptionless.Web/Bootstrapper.cs b/src/Exceptionless.Web/Bootstrapper.cs index 9983e3b117..f83edd3615 100644 --- a/src/Exceptionless.Web/Bootstrapper.cs +++ b/src/Exceptionless.Web/Bootstrapper.cs @@ -1,16 +1,12 @@ -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs.WorkItemHandlers; -using Exceptionless.Core.Models; -using Exceptionless.Core.Models.Data; using Exceptionless.Core.Queues.Models; using Exceptionless.Web.Hubs; -using Exceptionless.Web.Models; +using Exceptionless.Web.Mapping; using Foundatio.Extensions.Hosting.Startup; using Foundatio.Jobs; using Foundatio.Messaging; -using Token = Exceptionless.Core.Models.Token; namespace Exceptionless.Web; @@ -21,7 +17,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); Core.Bootstrapper.RegisterServices(services, appOptions); Insulation.Bootstrapper.RegisterServices(services, appOptions, appOptions.RunJobsInProcess); @@ -46,34 +42,4 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddStartupAction(); } - - public class ApiMappings : Profile - { - public ApiMappings(TimeProvider timeProvider) - { - CreateMap(); - - CreateMap(); - CreateMap().AfterMap((o, vo) => - { - vo.IsOverMonthlyLimit = o.IsOverMonthlyLimit(timeProvider); - }); - - CreateMap().AfterMap((si, igm) => - { - igm.Id = igm.Id.Substring(3); - igm.Date = si.Created; - }); - - CreateMap(); - CreateMap().AfterMap((p, vp) => vp.HasSlackIntegration = p.Data is not null && p.Data.ContainsKey(Project.KnownDataKeys.SlackToken)); - - CreateMap().ForMember(m => m.Type, m => m.Ignore()); - CreateMap(); - - CreateMap(); - - CreateMap(); - } - } } diff --git a/src/Exceptionless.Web/ClientApp/.npmrc b/src/Exceptionless.Web/ClientApp/.npmrc index b6f27f1359..d5831dd518 100644 --- a/src/Exceptionless.Web/ClientApp/.npmrc +++ b/src/Exceptionless.Web/ClientApp/.npmrc @@ -1 +1,2 @@ engine-strict=true +legacy-peer-deps=true diff --git a/src/Exceptionless.Web/ClientApp/.oxfmtrc.json b/src/Exceptionless.Web/ClientApp/.oxfmtrc.json new file mode 100644 index 0000000000..871052ca7c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/.oxfmtrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 160, + "singleQuote": true, + "trailingComma": "none", + "endOfLine": "lf", + "ignorePatterns": [] +} diff --git a/src/Exceptionless.Web/ClientApp/.oxlintrc.json b/src/Exceptionless.Web/ClientApp/.oxlintrc.json new file mode 100644 index 0000000000..6f6d341feb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import"], + "categories": { + "correctness": "warn" + }, + "rules": { + "no-unused-vars": "off" + }, + "ignorePatterns": [".agents/", "build/", ".svelte-kit/", "dist/", "src/lib/generated/", "src/lib/features/shared/components/ui/"] +} diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 9bfd564cf6..4bb41e0e5c 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -2,6 +2,7 @@ import { includeIgnoreFile } from '@eslint/compat'; import js from '@eslint/js'; import pluginQuery from '@tanstack/eslint-plugin-query'; import prettier from 'eslint-config-prettier'; +import oxlint from 'eslint-plugin-oxlint'; import perfectionist from 'eslint-plugin-perfectionist'; // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format import storybook from 'eslint-plugin-storybook'; @@ -34,6 +35,9 @@ export default ts.config( parserOptions: { parser: ts.parser } + }, + rules: { + 'no-useless-assignment': 'off' } }, { @@ -54,5 +58,7 @@ export default ts.config( 'perfectionist/sort-svelte-attributes': 'off' } }, - storybook.configs['flat/recommended'] + storybook.configs['flat/recommended'], + // Must be last — disables ESLint rules already covered by oxlint + oxlint.buildFromOxlintConfigFile('./.oxlintrc.json') ); diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index d12fee10be..5dfa6c6a3e 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -10,83 +10,81 @@ "dependencies": { "@exceptionless/browser": "^3.1.0", "@exceptionless/fetchclient": "^0.44.0", - "@internationalized/date": "^3.11.0", - "@lucide/svelte": "^0.564.0", - "@tanstack/svelte-form": "^1.28.3", - "@tanstack/svelte-query": "^6.0.18", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^0.577.0", + "@tanstack/svelte-form": "^1.28.5", + "@tanstack/svelte-query": "^6.1.3", "@tanstack/svelte-query-devtools": "^6.0.4", "@tanstack/svelte-table": "^9.0.0-alpha.10", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", - "bits-ui": "^2.15.5", + "bits-ui": "^2.16.3", "clsx": "^2.1.1", "d3-scale": "^4.0.2", - "dompurify": "^3.3.1", + "dompurify": "^3.3.3", "kit-query-params": "^0.0.26", - "layerchart": "^2.0.0-next.44", + "layerchart": "^2.0.0-next.46", "mode-watcher": "^1.1.0", - "oidc-client-ts": "^3.4.1", + "oidc-client-ts": "^3.5.0", "pretty-ms": "^9.3.0", "runed": "^0.37.1", - "shiki": "^3.22.0", - "svelte-sonner": "^1.0.7", + "shiki": "^4.0.2", + "svelte-sonner": "^1.1.0", "svelte-time": "^2.1.0", - "tailwind-merge": "^3.4.1", + "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "throttle-debounce": "^5.0.2", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@chromatic-com/storybook": "^5.0.1", - "@eslint/compat": "^2.0.2", - "@eslint/js": "^9.39.2", - "@iconify-json/lucide": "^1.2.90", + "@chromatic-com/storybook": "^5.0.2", + "@eslint/compat": "^2.0.3", + "@eslint/js": "^10.0.1", + "@iconify-json/lucide": "^1.2.98", "@playwright/test": "^1.58.2", - "@storybook/addon-a11y": "^10.2.8", - "@storybook/addon-docs": "^10.2.8", - "@storybook/addon-svelte-csf": "^5.0.11", - "@storybook/sveltekit": "^10.2.8", + "@storybook/addon-a11y": "^10.3.0", + "@storybook/addon-docs": "^10.3.0", + "@storybook/addon-svelte-csf": "^5.0.12", + "@storybook/sveltekit": "^10.3.0", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.52.0", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/eslint-plugin-query": "^5.91.4", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/eslint-plugin-query": "^5.91.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@types/eslint": "^9.6.1", - "@types/node": "^25.2.3", + "@types/node": "^25.5.0", "@types/throttle-debounce": "^5.0.2", "cross-env": "^10.1.0", - "eslint": "^9.39.2", + "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-perfectionist": "^5.5.0", - "eslint-plugin-storybook": "^10.2.8", - "eslint-plugin-svelte": "^3.15.0", - "jsdom": "^28.1.0", + "eslint-plugin-oxlint": "^1.56.0", + "eslint-plugin-perfectionist": "^5.7.0", + "eslint-plugin-storybook": "^10.3.0", + "eslint-plugin-svelte": "^3.15.2", + "globals": "^17.4.0", + "jsdom": "^29.0.0", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", - "storybook": "^10.2.8", - "svelte": "^5.51.2", - "svelte-check": "^4.4.0", - "swagger-typescript-api": "^13.2.18", + "storybook": "^10.3.0", + "svelte": "^5.54.0", + "svelte-check": "^4.4.5", + "swagger-typescript-api": "^13.6.5", "tslib": "^2.8.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.0", - "vite": "^7.3.1", - "vitest": "4.0.18", + "typescript-eslint": "^8.57.1", + "vite": "^8.0.1", + "vite-plugin-oxlint": "^2.0.1", + "vitest": "^4.1.0", "vitest-websocket-mock": "^0.5.0", "zod": "^4.3.6" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -94,32 +92,114 @@ "dev": true, "license": "MIT" }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -188,9 +268,9 @@ } }, "node_modules/@biomejs/wasm-nodejs": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/wasm-nodejs/-/wasm-nodejs-2.3.15.tgz", - "integrity": "sha512-MGt/D5Y3v2VQuhyGspB+26T1SickuQFC3+HUMe9seMSnDqoNNN4vyZdUoKcORe7DVBcqs9+eBzns15lEkq3AGw==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@biomejs/wasm-nodejs/-/wasm-nodejs-2.4.7.tgz", + "integrity": "sha512-oCbBK3q3c7syIuxmLFlKG7AC13yKSRkpKQpedrsluX/4J1aWANlF44sbMsONkT8flRa6gOUYlBa5yhDcLF8mrw==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -208,9 +288,9 @@ } }, "node_modules/@chromatic-com/storybook": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.1.tgz", - "integrity": "sha512-v80QBwVd8W6acH5NtDgFlUevIBaMZAh1pYpBiB40tuNzS242NTHeQHBDGYwIAbWKDnt1qfjJpcpL6pj5kAr4LA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.2.tgz", + "integrity": "sha512-uLd5gyvcz8q83GI0rYWjml45ryO3ZJwZLretLEZvWFJ3UlFk5C5Km9cwRcKZgZp0F3zYwbb8nEe6PJdgA1eKxg==", "dev": true, "license": "MIT", "dependencies": { @@ -225,13 +305,13 @@ "yarn": ">=1.22.18" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0" } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -273,9 +353,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -289,8 +369,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { "node": ">=20.19.0" @@ -324,9 +404,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "dev": true, "funding": [ { @@ -338,7 +418,15 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -361,21 +449,52 @@ } }, "node_modules/@dagrejs/dagre": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", - "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", "license": "MIT", "dependencies": { - "@dagrejs/graphlib": "2.2.4" + "@dagrejs/graphlib": "3.0.4" } }, "node_modules/@dagrejs/graphlib": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", - "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">17.0.0" + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@epic-web/invariant": { @@ -392,6 +511,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -408,6 +528,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -424,6 +545,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -440,6 +562,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,6 +579,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -472,6 +596,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -488,6 +613,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -504,6 +630,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -520,6 +647,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -536,6 +664,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,6 +681,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -568,6 +698,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,6 +715,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,6 +732,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -616,6 +749,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -632,6 +766,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -648,6 +783,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -664,6 +800,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -680,6 +817,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -696,6 +834,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,6 +851,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,6 +868,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -744,6 +885,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,6 +902,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -776,6 +919,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -792,6 +936,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -844,13 +989,13 @@ } }, "node_modules/@eslint/compat": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz", - "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.3.tgz", + "integrity": "sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.1.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -864,119 +1009,90 @@ } } }, - "node_modules/@eslint/compat/node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", - "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", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exceptionless/browser": { @@ -1002,9 +1118,9 @@ "license": "Apache-2.0" }, "node_modules/@exodus/bytes": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", - "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { @@ -1104,9 +1220,9 @@ } }, "node_modules/@iconify-json/lucide": { - "version": "1.2.90", - "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.90.tgz", - "integrity": "sha512-dPn1pRhfBa9KR+EVpdyM1Rvw3T36hCKYxd6TxK1ifffiNt0f5yXx8ZVhqnzPH4Vkz87yLMj5xFCXomNrnzd2kQ==", + "version": "1.2.98", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.98.tgz", + "integrity": "sha512-Lx2464W8Tty/QEnZ2UPb73nPdML/HpGCj0J0w37jP3/jx3l4fniZBjDxe1TgHiIL5XW9QO3vlx53ZQZ5JsNpzQ==", "dev": true, "license": "ISC", "dependencies": { @@ -1121,9 +1237,9 @@ "license": "MIT" }, "node_modules/@internationalized/date": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz", - "integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" @@ -1133,6 +1249,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1143,6 +1260,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1153,6 +1271,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1162,12 +1281,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1175,54 +1296,52 @@ } }, "node_modules/@layerstack/svelte-actions": { - "version": "1.0.1-next.14", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1-next.14.tgz", - "integrity": "sha512-MPBmVaB+GfNHvBkg5nJkPG18smoXKvsvJRpsdWnrUBfca+TieZLoaEzNxDH+9LG11dIXP9gghsXt1mUqbbyAsA==", + "version": "1.0.1-next.18", + "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1-next.18.tgz", + "integrity": "sha512-gxPzCnJ1c9LTfWtRqLUzefCx+k59ZpxDUQ2XB+LokveZQPe7IDSOwHaBOEMlaGoGrtwc3Ft8dSZq+2WT2o9u/g==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.0", - "@layerstack/utils": "2.0.0-next.14", + "@layerstack/utils": "2.0.0-next.18", "d3-scale": "^4.0.2" } }, "node_modules/@layerstack/svelte-state": { - "version": "0.1.0-next.19", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-state/-/svelte-state-0.1.0-next.19.tgz", - "integrity": "sha512-yCYoQAIbeP8y1xmOB/r0+UundgP4JFnpNURgMki+26TotzoqrZ5oLpHvhPSVm60ks+buR3ebDBTeUFdHzxwzQQ==", + "version": "0.1.0-next.23", + "resolved": "https://registry.npmjs.org/@layerstack/svelte-state/-/svelte-state-0.1.0-next.23.tgz", + "integrity": "sha512-7O4umv+gXwFfs3/vjzFWYHNXGwYnnjBapWJ5Y+9u99F4eVk6rh4ocNwqkqQNkpMZ5tUJBlRTWjPE1So6+hEzIg==", "license": "MIT", "dependencies": { - "@layerstack/utils": "2.0.0-next.14" + "@layerstack/utils": "2.0.0-next.18" } }, "node_modules/@layerstack/tailwind": { - "version": "2.0.0-next.17", - "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-2.0.0-next.17.tgz", - "integrity": "sha512-ZSn6ouqpnzB6DKzSKLVwrUBOQsrzpDA/By2/ba9ApxgTGnaD1nyqNwrvmZ+kswdAwB4YnrGEAE4VZkKrB2+DaQ==", + "version": "2.0.0-next.21", + "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-2.0.0-next.21.tgz", + "integrity": "sha512-Qgp2EpmEHmjtura8MQzWicR6ztBRSsRvddakFtx9ShrLMz6jWzd6bCMVVRu44Q3ZOrtXmSu4QxjCZWu1ytvuPg==", "license": "MIT", "dependencies": { - "@layerstack/utils": "^2.0.0-next.14", + "@layerstack/utils": "^2.0.0-next.18", "clsx": "^2.1.1", "d3-array": "^3.2.4", - "lodash-es": "^4.17.21", "tailwind-merge": "^3.2.0" } }, "node_modules/@layerstack/utils": { - "version": "2.0.0-next.14", - "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-2.0.0-next.14.tgz", - "integrity": "sha512-1I2CS0Cwgs53W35qVg1eBdYhB/CiPvL3s0XE61b8jWkTHxgjBF65yYNgXjW74kv7WI7GsJcWMNBufPd0rnu9kA==", + "version": "2.0.0-next.18", + "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-2.0.0-next.18.tgz", + "integrity": "sha512-EYILHpfBRYMMEahajInu9C2AXQom5IcAEdtCeucD3QIl/fdDgRbtzn6/8QW9ewumfyNZetdUvitOksmI1+gZYQ==", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", - "d3-time-format": "^4.1.0", - "lodash-es": "^4.17.21" + "d3-time-format": "^4.1.0" } }, "node_modules/@lucide/svelte": { - "version": "0.564.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.564.0.tgz", - "integrity": "sha512-jODK/wHX3lKi1CsLgx9wXspsDQ5/WbsNmBqecdTCbaH1Cy3C/n2g3WM1a59sokjq+81eZDFUi/7Yg4rVI3kyow==", + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.577.0.tgz", + "integrity": "sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==", "license": "ISC", "peerDependencies": { "svelte": "^5" @@ -1246,6 +1365,23 @@ "react": ">=16" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@neoconfetti/react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", @@ -1253,374 +1389,1039 @@ "dev": true, "license": "MIT" }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.41.0.tgz", + "integrity": "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.41.0.tgz", + "integrity": "sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.41.0.tgz", + "integrity": "sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.41.0.tgz", + "integrity": "sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.41.0.tgz", + "integrity": "sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.41.0.tgz", + "integrity": "sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.41.0.tgz", + "integrity": "sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.41.0.tgz", + "integrity": "sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.41.0.tgz", + "integrity": "sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.41.0.tgz", + "integrity": "sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.41.0.tgz", + "integrity": "sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.41.0.tgz", + "integrity": "sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.41.0.tgz", + "integrity": "sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.41.0.tgz", + "integrity": "sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.41.0.tgz", + "integrity": "sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.41.0.tgz", + "integrity": "sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.41.0.tgz", + "integrity": "sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.41.0.tgz", + "integrity": "sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.41.0.tgz", + "integrity": "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", + "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", + "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", + "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", + "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", + "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", + "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", + "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", + "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", + "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", + "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", + "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", + "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", + "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", + "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", + "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", + "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", + "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", + "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", + "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ - "loong64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ - "ppc64" + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ - "riscv64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ - "riscv64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ - "s390x" + "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ - "x64" + "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ - "arm64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "cpu": [ - "ia32" + "wasm32" ], + "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz", - "integrity": "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz", - "integrity": "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", - "integrity": "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/langs": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz", - "integrity": "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz", - "integrity": "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz", - "integrity": "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/vscode-textmate": { @@ -1630,16 +2431,16 @@ "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "devOptional": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.8.tgz", - "integrity": "sha512-EW5MzPKNzyPorvodd416U2Np+zEdMPe+BSyomjm0oCXoC/6rDurf05H1pa99rZsrTDRrpog+HCz8iVa4XSwN5Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.3.0.tgz", + "integrity": "sha512-GOQP8kp0Pos3weW00U5FC2azt1zZQOcyihP+tINQO6/MDA3hts0rKMbS5+MyVULX7A5JE5uk7SzcZQj+EZ8Yrg==", "dev": true, "license": "MIT", "dependencies": { @@ -1651,20 +2452,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8" + "storybook": "^10.3.0" } }, "node_modules/@storybook/addon-docs": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.8.tgz", - "integrity": "sha512-cEoWqQrLzrxOwZFee5zrD4cYrdEWKV80POb7jUZO0r5vfl2DuslIr3n/+RfLT52runCV4aZcFEfOfP/IWHNPxg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.0.tgz", + "integrity": "sha512-g9bc4YDiy4g/peLsUDmVcy2q/QXI3eHCQtHrVp2sHWef2SYjwUJ2+TOtJHScO8LuKhGnU3h2UeE59tPWTF2quw==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.8", + "@storybook/csf-plugin": "10.3.0", "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.8", + "@storybook/react-dom-shim": "10.3.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -1674,13 +2475,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8" + "storybook": "^10.3.0" } }, "node_modules/@storybook/addon-svelte-csf": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/@storybook/addon-svelte-csf/-/addon-svelte-csf-5.0.11.tgz", - "integrity": "sha512-grfiAAl0lsPph33NV/lJkDOC4JfrHYUacX0DuUA7/0vBcihlUaX1w7AMMZ9rMrhbCyeM1imz/2rp3FeOMb7EgQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-svelte-csf/-/addon-svelte-csf-5.0.12.tgz", + "integrity": "sha512-bT7Xaxk9hQ8ZGNVtUN9IVe5ZqJAbO5iSE0TBdMsIPmY6qSG0WTm3H7HqUsb/+EdHOjykmTf2Tbs3eJt8jkpwVg==", "dev": true, "license": "MIT", "dependencies": { @@ -1693,21 +2494,21 @@ "zimmerframe": "^1.1.2" }, "peerDependencies": { - "@storybook/svelte": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0", + "@storybook/svelte": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0", "@sveltejs/vite-plugin-svelte": "^4.0.0 || ^5.0.0 || ^6.0.0", - "storybook": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0", + "storybook": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0", "svelte": "^5.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@storybook/builder-vite": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.8.tgz", - "integrity": "sha512-+6/Lwi7W0YIbzHDh798GPp0IHUYDwp0yv0Y1eVNK/StZD0tnv4/1C28NKyP+O7JOsFsuWI1qHiDhw8kNURugZw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.0.tgz", + "integrity": "sha512-T7LfZPE31j94Jkk66bnsxMibBnbLYmebLIDgPSYzeN3ZkjPfoFhhi2+8Zxneth5cQCGRkCAhRTV0tYmFp1+H6g==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.2.8", + "@storybook/csf-plugin": "10.3.0", "ts-dedent": "^2.0.0" }, "funding": { @@ -1715,8 +2516,8 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.3.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf": { @@ -1730,9 +2531,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.8.tgz", - "integrity": "sha512-kKkLYhRXb33YtIPdavD2DU25sb14sqPYdcQFpyqu4TaD9truPPqW8P5PLTUgERydt/eRvRlnhauPHavU1kjsnA==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.0.tgz", + "integrity": "sha512-zlBnNpv0wtmICdQPDoY91HNzn6BNqnS2hur580J+qJtcP+5ZOYU7+gNyU+vfAnQuLEWbPz34rx8b1cTzXZQCDg==", "dev": true, "license": "MIT", "dependencies": { @@ -1745,7 +2546,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.2.8", + "storybook": "^10.3.0", "vite": "*", "webpack": "*" }, @@ -1783,9 +2584,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.8.tgz", - "integrity": "sha512-Xde9X3VszFV1pTXfc2ZFM89XOCGRxJD8MUIzDwkcT9xaki5a+8srs/fsXj75fMY6gMYfcL5lNRZvCqg37HOmcQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.0.tgz", + "integrity": "sha512-dmAnIjkMmUYZCdg3FUL83Lavybin3bYKRNRXFZq1okCH8SINa2J+zKEzJhPlqixAKkbd7x1PFDgXnxxM/Nisig==", "dev": true, "license": "MIT", "funding": { @@ -1795,13 +2596,13 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.8" + "storybook": "^10.3.0" } }, "node_modules/@storybook/svelte": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.2.8.tgz", - "integrity": "sha512-nJWNzZxAthG7M8tySz2r3P7aLIQUOxKHF9QP08NdcKt32wyw2JGsv+7x2R1Epvt4/XALz2eklsIrCQfDbzQOOg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.3.0.tgz", + "integrity": "sha512-FxUO40O8oYk1o1fpINP5dDp5+UW+wFI7PtJ/kNUOGmhA18s3XiPI5rB1+40kPWDAl8oY9v44CixRxEzwfP50MQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1813,19 +2614,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8", + "storybook": "^10.3.0", "svelte": "^5.0.0" } }, "node_modules/@storybook/svelte-vite": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.2.8.tgz", - "integrity": "sha512-HD1cH+1cDAK47l8qNYjY3Z4EMzTPFNrRXzerYHA+eo1cexwtMbRAAqioH4NnRXSCC0Px4s98hGUYH2BfPkuOQw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.3.0.tgz", + "integrity": "sha512-FC8wzvLOB7/ybCOfp90ApBxrpS0AD+XwqltymePtqFkzd2vEMWO7HYl0O8NCflkMY2SQ7+UiZ5m0I25DPUZ+9Q==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-vite": "10.2.8", - "@storybook/svelte": "10.2.8", + "@storybook/builder-vite": "10.3.0", + "@storybook/svelte": "10.3.0", "magic-string": "^0.30.0", "svelte2tsx": "^0.7.44", "typescript": "^4.9.4 || ^5.0.0" @@ -1835,37 +2636,38 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "storybook": "^10.2.8", + "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "storybook": "^10.3.0", "svelte": "^5.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/sveltekit": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.2.8.tgz", - "integrity": "sha512-DT0Vak/fFqvZ98mhn2kvJm5fjYikWtdj4dRKK8j3Nx/Lb+XeM803RDKN51p+l/nTAnWfrpMBmErNF9OZS3AZSw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.3.0.tgz", + "integrity": "sha512-vS6R5mEwKFCRWzRAnP2XMWgdwhaKVsdNBVHzcAwu+bEtkQjgWCjiZunzO8xRc65iMUT225NTs7JAZKKVu96XpQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-vite": "10.2.8", - "@storybook/svelte": "10.2.8", - "@storybook/svelte-vite": "10.2.8" + "@storybook/builder-vite": "10.3.0", + "@storybook/svelte": "10.3.0", + "@storybook/svelte-vite": "10.3.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.8", + "storybook": "^10.3.0", "svelte": "^5.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1882,10 +2684,10 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz", - "integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==", - "devOptional": true, + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1893,12 +2695,11 @@ "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.6.2", + "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, @@ -1910,10 +2711,10 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -1925,42 +2726,23 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", - "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", - "devOptional": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", - "vitefu": "^1.1.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", - "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.1" + "vitefu": "^1.1.2" }, "engines": { "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "node_modules/@swc/helpers": { @@ -1973,49 +2755,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -2026,13 +2808,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -2043,13 +2825,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -2060,13 +2842,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -2077,13 +2859,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -2094,13 +2876,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], @@ -2111,13 +2893,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], @@ -2128,13 +2910,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], @@ -2145,13 +2927,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], @@ -2162,13 +2944,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2184,21 +2966,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -2209,13 +2991,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -2226,29 +3008,32 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/devtools-event-client": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.0.tgz", - "integrity": "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, "engines": { "node": ">=18" }, @@ -2258,9 +3043,9 @@ } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.91.4", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz", - "integrity": "sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ==", + "version": "5.91.5", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.5.tgz", + "integrity": "sha512-4pqgoT5J+ntkyOoBtnxJu8LYRj3CurfNe92fghJw66mI7pZijKmOulM32Wa48cyVzGtgiuQ2o5KWC9LJVXYcBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2272,7 +3057,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^5.0.0" + "typescript": "^5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2281,14 +3066,14 @@ } }, "node_modules/@tanstack/form-core": { - "version": "1.28.3", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.3.tgz", - "integrity": "sha512-DBhnu1d5VfACAYOAZJO8tsEUHjWczZMJY8v/YrtAJNWpwvL/3ogDuz8e6yUB2m/iVTNq6K8yrnVN2nrX0/BX/w==", + "version": "1.28.5", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.5.tgz", + "integrity": "sha512-8lYnduHHfP6uaXF9+2OLnh3Fo27tH4TdtekWLG2b/Bp26ynbrWG6L4qhBgEb7VcvTpJw/RjvJF/JyFhZkG3pfQ==", "license": "MIT", "dependencies": { - "@tanstack/devtools-event-client": "^0.4.0", + "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", - "@tanstack/store": "^0.8.1" + "@tanstack/store": "^0.9.1" }, "funding": { "type": "github", @@ -2309,9 +3094,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", "license": "MIT", "funding": { "type": "github", @@ -2329,9 +3114,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.8.1.tgz", - "integrity": "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", "license": "MIT", "funding": { "type": "github", @@ -2339,25 +3124,25 @@ } }, "node_modules/@tanstack/svelte-form": { - "version": "1.28.3", - "resolved": "https://registry.npmjs.org/@tanstack/svelte-form/-/svelte-form-1.28.3.tgz", - "integrity": "sha512-95opkSn2N8fYOWi7K1cxSRPh38bVm3/tVZshEVSbGACOUioyqt2RSJpQ0DobdRLcStdP78slyZTXA3GDUhSNIw==", + "version": "1.28.5", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-form/-/svelte-form-1.28.5.tgz", + "integrity": "sha512-m1nxd9U2/6rcBEShNt22/iyxrptScYL8kxsG0PLnNVZRWmcX+SbN0U3buZTgRzhLDgBanc8BdPHoObCDf280Zg==", "license": "MIT", "dependencies": { - "@tanstack/form-core": "1.28.3", - "@tanstack/svelte-store": "^0.9.1" + "@tanstack/form-core": "1.28.5", + "@tanstack/svelte-store": "^0.10.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "node_modules/@tanstack/svelte-query": { - "version": "6.0.18", - "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.0.18.tgz", - "integrity": "sha512-iGS8osfrIVUW5pkV4Ig6pspNIMtiNjGnVTNJKDas0m/QaNDFFIKbgg74rCzcjwrTIvO38tMpzb4VUKklvAmjxw==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.1.3.tgz", + "integrity": "sha512-CUGeiCHClr+8M9M0Ie85Rs/x+wx61jJmboRwL0p+D2A+NiUhTirh85dfLZ+umGopmb1Jvu0L6klJ7FKQv5gwvg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.91.2" }, "funding": { "type": "github", @@ -2386,12 +3171,12 @@ } }, "node_modules/@tanstack/svelte-store": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@tanstack/svelte-store/-/svelte-store-0.9.1.tgz", - "integrity": "sha512-4RYp0CXSB9tjlUZNl29mjraWeRquKzuaW+bGGI4s3kS6BWatgt7BfX4OtoLT8MTBdepW9ARwqHZ3s8YGpfOZkQ==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-store/-/svelte-store-0.10.2.tgz", + "integrity": "sha512-6yrYg6ukZZeqPf3CY5+3H0DI6bUoLNwmImgqLNchavK89LtHL4Sel1fDyLtXymMssK4kYrJOQQ9+BBZWZhhTDw==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.8.1" + "@tanstack/store": "0.9.2" }, "funding": { "type": "github", @@ -2544,6 +3329,17 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2566,7 +3362,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/d3-path": { @@ -2617,10 +3413,18 @@ "@types/json-schema": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/hast": { @@ -2656,24 +3460,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.2.2" + "undici-types": "~7.18.0" } }, "node_modules/@types/swagger-schema-official": { @@ -2694,6 +3487,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -2703,17 +3497,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -2726,7 +3520,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2742,16 +3536,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "engines": { @@ -2767,14 +3561,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "engines": { @@ -2789,14 +3583,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2807,9 +3601,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, "license": "MIT", "engines": { @@ -2824,15 +3618,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -2849,9 +3643,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "dev": true, "license": "MIT", "engines": { @@ -2863,18 +3657,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -2890,43 +3684,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2941,13 +3709,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2959,9 +3727,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2995,13 +3763,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3010,7 +3778,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -3022,9 +3790,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -3045,13 +3813,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -3059,9 +3827,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -3072,13 +3840,14 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -3086,9 +3855,9 @@ } }, "node_modules/@vitest/runner/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3096,13 +3865,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3111,12 +3881,27 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -3124,9 +3909,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3162,9 +3947,10 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3183,20 +3969,10 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3210,6 +3986,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3247,6 +4038,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3289,17 +4081,21 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bidi-js": { "version": "1.0.3", @@ -3312,9 +4108,9 @@ } }, "node_modules/bits-ui": { - "version": "2.15.5", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.5.tgz", - "integrity": "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw==", + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz", + "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.1", @@ -3360,14 +4156,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/bundle-name": { @@ -3452,16 +4250,6 @@ "dev": true, "license": "MIT" }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3489,23 +4277,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -3583,6 +4354,19 @@ "dev": true, "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3659,13 +4443,6 @@ "node": ">= 10" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3683,11 +4460,18 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3727,14 +4511,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -3760,30 +4544,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -4103,16 +4863,16 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4176,7 +4936,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4261,16 +5021,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/devalue": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", - "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, "license": "MIT" }, "node_modules/devlop": { @@ -4294,9 +5055,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4323,14 +5084,14 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -4359,9 +5120,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -4387,7 +5148,7 @@ "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4449,33 +5210,30 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4485,8 +5243,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4494,7 +5251,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4524,27 +5281,37 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-oxlint": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.56.0.tgz", + "integrity": "sha512-s47/OjE4cfQ+CD4eA38g+5axvwuyswY5H6acCdVGIvowYuLVJ6zrR7N260XfVVLRuyjjPO9L77qNYwSbmRNyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.3.1" + } + }, "node_modules/eslint-plugin-perfectionist": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.5.0.tgz", - "integrity": "sha512-lZX2KUpwOQf7J27gAg/6vt8ugdPULOLmelM8oDJPMbaN7P2zNNeyS9yxGSmJcKX0SF9qR/962l9RWM2Z5jpPzg==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.7.0.tgz", + "integrity": "sha512-WRHj7OZS/INutQ/gKN5C1ZGnMhkQ3oKZQAA2I7rl5yM8keBtSd9oj/qlJaHuwh5873FhMPqYlttcadF0YsTN7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.54.0", + "@typescript-eslint/utils": "^8.57.1", "natural-orderby": "^5.0.0" }, "engines": { "node": "^20.0.0 || >=22.0.0" }, "peerDependencies": { - "eslint": ">=8.45.0" + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-storybook": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.8.tgz", - "integrity": "sha512-BtysXrg1RoYT3DIrCc+svZ0+L3mbWsu7suxTLGrihBY5HfWHkJge+qjlBBR1Nm2ZMslfuFS5K0NUWbWCJRu6kg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.3.0.tgz", + "integrity": "sha512-8R0/RjELXkJ2RxPusX14ZiIj1So90bPnrjbxmQx1BD+4M2VoMHfn3n+6IvzJWQH4FT5tMRRUBqjLBe1fJjRRkg==", "dev": true, "license": "MIT", "dependencies": { @@ -4552,13 +5319,13 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.2.8" + "storybook": "^10.3.0" } }, "node_modules/eslint-plugin-svelte": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", - "integrity": "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.2.tgz", + "integrity": "sha512-k4Nsjs3bHujeEnnckoTM4mFYR1e8Mb9l2rTwNdmYiamA+Tjzn8X+2F+fuSP2w4VbXYhn2bmySyACQYdmUDW2Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -4632,6 +5399,56 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -4671,9 +5488,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4750,9 +5567,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4794,11 +5611,28 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4877,6 +5711,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4939,9 +5774,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -4958,16 +5793,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -5027,20 +5852,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http2-client": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", @@ -5048,20 +5859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5077,28 +5874,11 @@ "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" } }, "node_modules/imurmurhash": { @@ -5215,6 +5995,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -5247,7 +6028,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -5274,36 +6055,36 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.2", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.3", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -5335,6 +6116,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -5379,7 +6167,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5393,16 +6181,16 @@ "license": "MIT" }, "node_modules/layerchart": { - "version": "2.0.0-next.44", - "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-2.0.0-next.44.tgz", - "integrity": "sha512-ivFNjkwjGWbYMw45xr98O1BjUuAtAa1QqvQ1j22ww/UKe+Z72cUiZBoXXbG4bBtok+YJNmEPgSF3Oky2DUkWkg==", + "version": "2.0.0-next.46", + "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-2.0.0-next.46.tgz", + "integrity": "sha512-fpdnvexCG/chonAIunDdLbqrUrD0IZ2v6MG/kbI5v7/yi0bIC47er1Kz31sACQNuoK+sxMIvWeebMHqiQP5XSQ==", "license": "MIT", "dependencies": { - "@dagrejs/dagre": "^1.1.5", - "@layerstack/svelte-actions": "1.0.1-next.14", - "@layerstack/svelte-state": "0.1.0-next.19", - "@layerstack/tailwind": "2.0.0-next.17", - "@layerstack/utils": "2.0.0-next.14", + "@dagrejs/dagre": "^2.0.4", + "@layerstack/svelte-actions": "1.0.1-next.18", + "@layerstack/svelte-state": "0.1.0-next.23", + "@layerstack/tailwind": "2.0.0-next.21", + "@layerstack/utils": "2.0.0-next.18", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", @@ -5422,30 +6210,13 @@ "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", - "lodash-es": "^4.17.21", - "memoize": "^10.1.0", - "runed": "^0.31.1" + "memoize": "^10.2.0", + "runed": "^0.37.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, - "node_modules/layerchart/node_modules/runed": { - "version": "0.31.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz", - "integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5461,10 +6232,10 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "devOptional": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -5477,26 +6248,27 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5511,12 +6283,13 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5531,12 +6304,13 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5551,12 +6325,13 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5571,12 +6346,13 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5591,12 +6367,13 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5611,12 +6388,13 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5631,12 +6409,13 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5651,12 +6430,13 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5671,12 +6451,13 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5691,12 +6472,13 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5724,6 +6506,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -5742,19 +6525,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5763,9 +6533,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5785,6 +6555,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -5812,9 +6583,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, @@ -5945,16 +6716,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mock-socket": { @@ -6034,7 +6808,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -6044,7 +6818,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6054,14 +6828,14 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -6296,7 +7070,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "devOptional": true, + "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -6311,9 +7085,9 @@ "license": "MIT" }, "node_modules/oidc-client-ts": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", - "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", "license": "Apache-2.0", "dependencies": { "jwt-decode": "^4.0.0" @@ -6329,13 +7103,13 @@ "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", "license": "MIT", "dependencies": { "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", + "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, @@ -6383,6 +7157,91 @@ "node": ">= 0.8.0" } }, + "node_modules/oxfmt": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.41.0.tgz", + "integrity": "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.41.0", + "@oxfmt/binding-android-arm64": "0.41.0", + "@oxfmt/binding-darwin-arm64": "0.41.0", + "@oxfmt/binding-darwin-x64": "0.41.0", + "@oxfmt/binding-freebsd-x64": "0.41.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", + "@oxfmt/binding-linux-arm64-gnu": "0.41.0", + "@oxfmt/binding-linux-arm64-musl": "0.41.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", + "@oxfmt/binding-linux-riscv64-musl": "0.41.0", + "@oxfmt/binding-linux-s390x-gnu": "0.41.0", + "@oxfmt/binding-linux-x64-gnu": "0.41.0", + "@oxfmt/binding-linux-x64-musl": "0.41.0", + "@oxfmt/binding-openharmony-arm64": "0.41.0", + "@oxfmt/binding-win32-arm64-msvc": "0.41.0", + "@oxfmt/binding-win32-ia32-msvc": "0.41.0", + "@oxfmt/binding-win32-x64-msvc": "0.41.0" + } + }, + "node_modules/oxlint": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", + "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.56.0", + "@oxlint/binding-android-arm64": "1.56.0", + "@oxlint/binding-darwin-arm64": "1.56.0", + "@oxlint/binding-darwin-x64": "1.56.0", + "@oxlint/binding-freebsd-x64": "1.56.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", + "@oxlint/binding-linux-arm-musleabihf": "1.56.0", + "@oxlint/binding-linux-arm64-gnu": "1.56.0", + "@oxlint/binding-linux-arm64-musl": "1.56.0", + "@oxlint/binding-linux-ppc64-gnu": "1.56.0", + "@oxlint/binding-linux-riscv64-gnu": "1.56.0", + "@oxlint/binding-linux-riscv64-musl": "1.56.0", + "@oxlint/binding-linux-s390x-gnu": "1.56.0", + "@oxlint/binding-linux-x64-gnu": "1.56.0", + "@oxlint/binding-linux-x64-musl": "1.56.0", + "@oxlint/binding-openharmony-arm64": "1.56.0", + "@oxlint/binding-win32-arm64-msvc": "1.56.0", + "@oxlint/binding-win32-ia32-msvc": "1.56.0", + "@oxlint/binding-win32-x64-msvc": "1.56.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.15.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6415,18 +7274,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/parse-ms": { "version": "4.0.0", @@ -6501,14 +7354,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6562,10 +7415,10 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "devOptional": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6725,9 +7578,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", - "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7027,62 +7880,44 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "devOptional": true, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, "node_modules/run-applescript": { @@ -7136,7 +7971,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mri": "^1.1.0" @@ -7195,7 +8030,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/shebang-command": { @@ -7222,19 +8057,22 @@ } }, "node_modules/shiki": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz", - "integrity": "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.22.0", - "@shikijs/engine-javascript": "3.22.0", - "@shikijs/engine-oniguruma": "3.22.0", - "@shikijs/langs": "3.22.0", - "@shikijs/themes": "3.22.0", - "@shikijs/types": "3.22.0", + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/should": { @@ -7308,7 +8146,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -7332,7 +8170,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7392,22 +8230,22 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/storybook": { - "version": "10.2.8", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.8.tgz", - "integrity": "sha512-885uSIn8NQw2ZG7vy84K45lHCOSyz1DVsDV8pHiHQj3J0riCuWLNeO50lK9z98zE8kjhgTtxAAkMTy5nkmNRKQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.0.tgz", + "integrity": "sha512-OpLdng98l7cACuqBoQwewx21Vhgl9XPssgLdXQudW0+N5QPjinWXZpZCquZpXpNCyw5s5BFAcv+jKB3Qkf9jeA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", - "@testing-library/jest-dom": "^6.6.3", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", @@ -7518,19 +8356,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -7540,23 +8365,11 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/svelte": { - "version": "5.51.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.51.2.tgz", - "integrity": "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -7565,10 +8378,10 @@ "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "^5.3.1", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.2", + "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", @@ -7626,9 +8439,9 @@ "license": "MIT" }, "node_modules/svelte-check": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.0.tgz", - "integrity": "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", "dev": true, "license": "MIT", "dependencies": { @@ -7680,9 +8493,9 @@ } }, "node_modules/svelte-sonner": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz", - "integrity": "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz", + "integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==", "license": "MIT", "dependencies": { "runed": "^0.28.0" @@ -7760,19 +8573,30 @@ } } }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/svelte/node_modules/esrap": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "node_modules/svelte2tsx": { - "version": "0.7.48", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.48.tgz", - "integrity": "sha512-B15C8dtOY6C9MbnQJDCkzbK3yByInzKtXrr23QCoF8APHMh6JaDhjCMcRl6ay4qaeKYqkX4X3tNaJrsZL45Zlg==", + "version": "0.7.52", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.52.tgz", + "integrity": "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg==", "dev": true, "license": "MIT", "dependencies": { @@ -7792,14 +8616,15 @@ "license": "ISC" }, "node_modules/swagger-typescript-api": { - "version": "13.2.18", - "resolved": "https://registry.npmjs.org/swagger-typescript-api/-/swagger-typescript-api-13.2.18.tgz", - "integrity": "sha512-YLcEdW3weYRB1BokCJv8RnxPdPRV36segDLu1kVIn+02S9emDU86CwRuZKmQ3izZRAuWtRYvCzB0p1vwb8mwgw==", + "version": "13.6.5", + "resolved": "https://registry.npmjs.org/swagger-typescript-api/-/swagger-typescript-api-13.6.5.tgz", + "integrity": "sha512-42dFRKr3VYOWT2EY3qYxLDcDlVrlKqaD5WvoO3ePLc2sNpP4YgDebdPhxYBEhwRQk+Bo7941XtAQZaQzz2znSQ==", "dev": true, "license": "MIT", "dependencies": { + "@apidevtools/swagger-parser": "12.1.0", "@biomejs/js-api": "4.0.0", - "@biomejs/wasm-nodejs": "2.3.15", + "@biomejs/wasm-nodejs": "2.4.7", "@types/swagger-schema-official": "^2.0.25", "c12": "^3.3.3", "citty": "^0.2.1", @@ -7812,7 +8637,8 @@ "swagger2openapi": "^7.0.8", "type-fest": "^5.4.4", "typescript": "~5.9.3", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "yummies": "7.11.0" }, "bin": { "sta": "dist/cli.mjs", @@ -7922,9 +8748,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz", - "integrity": "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -7951,9 +8777,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { @@ -8007,7 +8833,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -8020,6 +8846,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -8041,22 +8877,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", "dev": true, "license": "MIT" }, @@ -8064,16 +8900,16 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8107,9 +8943,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -8174,7 +9010,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8185,16 +9021,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8209,9 +9045,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "dev": true, "license": "MIT", "engines": { @@ -8219,10 +9055,10 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unist-util-is": { @@ -8375,17 +9211,16 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "devOptional": true, + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "bin": { @@ -8402,9 +9237,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -8417,13 +9253,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -8449,10 +9288,26 @@ } } }, + "node_modules/vite-plugin-oxlint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vite-plugin-oxlint/-/vite-plugin-oxlint-2.0.1.tgz", + "integrity": "sha512-+WPw00LwN8BZ9uKro0a2lOE8+Da8YHM6p5p10E43mXw/zYCQNV2pEnDuxehCUhUJ/KyAf4cs01n6i4Ey4JfhfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "package-manager-detector": "^1.6.0" + }, + "peerDependencies": { + "oxlint": ">=0.9.0", + "vite": ">=5.0.0" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8464,10 +9319,10 @@ } }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "devOptional": true, + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -8475,7 +9330,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -8484,31 +9339,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8524,12 +9379,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -8558,6 +9414,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -8576,17 +9435,17 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -8594,9 +9453,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -8607,9 +9466,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -8617,13 +9476,14 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -8641,9 +9501,9 @@ } }, "node_modules/vitest/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -8691,9 +9551,9 @@ } }, "node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { @@ -8848,7 +9708,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -8902,17 +9762,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yummies": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/yummies/-/yummies-7.11.0.tgz", + "integrity": "sha512-JuDP4NdO5bPfYCTv4QkGHmJPKjMyJ1KcsxnSLOmge4qPxQFfZuCnP4hvpX1WWvF/xI/v3bytdgGV2i4ZmhDt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "nanoid": "^5.1.6", + "tailwind-merge": "^3.5.0" + }, + "peerDependencies": { + "mobx": "^6.12.4", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "mobx": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/yummies/node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, "license": "MIT" }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 76692cbd24..1304f6f57e 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -2,6 +2,7 @@ "name": "exceptionless", "version": "8.0.0", "private": true, + "type": "module", "scripts": { "dev": "cross-env NODE_OPTIONS=--trace-warnings vite dev", "dev:api": "cross-env ASPNETCORE_URLS=https://be.exceptionless.io/ vite dev", @@ -9,12 +10,15 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "npm run lint:prettier && npm run lint:eslint", + "lint": "npm run lint:oxfmt && npm run lint:prettier && npm run lint:oxlint && npm run lint:eslint", + "lint:oxfmt": "oxfmt --check .", + "lint:prettier": "prettier --check **/*.svelte", + "lint:oxlint": "oxlint", "lint:eslint": "eslint . --concurrency=auto", - "lint:prettier": "prettier --check .", - "format": "npm run format:prettier && npm run format:eslint", + "format": "npm run format:oxfmt && npm run format:prettier && npm run format:eslint", + "format:oxfmt": "oxfmt --write .", + "format:prettier": "prettier --write **/*.svelte", "format:eslint": "eslint . --fix --concurrency=auto", - "format:prettier": "prettier --write .", "generate-models": "node scripts/generate-api.mjs", "generate-templates": "swagger-typescript-api generate-templates -o api-templates", "test:e2e": "playwright test", @@ -24,79 +28,83 @@ "build-storybook": "storybook build", "upgrade": "ncu -i" }, - "devDependencies": { - "@chromatic-com/storybook": "^5.0.1", - "@eslint/compat": "^2.0.2", - "@eslint/js": "^9.39.2", - "@iconify-json/lucide": "^1.2.90", - "@playwright/test": "^1.58.2", - "@storybook/addon-a11y": "^10.2.8", - "@storybook/addon-docs": "^10.2.8", - "@storybook/addon-svelte-csf": "^5.0.11", - "@storybook/sveltekit": "^10.2.8", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.52.0", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/eslint-plugin-query": "^5.91.4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/svelte": "^5.3.1", - "@types/eslint": "^9.6.1", - "@types/node": "^25.2.3", - "@types/throttle-debounce": "^5.0.2", - "cross-env": "^10.1.0", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-perfectionist": "^5.5.0", - "eslint-plugin-storybook": "^10.2.8", - "eslint-plugin-svelte": "^3.15.0", - "jsdom": "^28.1.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.4.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "storybook": "^10.2.8", - "svelte": "^5.51.2", - "svelte-check": "^4.4.0", - "swagger-typescript-api": "^13.2.18", - "tslib": "^2.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.56.0", - "vite": "^7.3.1", - "vitest": "4.0.18", - "vitest-websocket-mock": "^0.5.0", - "zod": "^4.3.6" - }, "dependencies": { "@exceptionless/browser": "^3.1.0", "@exceptionless/fetchclient": "^0.44.0", - "@internationalized/date": "^3.11.0", - "@lucide/svelte": "^0.564.0", - "@tanstack/svelte-form": "^1.28.3", - "@tanstack/svelte-query": "^6.0.18", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^0.577.0", + "@tanstack/svelte-form": "^1.28.5", + "@tanstack/svelte-query": "^6.1.3", "@tanstack/svelte-query-devtools": "^6.0.4", "@tanstack/svelte-table": "^9.0.0-alpha.10", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", - "bits-ui": "^2.15.5", + "bits-ui": "^2.16.3", "clsx": "^2.1.1", "d3-scale": "^4.0.2", - "dompurify": "^3.3.1", + "dompurify": "^3.3.3", "kit-query-params": "^0.0.26", - "layerchart": "^2.0.0-next.44", + "layerchart": "^2.0.0-next.46", "mode-watcher": "^1.1.0", - "oidc-client-ts": "^3.4.1", + "oidc-client-ts": "^3.5.0", "pretty-ms": "^9.3.0", "runed": "^0.37.1", - "shiki": "^3.22.0", - "svelte-sonner": "^1.0.7", + "shiki": "^4.0.2", + "svelte-sonner": "^1.1.0", "svelte-time": "^2.1.0", - "tailwind-merge": "^3.4.1", + "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "throttle-debounce": "^5.0.2", "tw-animate-css": "^1.4.0" }, - "type": "module", + "devDependencies": { + "@chromatic-com/storybook": "^5.0.2", + "@eslint/compat": "^2.0.3", + "@eslint/js": "^10.0.1", + "@iconify-json/lucide": "^1.2.98", + "@playwright/test": "^1.58.2", + "@storybook/addon-a11y": "^10.3.0", + "@storybook/addon-docs": "^10.3.0", + "@storybook/addon-svelte-csf": "^5.0.12", + "@storybook/sveltekit": "^10.3.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/eslint-plugin-query": "^5.91.5", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@types/eslint": "^9.6.1", + "@types/node": "^25.5.0", + "@types/throttle-debounce": "^5.0.2", + "cross-env": "^10.1.0", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-oxlint": "^1.56.0", + "eslint-plugin-perfectionist": "^5.7.0", + "eslint-plugin-storybook": "^10.3.0", + "eslint-plugin-svelte": "^3.15.2", + "globals": "^17.4.0", + "jsdom": "^29.0.0", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "storybook": "^10.3.0", + "svelte": "^5.54.0", + "svelte-check": "^4.4.5", + "swagger-typescript-api": "^13.6.5", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1", + "vite": "^8.0.1", + "vite-plugin-oxlint": "^2.0.1", + "vitest": "^4.1.0", + "vitest-websocket-mock": "^0.5.0", + "zod": "^4.3.6" + }, "overrides": { "storybook": "$storybook" } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts new file mode 100644 index 0000000000..17a7257c6c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -0,0 +1,93 @@ +import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery } from '@tanstack/svelte-query'; + +import type { AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, MigrationsResponse } from './models'; + +export type RunMaintenanceJobParams = { + name: string; + organizationId?: string; + utcEnd?: Date; + utcStart?: Date; +}; + +export const queryKeys = { + elasticsearch: ['admin', 'elasticsearch'] as const, + migrations: ['admin', 'migrations'] as const, + snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, + stats: ['admin', 'stats'] as const +}; + +export function getAdminStatsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/stats', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.stats, + staleTime: 60 * 1000 + })); +} + +export function getElasticsearchQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/elasticsearch', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.elasticsearch, + staleTime: 30 * 1000 + })); +} + +export function getElasticsearchSnapshotsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/elasticsearch/snapshots', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.snapshots, + staleTime: 60 * 1000 + })); +} + +export function getMigrationsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/migrations', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.migrations, + staleTime: 30 * 1000 + })); +} + +export function runMaintenanceJobMutation() { + return createMutation(() => ({ + mutationFn: async (params: RunMaintenanceJobParams) => { + const client = useFetchClient(); + await client.getJSON(`admin/maintenance/${params.name}`, { + params: { + organizationId: params.organizationId, + utcEnd: params.utcEnd?.toISOString(), + utcStart: params.utcStart?.toISOString() + } + }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/dialogs/run-maintenance-job-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/dialogs/run-maintenance-job-dialog.svelte new file mode 100644 index 0000000000..19da26bbe3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/dialogs/run-maintenance-job-dialog.svelte @@ -0,0 +1,239 @@ + + + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {action.label} + {action.description} + + +
+ {#if action.dangerous} +
+ + This is a destructive operation. Please ensure you understand the impact before proceeding. +
+ {/if} + + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + {#if action.hasDateRange} +
+ + Start Date (UTC) + + + {#snippet child({ props: triggerProps })} + + {/snippet} + + + + + + Leave blank to use the default start date. + + + + End Date (UTC) + + + {#snippet child({ props: triggerProps })} + + {/snippet} + + + + + + Leave blank to process through the current date. + +
+ {/if} + + {#if action.hasOrganizationId && !organizationId} + + {#snippet children(field)} + + Organization ID + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + Restrict this job to a specific organization. + + {/snippet} + + {/if} + + (value === action.name ? undefined : `Type "${action.name}" to confirm`) }} + > + {#snippet children(field)} + + + Type {action.name} to confirm + + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + +
+ + + Cancel + state.isSubmitting || state.values.confirmText !== action.name}> + {#snippet children(isDisabled)} + + {isDisabled && form.state.isSubmitting ? 'Running...' : 'Run Job'} + + {/snippet} + + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte new file mode 100644 index 0000000000..b7c4659789 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-data-table.svelte @@ -0,0 +1,43 @@ + + + + {#if toolbarChildren} + + {@render toolbarChildren()} + + {:else} + + {/if} + + {#if isLoading} + + + + {:else} + + {/if} + + + +
+ + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-options.svelte.ts new file mode 100644 index 0000000000..35818298d5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/backups-options.svelte.ts @@ -0,0 +1,96 @@ +import type { ElasticsearchSnapshot } from '$features/admin/models'; + +import DateTime from '$comp/formatters/date-time.svelte'; +import Number from '$comp/formatters/number.svelte'; +import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; +import { type ColumnDef, getSortedRowModel, renderComponent } from '@tanstack/svelte-table'; + +import ShardsCell from './shards-cell.svelte'; +import SnapshotStatusCell from './snapshot-status-cell.svelte'; + +export function getColumns(): ColumnDef[] { + return [ + { + accessorKey: 'name', + cell: (info) => info.getValue(), + enableSorting: true, + header: 'Name', + meta: { + class: 'max-w-64 font-mono' + } + }, + { + accessorKey: 'repository', + cell: (info) => info.getValue(), + enableSorting: true, + header: 'Repository' + }, + { + accessorKey: 'status', + cell: (info) => renderComponent(SnapshotStatusCell, { value: info.getValue() as string | undefined }), + enableSorting: true, + header: 'Status' + }, + { + accessorKey: 'start_time', + cell: (info) => renderComponent(DateTime, { value: (info.getValue() as null | string) ?? undefined }), + enableSorting: true, + header: 'Started' + }, + { + accessorKey: 'duration', + cell: (info) => info.getValue() ?? '—', + enableSorting: false, + header: 'Duration', + meta: { + class: 'font-mono' + } + }, + { + accessorKey: 'indices_count', + cell: (info) => renderComponent(Number, { value: info.getValue() as null | number }), + enableSorting: true, + header: 'Indices', + meta: { + class: 'text-right' + } + }, + { + cell: ({ row }) => + renderComponent(ShardsCell, { + failedShards: row.original.failed_shards, + successfulShards: row.original.successful_shards, + totalShards: row.original.total_shards + }), + enableSorting: false, + header: 'Shards', + id: 'shards', + meta: { + class: 'text-right' + } + } + ]; +} + +export function getTableOptions(queryParameters: TableMemoryPagingParameters, getData: () => ElasticsearchSnapshot[]) { + return getSharedTableOptions({ + columnPersistenceKey: 'admin-backups', + get columns() { + return getColumns(); + }, + configureOptions: (options) => { + options.getRowId = (row) => row.repository + '/' + row.name; + options.getSortedRowModel = getSortedRowModel(); + options.initialState = { sorting: [{ desc: true, id: 'start_time' }] }; + options.manualSorting = false; + return options; + }, + paginationStrategy: 'memory', + get queryData() { + return getData(); + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/health-badge-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/health-badge-cell.svelte new file mode 100644 index 0000000000..d5ca55b98e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/health-badge-cell.svelte @@ -0,0 +1,14 @@ + + + + {healthLabel(value)} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte new file mode 100644 index 0000000000..7ac36abebc --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-data-table.svelte @@ -0,0 +1,43 @@ + + + + {#if toolbarChildren} + + {@render toolbarChildren()} + + {:else} + + {/if} + + {#if isLoading} + + + + {:else} + + {/if} + + + +
+ + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-options.svelte.ts new file mode 100644 index 0000000000..cce671df3f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/indices-options.svelte.ts @@ -0,0 +1,106 @@ +import type { ElasticsearchIndexDetail } from '$features/admin/models'; + +import Bytes from '$comp/formatters/bytes.svelte'; +import Number from '$comp/formatters/number.svelte'; +import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; +import { type ColumnDef, getSortedRowModel, renderComponent } from '@tanstack/svelte-table'; + +import HealthBadgeCell from './health-badge-cell.svelte'; +import UnassignedShardsCell from './unassigned-shards-cell.svelte'; + +export function getColumns(): ColumnDef[] { + return [ + { + accessorKey: 'index', + cell: (info) => info.getValue(), + enableSorting: true, + header: 'Index', + meta: { + class: 'max-w-xs' + } + }, + { + accessorKey: 'health', + cell: (info) => renderComponent(HealthBadgeCell, { value: info.getValue() as null | string | undefined }), + enableSorting: true, + header: 'Health' + }, + { + accessorKey: 'status', + cell: (info) => info.getValue(), + enableSorting: true, + header: 'Status' + }, + { + accessorKey: 'primary', + cell: (info) => renderComponent(Number, { value: info.getValue() as null | number }), + enableSorting: true, + header: 'Primary', + meta: { + class: 'text-right' + } + }, + { + accessorKey: 'replica', + cell: (info) => renderComponent(Number, { value: info.getValue() as null | number }), + enableSorting: true, + header: 'Replica', + meta: { + class: 'text-right' + } + }, + { + accessorKey: 'unassigned_shards', + cell: (info) => renderComponent(UnassignedShardsCell, { value: info.getValue() as number }), + enableSorting: true, + header: 'Unassigned', + meta: { + class: 'text-right' + } + }, + { + accessorKey: 'docs_count', + cell: (info) => renderComponent(Number, { value: info.getValue() as null | number }), + enableSorting: true, + header: 'Documents', + meta: { + class: 'text-right' + } + }, + { + accessorKey: 'store_size_in_bytes', + cell: (info) => renderComponent(Bytes, { value: info.getValue() as null | number }), + enableSorting: true, + header: 'Size', + meta: { + class: 'text-right' + } + } + ]; +} + +export function getTableOptions(queryParameters: TableMemoryPagingParameters, getData: () => ElasticsearchIndexDetail[]) { + return getSharedTableOptions({ + columnPersistenceKey: 'admin-indices', + get columns() { + return getColumns(); + }, + configureOptions: (options) => { + options.getSortedRowModel = getSortedRowModel(); + options.initialState = { sorting: [{ desc: true, id: 'store_size_in_bytes' }] }; + options.manualSorting = false; + return options; + }, + defaultColumnVisibility: { + primary: false, + replica: false + }, + paginationStrategy: 'memory', + get queryData() { + return getData(); + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-duration-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-duration-cell.svelte new file mode 100644 index 0000000000..dd33a3e403 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-duration-cell.svelte @@ -0,0 +1,19 @@ + + +{#if durationMs !== null} + +{:else} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-error-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-error-cell.svelte new file mode 100644 index 0000000000..80915bf166 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-error-cell.svelte @@ -0,0 +1,15 @@ + + +{#if value} + {value} +{:else} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-status-cell.svelte new file mode 100644 index 0000000000..352303773a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-status-cell.svelte @@ -0,0 +1,28 @@ + + +
+ + {status} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-type-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-type-cell.svelte new file mode 100644 index 0000000000..1fc35cd8bb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migration-type-cell.svelte @@ -0,0 +1,22 @@ + + +{migrationTypeLabel(value)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte new file mode 100644 index 0000000000..784564e469 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-data-table.svelte @@ -0,0 +1,43 @@ + + + + {#if toolbarChildren} + + {@render toolbarChildren()} + + {:else} + + {/if} + + {#if isLoading} + + + + {:else} + + {/if} + + + +
+ + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-options.svelte.ts new file mode 100644 index 0000000000..93082459a8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/migrations-options.svelte.ts @@ -0,0 +1,108 @@ +import type { MigrationState, MigrationStatus } from '$features/admin/models'; + +import DateTime from '$comp/formatters/date-time.svelte'; +import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; +import { type ColumnDef, getSortedRowModel, renderComponent } from '@tanstack/svelte-table'; + +import MigrationDurationCell from './migration-duration-cell.svelte'; +import MigrationErrorCell from './migration-error-cell.svelte'; +import MigrationStatusCell from './migration-status-cell.svelte'; +import MigrationTypeCell from './migration-type-cell.svelte'; + +export type MigrationStateRow = MigrationState & { status: MigrationStatus }; + +export function getColumns(): ColumnDef[] { + return [ + { + accessorKey: 'id', + cell: (info) => info.getValue(), + enableSorting: true, + header: 'Name', + meta: { + class: 'max-w-xs font-medium' + } + }, + { + accessorKey: 'version', + cell: (info) => (info.getValue() as null | number) ?? '—', + enableSorting: true, + header: 'Version', + meta: { + class: 'text-right' + } + }, + { + accessorKey: 'migration_type', + cell: (info) => renderComponent(MigrationTypeCell, { value: info.getValue() as number }), + enableSorting: true, + header: 'Type' + }, + { + accessorKey: 'status', + cell: (info) => renderComponent(MigrationStatusCell, { status: info.getValue() as MigrationStatus }), + enableSorting: true, + header: 'Status' + }, + { + accessorKey: 'started_utc', + cell: (info) => renderComponent(DateTime, { value: (info.getValue() as null | string) ?? undefined }), + enableSorting: true, + header: 'Started' + }, + { + accessorKey: 'completed_utc', + cell: (info) => renderComponent(DateTime, { value: (info.getValue() as null | string) ?? undefined }), + enableSorting: true, + header: 'Completed' + }, + { + cell: ({ row }) => + renderComponent(MigrationDurationCell, { + completedUtc: row.original.completed_utc, + startedUtc: row.original.started_utc + }), + enableSorting: false, + header: 'Duration', + id: 'duration' + }, + { + accessorKey: 'error_message', + cell: (info) => renderComponent(MigrationErrorCell, { value: info.getValue() as null | string | undefined }), + enableSorting: false, + header: 'Error', + meta: { + class: 'max-w-xs' + } + } + ]; +} + +export function getTableOptions(queryParameters: TableMemoryPagingParameters, getData: () => MigrationStateRow[]) { + return getSharedTableOptions({ + columnPersistenceKey: 'admin-migrations', + get columns() { + return getColumns(); + }, + configureOptions: (options) => { + options.getRowId = (row) => row.id; + options.getSortedRowModel = getSortedRowModel(); + options.initialState = { + sorting: [{ desc: true, id: 'version' }] + }; + options.manualSorting = false; + return options; + }, + defaultColumnVisibility: { + completed_utc: false, + duration: false, + error_message: false + }, + paginationStrategy: 'memory', + get queryData() { + return getData(); + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-metric-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-metric-cell.svelte new file mode 100644 index 0000000000..be6bb923e2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-metric-cell.svelte @@ -0,0 +1,31 @@ + + +
+ {#if metricId === 'active_primary'} + + {:else if metricId === 'active_total'} + + {:else if metricId === 'relocating'} + + {:else if metricId === 'unassigned'} + {#if value > 0} + + {:else} + + {/if} + {/if} + {label} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-value-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-value-cell.svelte new file mode 100644 index 0000000000..5b89f6b744 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shard-value-cell.svelte @@ -0,0 +1,14 @@ + + + 0 ? 'font-semibold text-amber-500' : ''}> + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-cell.svelte new file mode 100644 index 0000000000..5d49267f66 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-cell.svelte @@ -0,0 +1,18 @@ + + + 0 ? 'text-destructive font-semibold' : 'text-muted-foreground'}> + / + {#if failedShards > 0} + ( failed) + {/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-data-table.svelte new file mode 100644 index 0000000000..4e064e8257 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-data-table.svelte @@ -0,0 +1,27 @@ + + + + + {#if isLoading} + + + + {:else} + + {/if} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-options.svelte.ts new file mode 100644 index 0000000000..71536e7aeb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/shards-overview-options.svelte.ts @@ -0,0 +1,52 @@ +import type { ShardMetric } from '$features/admin/models'; + +import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; +import { type ColumnDef, renderComponent } from '@tanstack/svelte-table'; + +import ShardMetricCell from './shard-metric-cell.svelte'; +import ShardValueCell from './shard-value-cell.svelte'; + +export function getColumns(): ColumnDef[] { + return [ + { + accessorKey: 'label', + cell: (info) => + renderComponent(ShardMetricCell, { + label: info.row.original.label, + metricId: info.row.original.id, + value: info.row.original.value + }), + enableSorting: false, + header: 'Metric' + }, + { + accessorKey: 'value', + cell: (info) => + renderComponent(ShardValueCell, { + metricId: info.row.original.id, + value: info.getValue() as number + }), + enableSorting: false, + header: 'Count', + meta: { + class: 'text-right' + } + } + ]; +} + +export function getTableOptions(queryParameters: TableMemoryPagingParameters, getData: () => ShardMetric[]) { + return getSharedTableOptions({ + columnPersistenceKey: 'admin-shards-overview', + get columns() { + return getColumns(); + }, + paginationStrategy: 'memory', + get queryData() { + return getData(); + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/snapshot-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/snapshot-status-cell.svelte new file mode 100644 index 0000000000..6744a0b18c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/snapshot-status-cell.svelte @@ -0,0 +1,14 @@ + + + + {value?.toLowerCase().replaceAll('_', ' ')} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/unassigned-shards-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/unassigned-shards-cell.svelte new file mode 100644 index 0000000000..3b5fccd88f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/table/unassigned-shards-cell.svelte @@ -0,0 +1,13 @@ + + + + {#if value > 0}{:else}—{/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/elasticsearch-utils.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/elasticsearch-utils.ts new file mode 100644 index 0000000000..bad9071378 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/elasticsearch-utils.ts @@ -0,0 +1,85 @@ +export function healthBadgeClass(status: null | number | string | undefined): string { + const s = typeof status === 'string' ? status.toLowerCase() : status; + + if (s === 0 || s === 'green') { + return 'text-muted-foreground border-muted-foreground/30'; + } + + if (s === 1 || s === 'yellow') { + return 'border-amber-500 text-amber-600 dark:text-amber-400'; + } + + if (s === 2 || s === 'red') { + return 'border-destructive/50 text-destructive'; + } + + return 'text-muted-foreground border-muted-foreground/30'; +} + +export function healthColor(status: null | number | string | undefined): string { + const s = typeof status === 'string' ? status.toLowerCase() : status; + + if (s === 0 || s === 'green') { + return 'text-green-600'; + } + + if (s === 1 || s === 'yellow') { + return 'text-amber-500'; + } + + if (s === 2 || s === 'red') { + return 'text-destructive'; + } + + return 'text-muted-foreground'; +} + +export function healthLabel(status: null | number | string | undefined): string { + if (typeof status === 'string') { + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); + } + switch (status) { + case 0: + return 'Green'; + case 1: + return 'Yellow'; + case 2: + return 'Red'; + default: + return 'Unknown'; + } +} + +export function healthVariant(status: null | number | string | undefined): 'default' | 'destructive' | 'outline' | 'secondary' { + const s = typeof status === 'string' ? status.toLowerCase() : status; + + if (s === 2 || s === 'red') { + return 'destructive'; + } + + return 'outline'; +} + +export function snapshotBadgeClass(status: string | undefined): string { + switch (status?.toUpperCase()) { + case 'IN_PROGRESS': + return 'border-blue-500 text-blue-600 dark:text-blue-400'; + case 'PARTIAL': + return 'border-amber-500 text-amber-600 dark:text-amber-400'; + case 'SUCCESS': + return 'text-muted-foreground border-muted-foreground/30'; + default: + return 'text-muted-foreground border-muted-foreground/30'; + } +} + +export function snapshotVariant(status: string | undefined): 'default' | 'destructive' | 'outline' | 'secondary' { + switch (status?.toUpperCase()) { + case 'FAILED': + return 'destructive'; + case 'IN_PROGRESS': + return 'secondary'; + default: + return 'outline'; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts new file mode 100644 index 0000000000..0e2fe5ebe3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -0,0 +1,186 @@ +import type { CountResult } from '$generated/api'; + +export enum MigrationType { + Versioned = 0, + VersionedAndResumable = 1, + Repeatable = 2 +} + +export type AdminStats = { + events: CountResult; + organizations: CountResult; + projects: CountResult; + stacks: CountResult; + users: CountResult; +}; + +export type ElasticsearchHealth = { + active_primary_shards: number; + active_shards: number; + cluster_name: string; + number_of_data_nodes: number; + number_of_nodes: number; + relocating_shards: number; + status: number; + unassigned_shards: number; +}; + +export type ElasticsearchIndexDetail = { + docs_count: number; + health?: null | string; + index?: null | string; + primary: number; + replica: number; + status?: null | string; + store_size_in_bytes: number; + unassigned_shards: number; +}; + +export type ElasticsearchIndices = { + count: number; + docs_count: number; + store_size_in_bytes: number; +}; + +export type ElasticsearchInfo = { + health: ElasticsearchHealth; + index_details: ElasticsearchIndexDetail[]; + indices: ElasticsearchIndices; +}; + +export type ElasticsearchSnapshot = { + duration: string; + end_time?: null | string; + failed_shards: number; + indices_count: number; + name: string; + repository: string; + start_time?: null | string; + status: string; + successful_shards: number; + total_shards: number; +}; + +export type ElasticsearchSnapshotsResponse = { + repositories: string[]; + snapshots: ElasticsearchSnapshot[]; +}; + +export type MaintenanceAction = { + category: MaintenanceActionCategory; + dangerous: boolean; + description: string; + hasDateRange?: boolean; + hasOrganizationId?: boolean; + label: string; + name: string; +}; + +export type MaintenanceActionCategory = 'Billing' | 'Configuration' | 'Elasticsearch' | 'Maintenance' | 'Security' | 'Users'; +export type MigrationsResponse = { + current_version: number; + states: MigrationState[]; +}; + +export type MigrationState = { + completed_utc?: null | string; + error_message?: null | string; + id: string; + migration_type: number; + started_utc?: null | string; + version: number; +}; + +export type MigrationStatus = 'Completed' | 'Failed' | 'Pending' | 'Running'; + +export type ShardMetric = { + id: string; + label: string; + value: number; +}; + +export const maintenanceActions: MaintenanceAction[] = [ + { + category: 'Elasticsearch', + dangerous: false, + description: + 'Runs Elasticsearch index setup for all indices: creates any missing indices and applies current field mappings. Does not reindex existing documents. Safe to run at any time to bring the schema in sync with new mapping definitions.', + label: 'Configure Indexes', + name: 'indexes' + }, + { + category: 'Billing', + dangerous: false, + description: + 'Re-applies the current billing plan limits and features (event limits, data retention, team size, etc.) to every organization without changing subscription status. Run after updating a plan definition to propagate changes to all existing subscribers.', + label: 'Update Organization Plans', + name: 'update-organization-plans' + }, + { + category: 'Maintenance', + dangerous: true, + description: + 'Permanently deletes hourly usage records older than 3 days and monthly usage records older than 366 days from every organization. Reduces document size and removes stale data no longer needed for billing or dashboards.', + label: 'Remove Old Organization Usage', + name: 'remove-old-organization-usage' + }, + { + category: 'Configuration', + dangerous: false, + description: + 'Re-stamps the latest system-default user-agent bot-filter patterns onto every project and bumps the configuration version, forcing all Exceptionless clients to refresh their local settings on the next request.', + label: 'Update Project Default Bot Lists', + name: 'update-project-default-bot-lists' + }, + { + category: 'Configuration', + dangerous: false, + description: + 'Bumps the configuration version counter on every project, forcing all connected Exceptionless clients to re-download their project settings (rate limits, user-agent filters, custom data exclusions, etc.) on the next heartbeat.', + label: 'Increment Project Configuration Version', + name: 'increment-project-configuration-version' + }, + { + category: 'Maintenance', + dangerous: true, + description: + 'Permanently deletes hourly usage records older than 3 days and monthly usage records older than 366 days from every project. Similar to organization usage cleanup but operates at the per-project level.', + label: 'Remove Old Project Usage', + name: 'remove-old-project-usage' + }, + { + category: 'Users', + dangerous: false, + description: + "Trims whitespace and lowercases every user's email address and full name. Fixes historical records created before strict normalization was enforced, ensuring consistent login lookups and deduplication.", + label: 'Normalize User Email Addresses', + name: 'normalize-user-email-address' + }, + { + category: 'Security', + dangerous: true, + description: + 'Generates a fresh random verification token and resets the expiration date for every unverified user account. Run before a bulk re-verification email campaign or after changing the token TTL policy. Does not send any emails.', + label: 'Reset Verify Email Address Tokens', + name: 'reset-verify-email-address-token-and-expiration' + }, + { + category: 'Elasticsearch', + dangerous: false, + description: + 'Re-derives first occurrence, last occurrence, and total event count for every stack by running aggregations against raw event documents. Only updates fields that are out-of-date. Accepts an optional date range and organization ID to limit scope. Corrects stale or corrupted stats caused by missed event counter flushes.', + hasDateRange: true, + hasOrganizationId: true, + label: 'Fix Stack Stats', + name: 'fix-stack-stats' + }, + { + category: 'Maintenance', + dangerous: false, + description: + 'Scans every project and removes notification settings for users who no longer belong to the organization. Accepts an optional organization ID to limit scope. Prevents stale user entries from accumulating in project notification settings.', + hasOrganizationId: true, + label: 'Update Project Notification Settings', + name: 'update-project-notification-settings' + } +]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts new file mode 100644 index 0000000000..661e0abad5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts @@ -0,0 +1,9 @@ +import { date, type infer as Infer, object, string } from 'zod'; + +export const RunMaintenanceJobSchema = object({ + confirmText: string().min(1), + organizationId: string().optional(), + utcEnd: date().optional(), + utcStart: date().optional() +}); +export type RunMaintenanceJobFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/schemas.ts index 4ab8b15a95..ddf5f00b0d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/schemas.ts @@ -7,7 +7,9 @@ export { type SignupFormData, SignupSchema } from '$generated/schemas'; // In dev mode, allow addresses like test@localhost (no TLD required) export const LoginSchema = dev ? GeneratedLoginSchema.extend({ - email: string().min(1, 'Email is required').regex(/^[^\s@]+@[^\s@]+$/, 'Please enter a valid email address') + email: string() + .min(1, 'Email is required') + .regex(/^[^\s@]+@[^\s@]+$/, 'Please enter a valid email address') }) : GeneratedLoginSchema; export type LoginFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 244d7443a3..2b407c2e42 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -128,9 +128,9 @@ > () {:else} - + - {/if} + {/if} {#if projectQuery.isSuccess} @@ -140,9 +140,9 @@ > {projectQuery.data.name} {:else} - + - + {/if} @@ -177,14 +177,14 @@ {/each} {:else} - + {#each { length: 5 } as name, index (`${name}-${index}`)} - + - + {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index 9cf98565c3..626daca8a4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -252,7 +252,7 @@ -
+
{#if event.data?.['@error']} {:else if event.data?.['@simple_error']} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte index 43047ca466..548ee7edc1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -47,7 +47,7 @@ let suspendedFilter = $state(undefined); let selectedOrganization = $state(null); let currentPage = $state(1); - const pageSize = 5; + const pageSize = 10; const searchResults = getAdminOrganizationsQuery({ params: { @@ -205,7 +205,7 @@ } }} > - + {#if paidFilter === undefined} All Plans {:else if paidFilter} @@ -232,7 +232,7 @@ } }} > - + {#if suspendedFilter === undefined} All Status {:else if suspendedFilter} @@ -253,7 +253,7 @@ {/if}
-
+
{#if searchResults.isFetching}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-admin-actions-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-admin-actions-dropdown-menu.svelte index 5095c3bfe3..55b0b063df 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-admin-actions-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-admin-actions-dropdown-menu.svelte @@ -4,6 +4,9 @@ import { Button } from '$comp/ui/button'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; + import { runMaintenanceJobMutation } from '$features/admin/api.svelte'; + import RunMaintenanceJobDialog from '$features/admin/components/dialogs/run-maintenance-job-dialog.svelte'; + import { maintenanceActions } from '$features/admin/models'; import { deleteSuspendOrganization, postSetBonusOrganization, postSuspendOrganization } from '$features/organizations/api.svelte'; import SetEventBonusDialog from '$features/organizations/components/dialogs/set-event-bonus-dialog.svelte'; import SuspendOrganizationDialog from '$features/organizations/components/dialogs/suspend-organization-dialog.svelte'; @@ -11,6 +14,7 @@ import Award from '@lucide/svelte/icons/award'; import Pause from '@lucide/svelte/icons/pause'; import Play from '@lucide/svelte/icons/play'; + import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import Shield from '@lucide/svelte/icons/shield'; import { toast } from 'svelte-sonner'; @@ -23,6 +27,7 @@ let toastId = $state(); let openSuspendOrganizationDialog = $state(false); let openSetEventBonusDialog = $state(false); + let openFixStackStatsDialog = $state(false); const markSuspended = postSuspendOrganization({ route: { @@ -41,6 +46,7 @@ }); const setOrganizationBonus = postSetBonusOrganization(); + const runJob = runMaintenanceJobMutation(); async function suspend(params: PostSuspendOrganizationParams) { toast.dismiss(toastId); @@ -83,6 +89,19 @@ function handleSetBonus() { openSetEventBonusDialog = true; } + + async function handleFixStackStats(params: Parameters[0]) { + toast.dismiss(toastId); + + try { + await runJob.mutateAsync(params); + toastId = toast.success('Successfully enqueued the Fix Stack Stats job.'); + } catch (error: unknown) { + const message = error instanceof ProblemDetails ? error.title : 'Please try again.'; + toastId = toast.error(`An error occurred while starting the job: ${message}`); + throw error; + } + } @@ -113,6 +132,11 @@ Set Bonus + + (openFixStackStatsDialog = true)} disabled={runJob.isPending}> + + Fix Stack Stats + @@ -124,3 +148,12 @@ {#if organization && openSetEventBonusDialog} {/if} + +{#if organization && openFixStackStatsDialog} + a.name === 'fix-stack-stats')!} + organizationId={organization.id} + onConfirm={handleFixStackStats} + /> +{/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 index a004279ea7..179cd05a08 100644 --- 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 @@ -11,5 +11,6 @@ {#if code} -{:else}x - +{:else} + - {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index b3f28ff719..adda35925e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -21,8 +21,14 @@ let { children, rowClick, rowHref, table }: Props = $props(); function getHeaderColumnClass(header: Header) { - const classes = [(header.column.columnDef.meta as { class?: string })?.class || '']; - return classes.filter(Boolean).join(' '); + const metaClass = (header.column.columnDef.meta as { class?: string })?.class || ''; + if (!metaClass) { + return ''; + } + if (metaClass.includes('text-right')) { + return [metaClass, 'justify-end'].join(' '); + } + return metaClass; } function getCellClass(cell: Cell) { @@ -30,7 +36,9 @@ return; } - return 'cursor-pointer hover truncate max-w-sm'; + const metaClass = (cell.column.columnDef.meta as { class?: string })?.class ?? ''; + const classes = rowClick ? ['cursor-pointer', 'truncate', 'max-w-sm', metaClass] : ['truncate', 'max-w-sm', metaClass]; + return classes.filter(Boolean).join(' '); } function onCellClick(event: MouseEvent, cell: Cell): void { @@ -63,8 +71,9 @@ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} - - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte index da229d18a9..9888a2b377 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-footer.svelte @@ -20,7 +20,7 @@ let { children, class: className, table }: Props = $props(); -
+
{#if children} {@render children()} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-toolbar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-toolbar.svelte index 4ab2282918..df63108800 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-toolbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-toolbar.svelte @@ -18,7 +18,7 @@ let { children, size = 'icon', table }: Props = $props(); -
+
{#if children} {@render children()} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-range-selector.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-range-selector.svelte index 0836c0d5c5..638781cb89 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-range-selector.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-range-selector.svelte @@ -29,7 +29,7 @@ value={option.label} onclick={() => selectQuick(option)} aria-selected={option.value === value} - class={['cursor-pointer', option.value === value ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible']} + class={['cursor-pointer', option.value === value ? 'bg-secondary text-secondary-foreground' : 'opacity-50 [&_svg]:invisible']} > {option.label} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-ranges.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-ranges.ts index d641ee1730..93f8e0a332 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-ranges.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/date-range-picker/quick-ranges.ts @@ -43,9 +43,9 @@ export const quickRanges: QuickRangeSection[] = [ { label: 'Complete periods', options: [ - { label: 'Previous day', value: '[now-1d/d TO now/d}' }, - { label: 'Previous week', value: '[now-1w/w TO now/w}' }, - { label: 'Previous month', value: '[now-1M/M TO now/M}' } + { label: 'Previous day', value: '[now-1d/d TO now-1d/d]' }, + { label: 'Previous week', value: '[now-1w/w TO now-1w/w]' }, + { label: 'Previous month', value: '[now-1M/M TO now-1M/M]' } ] } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index aaf6c41f03..4bd38cf8b8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -22,6 +22,7 @@ export interface TableConfiguration[]; configureOptions?: (options: TableOptions) => TableOptions; + defaultColumnVisibility?: VisibilityState; paginationStrategy: TPaginationStrategy; queryData?: TData[]; queryMeta?: QueryMeta; @@ -66,7 +67,7 @@ export function getSharedTableOptions{}); + const [columnVisibility, setColumnVisibility] = createPersistedTableState(visibilityKey, configuration.defaultColumnVisibility ?? {}); // Initialize pagination state from parameters const initialPageIndex = isOffsetPaging diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index f53b1bf1c1..856890de12 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -164,7 +164,7 @@ export interface OAuthAccount { provider: string; provider_user_id: string; username: string; - extra_data?: null | object; + extra_data: Record; } export interface PersistentEvent { @@ -223,7 +223,7 @@ export interface PersistentEvent { */ count?: null | number; /** Optional data entries that contain additional information about this event. */ - data?: null | object; + data?: null | Record; /** An optional identifier to be used for referencing this event instance at a later time. */ reference_id?: null | string; } @@ -419,7 +419,7 @@ export interface UserDescription { email_address?: null | string; description?: null | string; /** Extended data entries for this user description. */ - data?: null | object; + data?: null | Record; } export interface ViewCurrentUser { @@ -487,7 +487,7 @@ export interface ViewOrganization { invites: Invite[]; usage_hours: UsageHourInfo[]; usage: UsageInfo[]; - data?: null | object; + data?: null | Record; is_throttled: boolean; is_over_monthly_limit: boolean; is_over_request_limit: boolean; @@ -503,7 +503,7 @@ export interface ViewProject { organization_name: string; name: string; delete_bot_data_enabled: boolean; - data?: null | object; + data?: null | Record; promoted_tabs: string[]; is_configured?: null | boolean; /** @format int64 */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 75f1d790ff..66e65ceef1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -224,7 +224,7 @@ export const OAuthAccountSchema = object({ provider: string().min(1, "Provider is required"), provider_user_id: string().min(1, "Provider user id is required"), username: string().min(1, "Username is required"), - extra_data: record(string(), unknown()).nullable().optional(), + extra_data: record(string(), string()), }); export type OAuthAccountFormData = Infer; @@ -407,7 +407,7 @@ export const UserSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), - organization_ids: array(string()).optional(), + organization_ids: array(string()), password: string().min(1, "Password is required").nullable().optional(), salt: string().min(1, "Salt is required").nullable().optional(), password_reset_token: string() @@ -415,7 +415,7 @@ export const UserSchema = object({ .nullable() .optional(), password_reset_token_expiration: iso.datetime(), - o_auth_accounts: array(lazy(() => OAuthAccountSchema)).optional(), + o_auth_accounts: array(lazy(() => OAuthAccountSchema)), full_name: string().min(1, "Full name is required"), email_address: email(), email_notifications_enabled: boolean(), @@ -603,7 +603,7 @@ export const WebHookSchema = object({ export type WebHookFormData = Infer; export const WorkInProgressResultSchema = object({ - workers: array(string()).optional(), + workers: array(string()), }); export type WorkInProgressResultFormData = Infer< typeof WorkInProgressResultSchema 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 165b399639..df8b0b9dba 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 @@ -21,12 +21,17 @@ import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; import Help from '@lucide/svelte/icons/circle-help'; import CreditCard from '@lucide/svelte/icons/credit-card'; + import Database from '@lucide/svelte/icons/database'; + import DatabaseZap from '@lucide/svelte/icons/database-zap'; import Eye from '@lucide/svelte/icons/eye'; import EyeOff from '@lucide/svelte/icons/eye-off'; import GitHub from '@lucide/svelte/icons/github'; + import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import LogOut from '@lucide/svelte/icons/log-out'; + import Play from '@lucide/svelte/icons/play'; import Plus from '@lucide/svelte/icons/plus'; import Settings from '@lucide/svelte/icons/settings'; + import Wrench from '@lucide/svelte/icons/wrench'; interface Props { gravatar: Gravatar; @@ -196,6 +201,32 @@ + + + + System + + + + + Overview + + + + Elasticsearch + + + + Actions + + + + Migrations + + + {#if isImpersonating} import type { ComponentProps, Snippet } from 'svelte'; + import { resolve } from '$app/paths'; import { page } from '$app/state'; import * as Collapsible from '$comp/ui/collapsible'; import * as Sidebar from '$comp/ui/sidebar'; import { useSidebar } from '$comp/ui/sidebar'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import Settings from '@lucide/svelte/icons/settings-2'; + import Wrench from '@lucide/svelte/icons/wrench'; - import type { NavigationItem, NavigationItemContext } from '../../../routes.svelte'; + import type { NavigationItem } from '../../../routes.svelte'; type Props = ComponentProps & { footer?: Snippet; header?: Snippet; - impersonating?: boolean; routes: NavigationItem[]; }; - let { footer, header, impersonating = false, routes, ...props }: Props = $props(); + let { footer, header, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); - // 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 settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); const settingsIsActive = $derived(settingsRoutes.some((route) => route.href === page.url.pathname)); + const systemRoutes = $derived(routes.filter((route) => route.group === 'System')); + const systemBasePath = resolve('/(app)/system'); + const systemIsActive = $derived(page.url.pathname === systemBasePath || page.url.pathname.startsWith(systemBasePath + '/')); + const sidebar = useSidebar(); function onMenuClick() { @@ -82,6 +83,10 @@ {#snippet child({ props })} + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} {subItem.title} {/snippet} @@ -95,6 +100,47 @@ + + {#if systemRoutes.length > 0} + + + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + System + + + {/snippet} + + + + {#each systemRoutes as subItem (subItem.href)} + + + {#snippet child({ props })} + + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} + {subItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index f69ca9ab0a..eb7e0da037 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -222,7 +222,7 @@ {#if isAuthenticated} - + {#snippet header()} {/snippet} -
-
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 403c2a72d6..b4351ba2d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -282,7 +282,7 @@ {#snippet footerChildren()} -
+
{#if table.getSelectedRowModel().flatRows.length} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index c80f9439dd..2f66c284ee 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -311,7 +311,7 @@ {#snippet footerChildren()} -
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index bf9c758859..30bb166562 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -13,6 +13,7 @@ import { routes as accountRoutes } from './account/routes.svelte'; import { routes as eventRoutes } from './event/routes.svelte'; import { routes as organizationRoutes } from './organization/routes.svelte'; import { routes as projectRoutes } from './project/routes.svelte'; +import { routes as systemRoutes } from './system/routes.svelte'; export function routes(): NavigationItem[] { const items = [ @@ -65,7 +66,8 @@ export function routes(): NavigationItem[] { ...accountRoutes(), ...eventRoutes(), ...organizationRoutes(), - ...projectRoutes() + ...projectRoutes(), + ...systemRoutes() ]; return items; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+layout.svelte new file mode 100644 index 0000000000..6e7616d3ff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+layout.svelte @@ -0,0 +1,42 @@ + + + +

System Administration

+ Manage Exceptionless system maintenance and operations. + + + + + + + {@render children()} + + + {#snippet disabled()} +
+ Access Denied + You must be a global administrator to access this page. +
+ {/snippet} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+page.svelte new file mode 100644 index 0000000000..24225dac27 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/+page.svelte @@ -0,0 +1,9 @@ + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte new file mode 100644 index 0000000000..15d2e9ad5f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte @@ -0,0 +1,163 @@ + + +
+
+

Actions

+ Run maintenance jobs and system operations. +
+ + + +
+
+ {#each categories as category (category)} + + {/each} +
+ +
+ + +
+
+ Action + + {filteredActions.length} + {filteredActions.length === 1 ? 'action' : 'actions'} + {#if destructiveCount > 0} + · {destructiveCount} destructive + {/if} + +
+ {#each filteredActions as action (action.name)} +
+
+
+ {action.label} + {#if activeCategory === 'All'} + + {action.category.toUpperCase()} + + {/if} + {#if action.dangerous} + + + DESTRUCTIVE + + {/if} +
+

{action.description}

+
+ +
+ {/each} + {#if filteredActions.length === 0} +
No actions found.
+ {/if} +
+
+ +{#if selectedAction && openDialog} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+layout.svelte new file mode 100644 index 0000000000..73c5fac8a7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+layout.svelte @@ -0,0 +1,45 @@ + + +
+
+ +
+

Elasticsearch

+ Cluster health, storage metrics, indices, and backup snapshots. +
+
+ + + + + {@render children()} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+page.svelte new file mode 100644 index 0000000000..37c8e8e4ff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/+page.svelte @@ -0,0 +1,9 @@ + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte new file mode 100644 index 0000000000..348e46f1b2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte @@ -0,0 +1,58 @@ + + +{#if snapshotsQuery.isPending} + + + Snapshot Backups + Loading snapshot repositories... + + + {#each [1, 2, 3, 4, 5] as i (i)} + + {/each} + + +{:else if snapshotsQuery.isError} + + +

Failed to load snapshot information. Please try again.

+
+
+{:else if !snapshotsData || snapshotsData.repositories.length === 0} + + + +

No snapshot repositories configured.

+

+ Snapshot repositories are typically named <scope>-hourly (e.g. + prod-hourly). +

+
+
+{:else} +

+ {snapshotsData.snapshots.length} snapshots across {snapshotsData.repositories.length} + {snapshotsData.repositories.length === 1 ? 'repository' : 'repositories'}: {snapshotsData.repositories.join(', ')} +

+ +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/indices/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/indices/+page.svelte new file mode 100644 index 0000000000..db1c4fafc3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/indices/+page.svelte @@ -0,0 +1,66 @@ + + +{#if esQuery.isPending} + + + + + +{:else if esQuery.isError} + + +

Failed to load Elasticsearch info. Please try again.

+
+
+{:else if data} + + {#snippet toolbarChildren()} + +
+ + +
+ + {/snippet} +
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/overview/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/overview/+page.svelte new file mode 100644 index 0000000000..7983b5ef8c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/overview/+page.svelte @@ -0,0 +1,130 @@ + + +{#if esQuery.isPending} +
+ {#each [1, 2, 3, 4] as i (i)} + + + + + + + {/each} +
+{:else if esQuery.isError} + + +

Failed to load Elasticsearch info. Please try again.

+
+
+{:else if data} +
+
+ + + Cluster Status + {#if data.health.status === 0} + + {:else} + + {/if} + + +
+ + {healthLabel(data.health.status)} + +
+

{data.health.cluster_name}

+
+
+ + + + Nodes + + + +
{data.health.number_of_nodes}
+

+ {data.health.number_of_data_nodes} data node{data.health.number_of_data_nodes !== 1 ? 's' : ''} +

+
+
+ + + + Indices + + + +
+

documents

+
+
+ + + + Storage + + + +
+

Total index size

+
+
+
+ +
+
+

Shard Details

+

Allocation and status of Elasticsearch shards across the cluster

+
+ +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/migrations/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/migrations/+page.svelte new file mode 100644 index 0000000000..04dfbac56f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/migrations/+page.svelte @@ -0,0 +1,146 @@ + + +
+
+

Migrations

+ Database migration history — versioned schema changes applied to Elasticsearch. +
+ + + {#if migrationsQuery.isError} + + +

Failed to load migration history. Please try again.

+
+
+ {:else} + {#if migrationsQuery.isPending} +
+ {#each [1, 2, 3, 4] as i (i)} + + + + + + + {/each} +
+ {:else if data} +
+ + + Current Version + + + +
{data.current_version >= 0 ? data.current_version : '—'}
+ Highest completed version +
+
+ + + Failed + + + +
0}>{failedCount}
+ {failedCount > 0 ? 'Requires attention' : 'No failures'} +
+
+ + + Running + + + +
0}>{runningCount}
+ In progress +
+
+ + + Total + + + +
{allStates.length}
+ State records in ES +
+
+
+ {/if} + + + {#snippet toolbarChildren()} + + + + {/snippet} + + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte new file mode 100644 index 0000000000..9b4847a24b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte @@ -0,0 +1,281 @@ + + +
+
+

Overview

+ System-wide statistics and usage trends. +
+ + + {#if statsQuery.isError} + + +

Failed to load system statistics. Please try again.

+
+
+ {:else} +
+ {#each statCards as card (card.label)} + {@const Icon = card.icon} + + + {card.label} + + + + {#if statsQuery.isPending} +
+ {:else} +
+ {#if card.label === 'Total Stacks' && stackStatusBreakdown.length > 0} +

+ {#each stackStatusBreakdown as bucket, index (bucket.status)} + {bucket.status}{index < stackStatusBreakdown.length - 1 ? ', ' : ''} + {/each} +

+ {:else if card.sub} +

{card.sub}

+ {/if} + {/if} +
+
+ {/each} +
+ +
+ + + Events All-Time + Total event volume by month across the full history + + + {#if statsQuery.isPending} + + {:else if eventsAllTimeChartData.length === 0} +

No event history available.

+ {:else} + + d.count))]} + series={eventsAllTimeChartSeries} + props={{ area: { curve: curveLinear } }} + > + {#snippet tooltip()} + formatMonthLabel(v)} /> + {/snippet} + + + {/if} +
+
+ + + + Organization Growth + New organizations created over time + + + {#if statsQuery.isPending} + + {:else if organizationGrowthChartData.length === 0} +

No growth data available.

+ {:else} + + d.count))]} + series={organizationGrowthChartSeries} + props={{ area: { curve: curveLinear } }} + > + {#snippet tooltip()} + formatMonthLabel(v)} /> + {/snippet} + + + {/if} +
+
+
+ + {#if stackTypeStatusBuckets.length > 0 || statsQuery.isPending} + + + Status by Event Type + Breakdown of stack statuses across each event type + + + {#if statsQuery.isPending} + + {:else} + + + {#snippet tooltip()} + + {/snippet} + + + {/if} + + + {/if} + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts new file mode 100644 index 0000000000..e41cff7fd6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts @@ -0,0 +1,40 @@ +import { resolve } from '$app/paths'; +import Database from '@lucide/svelte/icons/database'; +import DatabaseZap from '@lucide/svelte/icons/database-zap'; +import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; +import Play from '@lucide/svelte/icons/play'; + +import type { NavigationItem } from '../../routes.svelte'; + +export function routes(): NavigationItem[] { + return [ + { + group: 'System', + href: resolve('/(app)/system/overview'), + icon: LayoutDashboard, + show: (context) => context.user?.roles?.includes('global') ?? false, + title: 'Overview' + }, + { + group: 'System', + href: resolve('/(app)/system/elasticsearch/overview'), + icon: Database, + show: (context) => context.user?.roles?.includes('global') ?? false, + title: 'Elasticsearch' + }, + { + group: 'System', + href: resolve('/(app)/system/actions'), + icon: Play, + show: (context) => context.user?.roles?.includes('global') ?? false, + title: 'Actions' + }, + { + group: 'System', + href: resolve('/(app)/system/migrations'), + icon: DatabaseZap, + show: (context) => context.user?.roles?.includes('global') ?? false, + title: 'Migrations' + } + ]; +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte index a0a93ab42f..bcd4e4465d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte @@ -98,7 +98,10 @@ {#snippet children(field)} - Password Forgot password? + Password Forgot password? _eventPostQueue; private readonly IQueue _workItemQueue; private readonly AppOptions _appOptions; private readonly BillingManager _billingManager; private readonly BillingPlans _plans; + private readonly IMigrationStateRepository _migrationStateRepository; public AdminController( ExceptionlessElasticConfiguration configuration, IFileStorage fileStorage, IMessagePublisher messagePublisher, IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IStackRepository stackRepository, + IEventRepository eventRepository, + IUserRepository userRepository, IQueue eventPostQueue, IQueue workItemQueue, AppOptions appOptions, BillingManager billingManager, BillingPlans plans, - TimeProvider timeProvider) : base(timeProvider) + IMigrationStateRepository migrationStateRepository, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) : base(timeProvider) { + _logger = loggerFactory.CreateLogger(); _configuration = configuration; _fileStorage = fileStorage; _messagePublisher = messagePublisher; _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + _stackRepository = stackRepository; + _eventRepository = eventRepository; + _userRepository = userRepository; _eventPostQueue = eventPostQueue; _workItemQueue = workItemQueue; _appOptions = appOptions; _billingManager = billingManager; _plans = plans; + _migrationStateRepository = migrationStateRepository; } [HttpGet("settings")] @@ -64,6 +85,60 @@ public ActionResult SettingsRequest() return Ok(_appOptions); } + [HttpGet("stats")] + public async Task> GetStatsAsync() + { + var organizationCountTask = _organizationRepository.CountAsync(q => q + .AggregationsExpression("terms:billing_status date:created_utc~1M")); + + var userCountTask = _userRepository.CountAsync(); + var projectCountTask = _projectRepository.CountAsync(); + + var stackCountTask = _stackRepository.CountAsync(q => q + .AggregationsExpression("terms:status terms:(type terms:status)")); + + var eventCountTask = _eventRepository.CountAsync(q => q + .AggregationsExpression("date:date~1M")); + + await Task.WhenAll(organizationCountTask, userCountTask, projectCountTask, stackCountTask, eventCountTask); + + return Ok(new AdminStatsResponse( + Organizations: await organizationCountTask, + Users: await userCountTask, + Projects: await projectCountTask, + Stacks: await stackCountTask, + Events: await eventCountTask + )); + } + + [HttpGet("migrations")] + public async Task> GetMigrationsAsync() + { + var result = await _migrationStateRepository.GetAllAsync(o => o.SearchAfterPaging().PageLimit(1000)); + var migrationStates = new List(result.Documents.Count); + + while (result.Documents.Count > 0) + { + migrationStates.AddRange(result.Documents); + + if (!await result.NextPageAsync()) + break; + } + + var states = migrationStates + .OrderByDescending(s => s.Version) + .ThenByDescending(s => s.StartedUtc) + .ToArray(); + + int currentVersion = states + .Where(s => s.MigrationType != MigrationType.Repeatable && s.CompletedUtc.HasValue) + .Select(s => s.Version) + .DefaultIfEmpty(-1) + .Max(); + + return Ok(new MigrationsResponse(currentVersion, states)); + } + [HttpGet("echo")] public ActionResult EchoRequest() { @@ -155,39 +230,209 @@ public async Task RequeueAsync(string? path = null, bool archive } [HttpGet("maintenance/{name:minlength(1)}")] - public async Task RunJobAsync(string name) + public async Task RunJobAsync(string name, DateTime? utcStart = null, DateTime? utcEnd = null, string? organizationId = null) { + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + switch (name.ToLowerInvariant()) { + case "fix-stack-stats": + var effectiveUtcStart = utcStart ?? _timeProvider.GetUtcNow().UtcDateTime.AddDays(-90); + + if (utcEnd.HasValue && utcEnd.Value.IsBefore(effectiveUtcStart)) + { + ModelState.AddModelError(nameof(utcEnd), "utcEnd must be greater than or equal to utcStart."); + return ValidationProblem(ModelState); + } + + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = effectiveUtcStart, + UtcEnd = utcEnd, + OrganizationId = organizationId + }); + break; + case "increment-project-configuration-version": + await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { IncrementConfigurationVersion = true }); + break; case "indexes": if (!_appOptions.ElasticsearchOptions.DisableIndexConfiguration) await _configuration.ConfigureIndexesAsync(beginReindexingOutdated: false); break; - case "update-organization-plans": - await _workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { UpgradePlans = true }); + case "normalize-user-email-address": + await _workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { Normalize = true }); break; case "remove-old-organization-usage": await _workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { RemoveOldUsageStats = true }); break; - case "update-project-default-bot-lists": - await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { UpdateDefaultBotList = true, IncrementConfigurationVersion = true }); - break; - case "increment-project-configuration-version": - await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { IncrementConfigurationVersion = true }); - break; case "remove-old-project-usage": await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { RemoveOldUsageStats = true }); break; - case "normalize-user-email-address": - await _workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { Normalize = true }); - break; case "reset-verify-email-address-token-and-expiration": await _workItemQueue.EnqueueAsync(new UserMaintenanceWorkItem { ResetVerifyEmailAddressToken = true }); break; + case "update-organization-plans": + await _workItemQueue.EnqueueAsync(new OrganizationMaintenanceWorkItem { UpgradePlans = true }); + break; + case "update-project-default-bot-lists": + await _workItemQueue.EnqueueAsync(new ProjectMaintenanceWorkItem { UpdateDefaultBotList = true, IncrementConfigurationVersion = true }); + break; + case "update-project-notification-settings": + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem + { + OrganizationId = organizationId + }); + break; default: return NotFound(); } return Ok(); } + + [HttpGet("elasticsearch")] + public async Task> GetElasticsearchInfoAsync() + { + var client = _configuration.Client; + var healthTask = client.Cluster.HealthAsync(); + var statsTask = client.Cluster.StatsAsync(); + var catIndicesTask = client.Cat.IndicesAsync(r => r.Bytes(Elasticsearch.Net.Bytes.B)); + var catShardsTask = client.Cat.ShardsAsync(); + await Task.WhenAll(healthTask, statsTask, catIndicesTask, catShardsTask); + + var healthResponse = await healthTask; + var statsResponse = await statsTask; + var catIndicesResponse = await catIndicesTask; + var catShardsResponse = await catShardsTask; + + if (!healthResponse.IsValid || !statsResponse.IsValid || !catIndicesResponse.IsValid || !catShardsResponse.IsValid) + return Problem(title: "Elasticsearch cluster information is unavailable."); + + // Count unassigned shards per index + var unassignedByIndex = (catShardsResponse.Records ?? []) + .Where(s => string.Equals(s.State, "UNASSIGNED", StringComparison.OrdinalIgnoreCase)) + .GroupBy(s => s.Index ?? String.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + var indexDetails = (catIndicesResponse.Records ?? []) + .OrderByDescending(i => long.TryParse(i.StoreSize, out var s) ? s : 0) + .Select(i => new ElasticsearchIndexDetailResponse( + Index: i.Index, + Health: i.Health, + Status: i.Status, + Primary: int.TryParse(i.Primary, out var p) ? p : 0, + Replica: int.TryParse(i.Replica, out var r) ? r : 0, + DocsCount: long.TryParse(i.DocsCount, out var dc) ? dc : 0, + StoreSizeInBytes: long.TryParse(i.StoreSize, out var ss) ? ss : 0, + UnassignedShards: unassignedByIndex.GetValueOrDefault(i.Index ?? String.Empty, 0) + )) + .ToArray(); + + return Ok(new ElasticsearchInfoResponse( + Health: new ElasticsearchHealthResponse( + Status: (int)healthResponse.Status, + ClusterName: healthResponse.ClusterName, + NumberOfNodes: healthResponse.NumberOfNodes, + NumberOfDataNodes: healthResponse.NumberOfDataNodes, + ActiveShards: healthResponse.ActiveShards, + RelocatingShards: healthResponse.RelocatingShards, + UnassignedShards: healthResponse.UnassignedShards, + ActivePrimaryShards: healthResponse.ActivePrimaryShards + ), + Indices: new ElasticsearchIndicesResponse( + Count: statsResponse.Indices.Count, + DocsCount: statsResponse.Indices.Documents.Count, + StoreSizeInBytes: statsResponse.Indices.Store.SizeInBytes + ), + IndexDetails: indexDetails + )); + } + + [HttpGet("elasticsearch/snapshots")] + public async Task> GetElasticsearchSnapshotsAsync() + { + var client = _configuration.Client; + try + { + var repositoryResponse = await client.Cat.RepositoriesAsync(); + if (!repositoryResponse.IsValid) + return Problem(title: "Snapshot repository information is unavailable."); + + if (!(repositoryResponse.Records?.Any() ?? false)) + return Ok(new ElasticsearchSnapshotsResponse([], [])); + + var repositoryNames = repositoryResponse.Records + .Where(r => !String.IsNullOrEmpty(r.Id)) + .Select(r => r.Id!) + .ToArray(); + + var snapshotTasks = repositoryNames + .Select(async repositoryName => + { + var snapshotResponse = await client.Cat.SnapshotsAsync(r => r.RepositoryName(repositoryName)); + if (!snapshotResponse.IsValid) + return ( + RepositoryName: repositoryName, + Snapshots: Array.Empty(), + Error: $"Unable to retrieve snapshots for repository: {repositoryName}." + ); + + var snapshotRecords = snapshotResponse.Records?.ToArray() ?? []; + return ( + RepositoryName: repositoryName, + Snapshots: snapshotRecords.Select(s => new ElasticsearchSnapshotResponse( + Repository: repositoryName, + Name: s.Id ?? String.Empty, + Status: s.Status ?? String.Empty, + StartTime: s.StartEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.StartEpoch).UtcDateTime : null, + EndTime: s.EndEpoch > 0 ? DateTimeOffset.FromUnixTimeSeconds(s.EndEpoch).UtcDateTime : null, + Duration: s.Duration?.ToString() ?? String.Empty, + IndicesCount: s.Indices, + SuccessfulShards: s.SuccessfulShards, + FailedShards: s.FailedShards, + TotalShards: s.TotalShards + )).ToArray(), + Error: (string?)null + ); + }) + .ToArray(); + + var snapshotResults = await Task.WhenAll(snapshotTasks); + + var failedSnapshotResults = snapshotResults + .Where(r => r.Error is not null) + .ToArray(); + + if (failedSnapshotResults.Length is > 0) + { + _logger.LogWarning("Unable to retrieve snapshots for one or more repositories: {Repositories}", + String.Join(", ", failedSnapshotResults.Select(r => r.RepositoryName))); + } + + var successfulSnapshotResults = snapshotResults + .Where(r => r.Error is null) + .ToArray(); + + if (successfulSnapshotResults.Length is 0) + return Problem(title: "Unable to retrieve snapshot information."); + + var snapshots = successfulSnapshotResults + .SelectMany(r => r.Snapshots) + .OrderByDescending(s => s.StartTime) + .ToArray(); + + var successfulRepositoryNames = successfulSnapshotResults + .Select(r => r.RepositoryName) + .ToArray(); + + return Ok(new ElasticsearchSnapshotsResponse(successfulRepositoryNames, snapshots)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to retrieve snapshot information"); + return Problem(title: "Unable to retrieve snapshot information."); + } + } } + diff --git a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs index ab1f535120..c78f38c620 100644 --- a/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/ReadOnlyRepositoryApiController.cs @@ -1,25 +1,28 @@ -using AutoMapper; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; +using Exceptionless.Web.Mapping; using Foundatio.Repositories; using Foundatio.Repositories.Models; using Microsoft.AspNetCore.Mvc; namespace Exceptionless.Web.Controllers; -public abstract class ReadOnlyRepositoryApiController : ExceptionlessApiController where TRepository : ISearchableReadOnlyRepository where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new() +public abstract class ReadOnlyRepositoryApiController : ExceptionlessApiController + where TRepository : ISearchableReadOnlyRepository + where TModel : class, IIdentity, new() + where TViewModel : class, IIdentity, new() { protected readonly TRepository _repository; protected static readonly bool _isOwnedByOrganization = typeof(IOwnedByOrganization).IsAssignableFrom(typeof(TModel)); protected static readonly bool _isOrganization = typeof(TModel) == typeof(Organization); protected static readonly bool _supportsSoftDeletes = typeof(ISupportSoftDeletes).IsAssignableFrom(typeof(TModel)); protected static readonly IReadOnlyCollection EmptyModels = new List(0).AsReadOnly(); - protected readonly IMapper _mapper; + protected readonly ApiMapper _mapper; protected readonly IAppQueryValidator _validator; protected readonly ILogger _logger; - public ReadOnlyRepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) + public ReadOnlyRepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) { _repository = repository; _mapper = mapper; @@ -38,9 +41,21 @@ protected async Task> GetByIdImplAsync(string id) protected virtual async Task> OkModelAsync(TModel model) { - return Ok(await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Ok(viewModel); } + /// + /// Maps a domain model to a view model. Override in derived controllers. + /// + protected abstract TViewModel MapToViewModel(TModel model); + + /// + /// Maps a collection of domain models to view models. Override in derived controllers. + /// + protected abstract List MapToViewModels(IEnumerable models); + protected virtual async Task GetModelAsync(string id, bool useCache = true) { if (String.IsNullOrEmpty(id)) @@ -69,24 +84,6 @@ protected virtual async Task> GetModelsAsync(string[ return models; } - protected async Task MapAsync(object source, bool isResult = false) - { - var destination = _mapper.Map(source); - if (isResult) - await AfterResultMapAsync(new List(new[] { destination })); - - return destination; - } - - protected async Task> MapCollectionAsync(object source, bool isResult = false) - { - var destination = _mapper.Map>(source); - if (isResult) - await AfterResultMapAsync(destination); - - return destination; - } - protected virtual Task AfterResultMapAsync(ICollection models) { foreach (var model in models.OfType()) diff --git a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs b/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs index 774e403ad6..c7820c6c9d 100644 --- a/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/RepositoryApiController.cs @@ -1,8 +1,8 @@ -using AutoMapper; -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Utility; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -10,17 +10,27 @@ namespace Exceptionless.Web.Controllers; -public abstract class RepositoryApiController : ReadOnlyRepositoryApiController where TRepository : ISearchableRepository where TModel : class, IIdentity, new() where TViewModel : class, IIdentity, new() where TNewModel : class, new() where TUpdateModel : class, new() +public abstract class RepositoryApiController : ReadOnlyRepositoryApiController + where TRepository : ISearchableRepository + where TModel : class, IIdentity, new() + where TViewModel : class, IIdentity, new() + where TNewModel : class, new() + where TUpdateModel : class, new() { - public RepositoryApiController(TRepository repository, IMapper mapper, IAppQueryValidator validator, + public RepositoryApiController(TRepository repository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) { } + /// + /// Maps a new model (from API input) to a domain model. Override in derived controllers. + /// + protected abstract TModel MapToModel(TNewModel newModel); + protected async Task> PostImplAsync(TNewModel value) { if (value is null) return BadRequest(); - var mapped = await MapAsync(value); + var mapped = MapToModel(value); // if no organization id is specified, default to the user's 1st associated org. if (!_isOrganization && mapped is IOwnedByOrganization orgModel && String.IsNullOrEmpty(orgModel.OrganizationId) && GetAssociatedOrganizationIds().Count > 0) orgModel.OrganizationId = Request.GetDefaultOrganizationId()!; @@ -32,7 +42,9 @@ protected async Task> PostImplAsync(TNewModel value) var model = await AddModelAsync(mapped); await AfterAddAsync(model); - return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Created(new Uri(GetEntityLink(model.Id) ?? throw new InvalidOperationException()), viewModel); } protected async Task> UpdateModelAsync(string id, Func> modelUpdateFunc) @@ -50,7 +62,9 @@ protected async Task> UpdateModelAsync(string id, Func< if (typeof(TViewModel) == typeof(TModel)) return Ok(model); - return Ok(await MapAsync(model, true)); + var viewModel = MapToViewModel(model); + await AfterResultMapAsync([viewModel]); + return Ok(viewModel); } protected async Task> UpdateModelsAsync(string[] ids, Func> modelUpdateFunc) @@ -70,7 +84,9 @@ protected async Task> UpdateModelsAsync(string[] ids, F if (typeof(TViewModel) == typeof(TModel)) return Ok(models); - return Ok(await MapAsync(models, true)); + var viewModels = MapToViewModels(models); + await AfterResultMapAsync(viewModels); + return Ok(viewModels); } protected virtual string? GetEntityLink(string id) diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index f62b1e3275..e19a21f68f 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -1,5 +1,4 @@ using System.Text; -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -16,6 +15,7 @@ using Exceptionless.Core.Services; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Exceptionless.Web.Utility.OpenApi; @@ -60,7 +60,7 @@ public EventController(IEventRepository repository, FormattingPluginManager formattingPluginManager, ICacheClient cacheClient, JsonSerializerSettings jsonSerializerSettings, - IMapper mapper, + ApiMapper mapper, PersistentEventQueryValidator validator, AppOptions appOptions, TimeProvider timeProvider, @@ -82,6 +82,11 @@ ILoggerFactory loggerFactory DefaultDateField = EventIndex.Alias.Date; } + // Mapping implementations - PersistentEvent uses itself as view model (no mapping needed) + protected override PersistentEvent MapToModel(PersistentEvent newModel) => newModel; + protected override PersistentEvent MapToViewModel(PersistentEvent model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Count /// @@ -337,7 +342,8 @@ private async Task>> GetInternalAsync( .SystemFilter(systemFilter) .FilterExpression(filter) .EnforceEventStackFilter() - .AggregationsExpression($"terms:(stack_id~{GetSkip(resolvedPage + 1, limit) + 1} {stackAggregations})")); + .AggregationsExpression($"terms:(stack_id~{GetSkip(resolvedPage + 1, limit) + 1} {stackAggregations})") + ); var stackTerms = countResponse.Aggregations.Terms("terms_stack_id"); if (stackTerms is null || stackTerms.Buckets.Count == 0) @@ -410,7 +416,8 @@ private Task> GetEventsInternalAsync(AppFilter sf, .FilterExpression(filter) .EnforceEventStackFilter() .SortExpression(sort) - .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), + .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field) + .Index(ti.Range.UtcStart, ti.Range.UtcEnd), o => page.HasValue ? o.PageNumber(page).PageLimit(limit) : o.SearchBeforeToken(before).SearchAfterToken(after).PageLimit(limit)); @@ -811,9 +818,14 @@ public async Task SetUserDescriptionAsync(string referenceId, Use // Set the project for the configuration response filter. Request.SetProject(project); - var eventUserDescription = await MapAsync(description); - eventUserDescription.ProjectId = project.Id; - eventUserDescription.ReferenceId = referenceId; + var eventUserDescription = new EventUserDescription + { + ProjectId = project.Id, + ReferenceId = referenceId, + EmailAddress = description.EmailAddress, + Description = description.Description, + Data = description.Data + }; await _eventUserDescriptionQueue.EnqueueAsync(eventUserDescription); return StatusCode(StatusCodes.Status202Accepted); diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index a8c8d48c7e..905451ad6c 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; @@ -12,6 +11,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Services; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Caching; @@ -55,7 +55,7 @@ public OrganizationController( UsageService usageService, IMailer mailer, IMessagePublisher messagePublisher, - IMapper mapper, + ApiMapper mapper, IAppQueryValidator validator, AppOptions options, TimeProvider timeProvider, @@ -74,6 +74,11 @@ public OrganizationController( _options = options; } + // Mapping implementations + protected override Organization MapToModel(NewOrganization newModel) => _mapper.MapToOrganization(newModel); + protected override ViewOrganization MapToViewModel(Organization model) => _mapper.MapToViewOrganization(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewOrganizations(models); + /// /// Get all /// @@ -82,10 +87,11 @@ public OrganizationController( public async Task>> GetAllAsync(string? mode = null) { var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray()); - var viewOrganizations = await MapCollectionAsync(organizations, true); + var viewOrganizations = MapToViewModels(organizations); + await AfterResultMapAsync(viewOrganizations); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return Ok(await PopulateOrganizationStatsAsync(viewOrganizations.ToList())); + return Ok(await PopulateOrganizationStatsAsync(viewOrganizations)); return Ok(viewOrganizations); } @@ -98,7 +104,8 @@ public async Task>> GetForAdm page = GetPage(page); limit = GetLimit(limit); var organizations = await _repository.GetByCriteriaAsync(criteria, o => o.PageNumber(page).PageLimit(limit), sort, paid, suspended); - var viewOrganizations = (await MapCollectionAsync(organizations.Documents, true)).ToList(); + var viewOrganizations = MapToViewModels(organizations.Documents); + await AfterResultMapAsync(viewOrganizations); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return OkWithResourceLinks(await PopulateOrganizationStatsAsync(viewOrganizations), organizations.HasMore, page, organizations.Total); @@ -127,7 +134,9 @@ public async Task> GetAsync(string id, string? mo if (organization is null) return NotFound(); - var viewOrganization = await MapAsync(organization, true); + var viewOrganization = MapToViewModel(organization); + await AfterResultMapAsync([viewOrganization]); + if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return Ok(await PopulateOrganizationStatsAsync(viewOrganization)); @@ -306,7 +315,7 @@ public async Task>> GetInvoic var client = new StripeClient(_options.StripeOptions.StripeApiKey); var invoiceService = new InvoiceService(client); var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = limit + 1, EndingBefore = before, StartingAfter = after }; - var invoices = (await MapCollectionAsync(await invoiceService.ListAsync(invoiceOptions), true)).ToList(); + var invoices = _mapper.MapToInvoiceGridModels(await invoiceService.ListAsync(invoiceOptions)); return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit); } @@ -581,17 +590,11 @@ public async Task RemoveUserAsync(string id, string email) if (!user.OrganizationIds.Contains(organization.Id)) return BadRequest(); - if ((await _userRepository.GetByOrganizationIdAsync(organization.Id)).Total == 1) + var organizationUsers = await _userRepository.GetByOrganizationIdAsync(organization.Id); + if (organizationUsers.Total is 1) return BadRequest("An organization must contain at least one user."); - var projects = (await _projectRepository.GetByOrganizationIdAsync(organization.Id)).Documents.Where(p => p.NotificationSettings.ContainsKey(user.Id)).ToList(); - if (projects.Count > 0) - { - foreach (var project in projects) - project.NotificationSettings.Remove(user.Id); - - await _projectRepository.SaveAsync(projects); - } + await _organizationService.CleanupProjectNotificationSettingsAsync(organization, [user.Id]); user.OrganizationIds.Remove(organization.Id); await _userRepository.SaveAsync(user, o => o.Cache()); @@ -766,7 +769,8 @@ protected override async Task CanDeleteAsync(Organization valu if (!String.IsNullOrEmpty(value.StripeCustomerId) && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) return PermissionResult.DenyWithMessage("An organization cannot be deleted if it has a subscription.", value.Id); - var projects = (await _projectRepository.GetByOrganizationIdAsync(value.Id)).Documents.ToList(); + var organizationProjects = await _projectRepository.GetByOrganizationIdAsync(value.Id); + var projects = organizationProjects.Documents.ToList(); if (!User.IsInRole(AuthorizationRoles.GlobalAdmin) && projects.Count > 0) return PermissionResult.DenyWithMessage("An organization cannot be deleted if it contains any projects.", value.Id); diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index cf3b6d6c86..04700c5e43 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; @@ -10,6 +9,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Services; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Jobs; @@ -46,7 +46,7 @@ public ProjectController( IQueue workItemQueue, BillingManager billingManager, SlackService slackService, - IMapper mapper, + ApiMapper mapper, IAppQueryValidator validator, AppOptions options, UsageService usageService, @@ -66,6 +66,11 @@ ILoggerFactory loggerFactory _usageService = usageService; } + // Mapping implementations + protected override Project MapToModel(NewProject newModel) => _mapper.MapToProject(newModel); + protected override ViewProject MapToViewModel(Project model) => _mapper.MapToViewProject(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewProjects(models); + /// /// Get all /// @@ -87,10 +92,11 @@ public async Task>> GetAllAsync(st var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = await MapCollectionAsync(projects.Documents, true); + var viewProjects = MapToViewModels(projects.Documents); + await AfterResultMapAsync(viewProjects); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) - return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects.ToList()), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); + return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); return OkWithResourceLinks(viewProjects, projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); } @@ -117,7 +123,8 @@ public async Task>> GetByOrganizat limit = GetLimit(limit, 1000); var sf = new AppFilter(organization); var projects = await _repository.GetByFilterAsync(sf, filter, sort, o => o.PageNumber(page).PageLimit(limit)); - var viewProjects = (await MapCollectionAsync(projects.Documents, true)).ToList(); + var viewProjects = MapToViewModels(projects.Documents); + await AfterResultMapAsync(viewProjects); if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return OkWithResourceLinks(await PopulateProjectStatsAsync(viewProjects), projects.HasMore && !NextPageExceedsSkipLimit(page, limit), page, projects.Total); @@ -139,7 +146,9 @@ public async Task> GetAsync(string id, string? mode = if (project is null) return NotFound(); - var viewProject = await MapAsync(project, true); + var viewProject = MapToViewModel(project); + await AfterResultMapAsync([viewProject]); + if (!String.IsNullOrEmpty(mode) && String.Equals(mode, "stats", StringComparison.OrdinalIgnoreCase)) return Ok(await PopulateProjectStatsAsync(viewProject)); @@ -395,7 +404,7 @@ public async Task> GetIntegrationNotification [HttpPost("~/" + API_PREFIX + "/users/{userId:objectid}/projects/{id:objectid}/notifications")] [Consumes("application/json")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task SetNotificationSettingsAsync(string id, string userId, NotificationSettings settings) + public async Task SetNotificationSettingsAsync(string id, string userId, NotificationSettings? settings) { var project = await GetModelAsync(id, false); if (project is null) diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs index 4d2493e90a..eef9d13441 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -13,6 +12,7 @@ using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Utility; using Exceptionless.DateTimeExtensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Foundatio.Caching; using Foundatio.Queues; @@ -52,7 +52,7 @@ public StackController( ICacheClient cacheClient, FormattingPluginManager formattingPluginManager, SemanticVersionParser semanticVersionParser, - IMapper mapper, + ApiMapper mapper, StackQueryValidator validator, AppOptions options, TimeProvider timeProvider, @@ -75,6 +75,11 @@ ILoggerFactory loggerFactory DefaultDateField = StackIndex.Alias.LastOccurrence; } + // Mapping implementations - Stack uses itself as view model (no mapping needed) + protected override Stack MapToModel(Stack newModel) => newModel; + protected override Stack MapToViewModel(Stack model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Get by id /// @@ -97,10 +102,11 @@ public async Task> GetAsync(string id, string? offset = null ///
/// A comma-delimited list of stack identifiers. /// A version number that the stack was fixed in. + /// The stacks were marked as fixed. /// One or more stacks could not be found. [HttpPost("{ids:objectids}/mark-fixed")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task MarkFixedAsync(string ids, string? version = null) { SemanticVersion? semanticVersion = null; @@ -154,10 +160,11 @@ public async Task MarkFixedAsync(JsonDocument data) /// /// A comma-delimited list of stack identifiers. /// A time that the stack should be snoozed until. + /// The stacks were snoozed. /// One or more stacks could not be found. [HttpPost("{ids:objectids}/mark-snoozed")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task SnoozeAsync(string ids, DateTime snoozeUntilUtc) { if (snoozeUntilUtc < _timeProvider.GetUtcNow().UtcDateTime.AddMinutes(5)) diff --git a/src/Exceptionless.Web/Controllers/TokenController.cs b/src/Exceptionless.Web/Controllers/TokenController.cs index d849c35cf1..d5bc931d0d 100644 --- a/src/Exceptionless.Web/Controllers/TokenController.cs +++ b/src/Exceptionless.Web/Controllers/TokenController.cs @@ -1,10 +1,10 @@ -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Core.Repositories; using Exceptionless.Web.Controllers; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Repositories; @@ -23,7 +23,7 @@ public class TokenController : RepositoryApiController _mapper.MapToToken(newModel); + protected override ViewToken MapToViewModel(Token model) => _mapper.MapToViewToken(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewTokens(models); + /// /// Get by organization /// @@ -50,7 +55,8 @@ public async Task>> GetByOrganizatio page = GetPage(page); limit = GetLimit(limit); var tokens = await _repository.GetByTypeAndOrganizationIdAsync(TokenType.Access, organizationId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = (await MapCollectionAsync(tokens.Documents, true)).ToList(); + var viewTokens = MapToViewModels(tokens.Documents); + await AfterResultMapAsync(viewTokens); return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); } @@ -74,7 +80,8 @@ public async Task>> GetByProjectAsyn page = GetPage(page); limit = GetLimit(limit); var tokens = await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageNumber(page).PageLimit(limit)); - var viewTokens = (await MapCollectionAsync(tokens.Documents, true)).ToList(); + var viewTokens = MapToViewModels(tokens.Documents); + await AfterResultMapAsync(viewTokens); return OkWithResourceLinks(viewTokens, tokens.HasMore && !NextPageExceedsSkipLimit(page, limit), page, tokens.Total); } @@ -93,7 +100,8 @@ public async Task> GetDefaultTokenAsync(string projectId if (project is null) return NotFound(); - var token = (await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageLimit(1))).Documents.FirstOrDefault(); + var defaultTokenResults = await _repository.GetByTypeAndProjectIdAsync(TokenType.Access, projectId, o => o.PageLimit(1)); + var token = defaultTokenResults.Documents.FirstOrDefault(); if (token is not null) return await OkModelAsync(token); diff --git a/src/Exceptionless.Web/Controllers/UserController.cs b/src/Exceptionless.Web/Controllers/UserController.cs index e52dcba5eb..a06bcdc17d 100644 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ b/src/Exceptionless.Web/Controllers/UserController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -8,6 +7,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Caching; @@ -31,7 +31,7 @@ public class UserController : RepositoryApiController throw new NotSupportedException("Users cannot be created via API mapping."); + protected override ViewUser MapToViewModel(User model) => _mapper.MapToViewUser(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewUsers(models); + /// /// Get current user /// @@ -90,7 +95,8 @@ public async Task>> GetByOrganization return Ok(Enumerable.Empty()); var results = await _repository.GetByOrganizationIdAsync(organizationId, o => o.PageLimit(MAXIMUM_SKIP)); - var users = (await MapCollectionAsync(results.Documents, true)).ToList(); + var users = MapToViewModels(results.Documents); + await AfterResultMapAsync(users); if (!Request.IsGlobalAdmin()) users.ForEach(u => u.Roles.Remove(AuthorizationRoles.GlobalAdmin)); diff --git a/src/Exceptionless.Web/Controllers/WebHookController.cs b/src/Exceptionless.Web/Controllers/WebHookController.cs index 55bd42870d..f80d49b0a4 100644 --- a/src/Exceptionless.Web/Controllers/WebHookController.cs +++ b/src/Exceptionless.Web/Controllers/WebHookController.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using AutoMapper; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -8,6 +7,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Web.Controllers; using Exceptionless.Web.Extensions; +using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; @@ -22,13 +22,18 @@ public class WebHookController : RepositoryApiController _mapper.MapToWebHook(newModel); + protected override WebHook MapToViewModel(WebHook model) => model; + protected override List MapToViewModels(IEnumerable models) => models.ToList(); + /// /// Get by project /// diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 3571be3493..68da397f09 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -15,11 +15,12 @@ - + + - + @@ -28,7 +29,7 @@ - + diff --git a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs index fb5708ab7a..662aabff39 100644 --- a/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs +++ b/src/Exceptionless.Web/Hubs/WebSocketConnectionManager.cs @@ -1,10 +1,7 @@ using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; -using System.Text.Json; using Exceptionless.Core; -using Exceptionless.Core.Serialization; -using Exceptionless.Web.Utility; using Foundatio.Serializer; namespace Exceptionless.Web.Hubs; diff --git a/src/Exceptionless.Web/Mapping/ApiMapper.cs b/src/Exceptionless.Web/Mapping/ApiMapper.cs new file mode 100644 index 0000000000..450c22a472 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/ApiMapper.cs @@ -0,0 +1,76 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Mapping; + +/// +/// Facade for all API type mappers. Delegates to type-specific mappers. +/// Uses compile-time source generation for type-safe, performant mappings. +/// +public class ApiMapper +{ + private readonly OrganizationMapper _organizationMapper; + private readonly ProjectMapper _projectMapper; + private readonly TokenMapper _tokenMapper; + private readonly UserMapper _userMapper; + private readonly WebHookMapper _webHookMapper; + private readonly InvoiceMapper _invoiceMapper; + + public ApiMapper(TimeProvider timeProvider) + { + _organizationMapper = new OrganizationMapper(timeProvider); + _projectMapper = new ProjectMapper(); + _tokenMapper = new TokenMapper(); + _userMapper = new UserMapper(); + _webHookMapper = new WebHookMapper(); + _invoiceMapper = new InvoiceMapper(); + } + + // Organization mappings + public Organization MapToOrganization(NewOrganization source) + => _organizationMapper.MapToOrganization(source); + + public ViewOrganization MapToViewOrganization(Organization source) + => _organizationMapper.MapToViewOrganization(source); + + public List MapToViewOrganizations(IEnumerable source) + => _organizationMapper.MapToViewOrganizations(source); + + // Project mappings + public Project MapToProject(NewProject source) + => _projectMapper.MapToProject(source); + + public ViewProject MapToViewProject(Project source) + => _projectMapper.MapToViewProject(source); + + public List MapToViewProjects(IEnumerable source) + => _projectMapper.MapToViewProjects(source); + + // Token mappings + public Token MapToToken(NewToken source) + => _tokenMapper.MapToToken(source); + + public ViewToken MapToViewToken(Token source) + => _tokenMapper.MapToViewToken(source); + + public List MapToViewTokens(IEnumerable source) + => _tokenMapper.MapToViewTokens(source); + + // User mappings + public ViewUser MapToViewUser(User source) + => _userMapper.MapToViewUser(source); + + public List MapToViewUsers(IEnumerable source) + => _userMapper.MapToViewUsers(source); + + // WebHook mappings + public WebHook MapToWebHook(NewWebHook source) + => _webHookMapper.MapToWebHook(source); + + // Invoice mappings + public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) + => _invoiceMapper.MapToInvoiceGridModel(source); + + public List MapToInvoiceGridModels(IEnumerable source) + => _invoiceMapper.MapToInvoiceGridModels(source); +} diff --git a/src/Exceptionless.Web/Mapping/InvoiceMapper.cs b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs new file mode 100644 index 0000000000..82d278779f --- /dev/null +++ b/src/Exceptionless.Web/Mapping/InvoiceMapper.cs @@ -0,0 +1,21 @@ +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapper for Stripe Invoice to InvoiceGridModel. +/// Note: Created manually due to required properties and custom transformations. +/// +public class InvoiceMapper +{ + public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) + => new() + { + Id = source.Id[3..], // Strip "in_" prefix + Date = source.Created, + Paid = source.Paid + }; + + public List MapToInvoiceGridModels(IEnumerable source) + => source.Select(MapToInvoiceGridModel).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/OrganizationMapper.cs b/src/Exceptionless.Web/Mapping/OrganizationMapper.cs new file mode 100644 index 0000000000..09ace1eea4 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/OrganizationMapper.cs @@ -0,0 +1,41 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Organization types. +/// Computed/populated-later properties are explicitly ignored via MapperIgnoreTarget. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class OrganizationMapper +{ + private readonly TimeProvider _timeProvider; + + public OrganizationMapper(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public partial Organization MapToOrganization(NewOrganization source); + + [MapperIgnoreTarget(nameof(ViewOrganization.IsOverMonthlyLimit))] + [MapperIgnoreTarget(nameof(ViewOrganization.IsOverRequestLimit))] + [MapperIgnoreTarget(nameof(ViewOrganization.IsThrottled))] + [MapperIgnoreTarget(nameof(ViewOrganization.ProjectCount))] + [MapperIgnoreTarget(nameof(ViewOrganization.StackCount))] + [MapperIgnoreTarget(nameof(ViewOrganization.EventCount))] + private partial ViewOrganization MapToViewOrganizationCore(Organization source); + + public ViewOrganization MapToViewOrganization(Organization source) + { + var result = MapToViewOrganizationCore(source); + result.IsOverMonthlyLimit = source.IsOverMonthlyLimit(_timeProvider); + return result; + } + + public List MapToViewOrganizations(IEnumerable source) + => source.Select(MapToViewOrganization).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/ProjectMapper.cs b/src/Exceptionless.Web/Mapping/ProjectMapper.cs new file mode 100644 index 0000000000..34b8e48b5e --- /dev/null +++ b/src/Exceptionless.Web/Mapping/ProjectMapper.cs @@ -0,0 +1,32 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Project types. +/// Computed/populated-later properties are explicitly ignored via MapperIgnoreTarget. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class ProjectMapper +{ + public partial Project MapToProject(NewProject source); + + [MapperIgnoreTarget(nameof(ViewProject.HasSlackIntegration))] + [MapperIgnoreTarget(nameof(ViewProject.HasPremiumFeatures))] + [MapperIgnoreTarget(nameof(ViewProject.OrganizationName))] + [MapperIgnoreTarget(nameof(ViewProject.StackCount))] + [MapperIgnoreTarget(nameof(ViewProject.EventCount))] + private partial ViewProject MapToViewProjectCore(Project source); + + public ViewProject MapToViewProject(Project source) + { + var result = MapToViewProjectCore(source); + result.HasSlackIntegration = source.Data is not null && source.Data.ContainsKey(Project.KnownDataKeys.SlackToken); + return result; + } + + public List MapToViewProjects(IEnumerable source) + => source.Select(MapToViewProject).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/TokenMapper.cs b/src/Exceptionless.Web/Mapping/TokenMapper.cs new file mode 100644 index 0000000000..156d55449f --- /dev/null +++ b/src/Exceptionless.Web/Mapping/TokenMapper.cs @@ -0,0 +1,19 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for Token types. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class TokenMapper +{ + [MapperIgnoreTarget(nameof(Token.Type))] + public partial Token MapToToken(NewToken source); + + public partial ViewToken MapToViewToken(Token source); + + public partial List MapToViewTokens(IEnumerable source); +} diff --git a/src/Exceptionless.Web/Mapping/UserMapper.cs b/src/Exceptionless.Web/Mapping/UserMapper.cs new file mode 100644 index 0000000000..272121897a --- /dev/null +++ b/src/Exceptionless.Web/Mapping/UserMapper.cs @@ -0,0 +1,35 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for User types. +/// Uses RequiredMappingStrategy.Target so new ViewUser properties +/// produce compile warnings unless explicitly mapped or ignored. +/// Deep-copies collection properties (Roles, OrganizationIds) to prevent +/// controller-side mutations from affecting the source User model. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] +public partial class UserMapper +{ + [MapperIgnoreTarget(nameof(ViewUser.IsInvite))] + [MapperIgnoreTarget(nameof(ViewUser.Roles))] + [MapperIgnoreTarget(nameof(ViewUser.OrganizationIds))] + private partial ViewUser MapToViewUserCore(User source); + + public ViewUser MapToViewUser(User source) + { + var result = MapToViewUserCore(source); + result = result with + { + Roles = new HashSet(source.Roles), + OrganizationIds = new HashSet(source.OrganizationIds) + }; + return result; + } + + public List MapToViewUsers(IEnumerable source) + => source.Select(MapToViewUser).ToList(); +} diff --git a/src/Exceptionless.Web/Mapping/WebHookMapper.cs b/src/Exceptionless.Web/Mapping/WebHookMapper.cs new file mode 100644 index 0000000000..a9dbb1bf51 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/WebHookMapper.cs @@ -0,0 +1,14 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +/// +/// Mapperly-based mapper for WebHook types. +/// +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class WebHookMapper +{ + public partial WebHook MapToWebHook(NewWebHook source); +} diff --git a/src/Exceptionless.Web/Models/Admin/AdminStatsResponse.cs b/src/Exceptionless.Web/Models/Admin/AdminStatsResponse.cs new file mode 100644 index 0000000000..4cf42cc1f4 --- /dev/null +++ b/src/Exceptionless.Web/Models/Admin/AdminStatsResponse.cs @@ -0,0 +1,11 @@ +using Foundatio.Repositories.Models; + +namespace Exceptionless.Web.Models.Admin; + +public record AdminStatsResponse( + CountResult Organizations, + CountResult Users, + CountResult Projects, + CountResult Stacks, + CountResult Events +); diff --git a/src/Exceptionless.Web/Models/Admin/ElasticsearchResponse.cs b/src/Exceptionless.Web/Models/Admin/ElasticsearchResponse.cs new file mode 100644 index 0000000000..b8b7854642 --- /dev/null +++ b/src/Exceptionless.Web/Models/Admin/ElasticsearchResponse.cs @@ -0,0 +1,53 @@ +namespace Exceptionless.Web.Models.Admin; + +public record ElasticsearchHealthResponse( + int Status, + string ClusterName, + int NumberOfNodes, + int NumberOfDataNodes, + int ActiveShards, + int RelocatingShards, + int UnassignedShards, + int ActivePrimaryShards +); + +public record ElasticsearchIndicesResponse( + long Count, + long DocsCount, + double StoreSizeInBytes +); + +public record ElasticsearchIndexDetailResponse( + string? Index, + string? Health, + string? Status, + int Primary, + int Replica, + long DocsCount, + long StoreSizeInBytes, + int UnassignedShards +); + +public record ElasticsearchInfoResponse( + ElasticsearchHealthResponse Health, + ElasticsearchIndicesResponse Indices, + ElasticsearchIndexDetailResponse[] IndexDetails +); + +public record ElasticsearchSnapshotResponse( + string Repository, + string Name, + string Status, + DateTime? StartTime, + DateTime? EndTime, + string Duration, + long IndicesCount, + long SuccessfulShards, + long FailedShards, + long TotalShards +); + +public record ElasticsearchSnapshotsResponse( + string[] Repositories, + ElasticsearchSnapshotResponse[] Snapshots +); diff --git a/src/Exceptionless.Web/Models/Admin/MigrationsResponse.cs b/src/Exceptionless.Web/Models/Admin/MigrationsResponse.cs new file mode 100644 index 0000000000..61be050d91 --- /dev/null +++ b/src/Exceptionless.Web/Models/Admin/MigrationsResponse.cs @@ -0,0 +1,6 @@ +namespace Exceptionless.Web.Models.Admin; + +public record MigrationsResponse( + int CurrentVersion, + Foundatio.Repositories.Migrations.MigrationState[] States +); diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 8363b4124c..205a06c46a 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Claims; -using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; @@ -8,7 +7,6 @@ using Exceptionless.Core.Validation; using Exceptionless.Web.Extensions; using Exceptionless.Web.Hubs; -using Exceptionless.Web.Models; using Exceptionless.Web.Security; using Exceptionless.Web.Utility; using Exceptionless.Web.Utility.Handlers; @@ -24,9 +22,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -using Microsoft.AspNetCore.OpenApi; using Microsoft.Net.Http.Headers; -using Microsoft.OpenApi; using Scalar.AspNetCore; using Serilog; using Serilog.Events; diff --git a/src/Exceptionless.Web/Utility/OpenApi/AggregateDocumentTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/AggregateDocumentTransformer.cs index dcc5157173..7a535e9e33 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/AggregateDocumentTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/AggregateDocumentTransformer.cs @@ -1,4 +1,3 @@ -using Foundatio.Repositories.Models; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; diff --git a/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs b/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs index 37d87f4564..a72e1fe507 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/SchemaReferenceIdHelper.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization.Metadata; using Exceptionless.Web.Models; using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.Primitives; namespace Exceptionless.Web.Utility.OpenApi; diff --git a/src/Exceptionless.Web/Utility/Results/MessageContent.cs b/src/Exceptionless.Web/Utility/Results/MessageContent.cs index e27c5573e5..354717b64c 100644 --- a/src/Exceptionless.Web/Utility/Results/MessageContent.cs +++ b/src/Exceptionless.Web/Utility/Results/MessageContent.cs @@ -1,11 +1,11 @@ -namespace Exceptionless.Web.Utility.Results; +using System.Text.Json.Serialization; +namespace Exceptionless.Web.Utility.Results; + +[method: JsonConstructor] public record MessageContent(string? Id, string Message) { public MessageContent(string message) : this(null, message) { } - - public string? Id { get; private set; } = Id; - public string Message { get; private set; } = Message; } diff --git a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs new file mode 100644 index 0000000000..3b5a6cd65e --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs @@ -0,0 +1,545 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models.Admin; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Repositories.Models; +using Foundatio.Repositories.Utility; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public class AdminControllerTests : IntegrationTestsBase +{ + private readonly WorkItemJob _workItemJob; + private readonly IQueue _workItemQueue; + private readonly IStackRepository _stackRepository; + private readonly IEventRepository _eventRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; + private readonly StackData _stackData; + private readonly EventData _eventData; + + public AdminControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _workItemJob = GetService(); + _workItemQueue = GetService>(); + _stackRepository = GetService(); + _eventRepository = GetService(); + _projectRepository = GetService(); + _userRepository = GetService(); + _stackData = GetService(); + _eventData = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + } + + [Fact] + public async Task RunJobAsync_WhenFixStackStatsWithExplicitUtcWindow_ShouldRepairStatsEndToEnd() + { + // Arrange + TimeProvider.SetUtcNow(new DateTime(2026, 2, 15, 12, 0, 0, DateTimeKind.Utc)); + var stack = await CreateCorruptedStackWithEventAsync(new DateTimeOffset(2026, 2, 14, 0, 0, 0, TimeSpan.Zero)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .QueryString("utcStart", "2026-02-10T00:00:00Z") + .QueryString("utcEnd", "2026-02-23T00:00:00Z") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(1, stack.TotalOccurrences); + Assert.Equal(new DateTime(2026, 2, 14, 0, 0, 0, DateTimeKind.Utc), stack.FirstOccurrence); + Assert.Equal(new DateTime(2026, 2, 14, 0, 0, 0, DateTimeKind.Utc), stack.LastOccurrence); + } + + [Fact] + public async Task RunJobAsync_WhenFixStackStatsWindowIsOmitted_ShouldUseDefaultStartAndCurrentUtcEnd() + { + // Arrange + TimeProvider.SetUtcNow(new DateTime(2026, 2, 5, 12, 0, 0, DateTimeKind.Utc)); + var beforeWindow = await CreateCorruptedStackWithEventAsync(new DateTimeOffset(2025, 11, 1, 12, 0, 0, TimeSpan.Zero)); + + TimeProvider.SetUtcNow(new DateTime(2026, 2, 15, 12, 0, 0, DateTimeKind.Utc)); + var inWindow = await CreateCorruptedStackWithEventAsync(new DateTimeOffset(2026, 2, 15, 12, 0, 0, TimeSpan.Zero)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + beforeWindow = await _stackRepository.GetByIdAsync(beforeWindow.Id); + inWindow = await _stackRepository.GetByIdAsync(inWindow.Id); + + // Assert + Assert.NotNull(beforeWindow); + Assert.NotNull(inWindow); + Assert.Equal(0, beforeWindow.TotalOccurrences); + Assert.Equal(1, inWindow.TotalOccurrences); + } + + [Fact] + public async Task RunJobAsync_WhenFixStackStatsUsesOffsetUtcTimestamp_ShouldAcceptModelBindingValue() + { + // Arrange + TimeProvider.SetUtcNow(new DateTime(2026, 2, 15, 12, 0, 0, DateTimeKind.Utc)); + var stack = await CreateCorruptedStackWithEventAsync(new DateTimeOffset(2026, 2, 14, 0, 0, 0, TimeSpan.Zero)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .QueryString("utcStart", "2026-02-10T00:00:00+00:00") + .QueryString("utcEnd", "2026-02-23T00:00:00+00:00") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + var stats = await _workItemQueue.GetQueueStatsAsync(); + + // Assert + Assert.NotNull(stack); + Assert.Equal(1, stack.TotalOccurrences); + Assert.Equal(1, stats.Enqueued); + } + + [Fact] + public async Task RunJobAsync_WhenFixStackStatsEndDateIsBeforeStartDate_ShouldReturnUnprocessableEntity() + { + // Arrange + var response = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .QueryString("utcStart", "2026-02-20T00:00:00Z") + .QueryString("utcEnd", "2026-02-10T00:00:00Z") + .StatusCodeShouldBeUnprocessableEntity()); + + // Act + var stats = await _workItemQueue.GetQueueStatsAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Errors.ContainsKey("utc_end")); + Assert.Equal(0, stats.Enqueued); + } + + [Fact] + public async Task RunJobAsync_WhenFixStackStatsStartDateIsInvalid_ShouldReturnBadRequestAndNotQueueWorkItem() + { + // Arrange + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .QueryString("utcStart", "not-a-dateZ") + .StatusCodeShouldBeBadRequest()); + + // Act + var stats = await _workItemQueue.GetQueueStatsAsync(); + + // Assert + Assert.Equal(0, stats.Enqueued); + } + + private async Task CreateCorruptedStackWithEventAsync(DateTimeOffset occurrenceDate) + { + var utcOccurrenceDate = occurrenceDate.UtcDateTime; + var stack = _stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + totalOccurrences: 0, + utcFirstOccurrence: utcOccurrenceDate.AddDays(1), + utcLastOccurrence: utcOccurrenceDate.AddDays(-1)); + + stack = await _stackRepository.AddAsync(stack, o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [_eventData.GenerateEvent(TestConstants.OrganizationId, TestConstants.ProjectId, stack.Id, occurrenceDate: occurrenceDate)], + o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + return stack; + } + + [Fact] + public async Task RunJobAsync_WhenUpdateProjectNotificationSettingsWithOrphanedUser_ShouldRemoveOrphanedEntries() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + int settingsCountBefore = project.NotificationSettings.Count; + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + Assert.Equal(settingsCountBefore - 1, project.NotificationSettings.Count); + } + + [Fact] + public async Task RunJobAsync_WhenUpdateProjectNotificationSettingsWithValidUser_ShouldPreserveSettings() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + var globalAdmin = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(globalAdmin); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + + int settingsCountBefore = project.NotificationSettings.Count; + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + Assert.Equal(settingsCountBefore, project.NotificationSettings.Count); + } + + [Fact] + public async Task RunJobAsync_WhenUpdateProjectNotificationSettingsWithIntegration_ShouldPreserveIntegrationKey() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + project.NotificationSettings[Project.NotificationIntegrations.Slack] = new NotificationSettings { ReportNewErrors = true }; + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunJobAsync_WhenUpdateProjectNotificationSettingsWithDeletedUser_ShouldRemoveOrphanedEntries() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string deletedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[deletedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + Assert.Null(await _userRepository.GetByIdAsync(deletedUserId)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(deletedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunJobAsync_WhenUpdateProjectNotificationSettingsWithOrgFilter_ShouldOnlyProcessTargetOrg() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act: run cleanup for a different org + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .QueryString("organizationId", TestConstants.OrganizationId2) + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert: orphaned user in the OTHER org should still be there + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(orphanedUserId, project.NotificationSettings.Keys); + + // Act: now run for the correct org + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "maintenance", "update-project-notification-settings") + .QueryString("organizationId", TestConstants.OrganizationId) + .StatusCodeShouldBeOk()); + + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task GetStats_AsGlobalAdmin_ReturnsAllFieldsPopulated() + { + // Act + var stats = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "stats") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(stats); + Assert.True(stats.Organizations.Total >= 0); + Assert.True(stats.Users.Total >= 0); + Assert.True(stats.Projects.Total >= 0); + Assert.True(stats.Stacks.Total >= 0); + Assert.True(stats.Events.Total >= 0); + + Assert.NotNull(stats.Organizations.Aggregations); + Assert.NotNull(stats.Stacks.Aggregations); + Assert.NotNull(stats.Events.Aggregations); + } + + [Fact] + public async Task GetStats_AsGlobalAdmin_BillingStatusBreakdownSumsToOrgCount() + { + // Act + var stats = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "stats") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(stats); + var billingTerms = stats.Organizations.Aggregations.Terms("terms_billing_status"); + Assert.NotNull(billingTerms); + var billingBuckets = billingTerms.Buckets; + Assert.NotNull(billingBuckets); + long billingTotal = billingBuckets.Sum(b => b.Total ?? 0); + Assert.Equal(stats.Organizations.Total, billingTotal); + } + + [Fact] + public async Task GetStats_AsGlobalAdmin_StacksByStatusSumsToStackCount() + { + // Act + var stats = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "stats") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(stats); + var statusTerms = stats.Stacks.Aggregations.Terms("terms_status"); + Assert.NotNull(statusTerms); + var statusBuckets = statusTerms.Buckets; + Assert.NotNull(statusBuckets); + long statusTotal = statusBuckets.Sum(b => b.Total ?? 0); + Assert.Equal(stats.Stacks.Total, statusTotal); + } + + [Fact] + public async Task GetStats_AsGlobalAdmin_StacksByTypeStatusHasValidStructure() + { + // Act + var stats = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "stats") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(stats); + var typeTerms = stats.Stacks.Aggregations.Terms("terms_type"); + Assert.NotNull(typeTerms); + var typeBuckets = typeTerms.Buckets; + Assert.NotNull(typeBuckets); + foreach (var typeBucket in typeBuckets) + { + Assert.NotNull(typeBucket.Key); + Assert.True(typeBucket.Total >= 0); + var nestedStatusTerms = typeBucket.Aggregations.Terms("terms_status"); + Assert.NotNull(nestedStatusTerms); + var nestedStatusBuckets = nestedStatusTerms.Buckets; + Assert.NotNull(nestedStatusBuckets); + long subTotal = nestedStatusBuckets.Sum(b => b.Total ?? 0); + Assert.Equal(typeBucket.Total, subTotal); + } + } + + [Fact] + public Task GetStats_WithoutAuth_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AppendPaths("admin", "stats") + .StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public Task RunJobAsync_AsAuthenticatedNonGlobalAdmin_ReturnsForbidden() + { + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("admin", "maintenance", "fix-stack-stats") + .StatusCodeShouldBeForbidden()); + } + + [Theory] + [InlineData("admin/stats")] + [InlineData("admin/migrations")] + [InlineData("admin/elasticsearch")] + [InlineData("admin/elasticsearch/snapshots")] + public Task AdminReadEndpoints_AsAuthenticatedNonGlobalAdmin_ReturnForbidden(string path) + { + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPath(path) + .StatusCodeShouldBeForbidden()); + } + + [Fact] + public async Task GetMigrations_AsGlobalAdmin_ReturnsAllRegisteredMigrations() + { + // Act + var response = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "migrations") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.States); + + foreach (var state in response.States) + { + Assert.NotNull(state.Id); + Assert.True(Enum.IsDefined(state.MigrationType)); + } + } + + [Fact] + public Task GetMigrations_WithoutAuth_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AppendPaths("admin", "migrations") + .StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public async Task GetElasticsearch_AsGlobalAdmin_ReturnsClusterHealthAndIndices() + { + // Act + var elasticsearch = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "elasticsearch") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(elasticsearch); + Assert.NotNull(elasticsearch.Health); + Assert.NotNull(elasticsearch.Indices); + Assert.NotNull(elasticsearch.IndexDetails); + } + + [Fact] + public async Task GetElasticsearch_AsGlobalAdmin_IndexDetailsContainExpectedFields() + { + // Act + var elasticsearch = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "elasticsearch") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(elasticsearch); + Assert.All(elasticsearch.IndexDetails, indexDetail => + { + Assert.True(indexDetail.DocsCount >= 0); + Assert.True(indexDetail.StoreSizeInBytes >= 0); + Assert.True(indexDetail.UnassignedShards >= 0); + }); + } + + [Fact] + public Task GetElasticsearch_WithoutAuth_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AppendPaths("admin", "elasticsearch") + .StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public async Task GetElasticsearchSnapshots_AsGlobalAdmin_ReturnsTypedResponse() + { + // Act + var snapshots = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "elasticsearch", "snapshots") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(snapshots); + Assert.NotNull(snapshots.Repositories); + Assert.NotNull(snapshots.Snapshots); + } + + [Fact] + public Task GetElasticsearchSnapshots_WithoutAuth_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AppendPaths("admin", "elasticsearch", "snapshots") + .StatusCodeShouldBeUnauthorized()); + } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json index a9bfac892d..9e16ff0d4c 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-input.json @@ -22,7 +22,7 @@ "http_method": "POST", "user_agent": "TestAgent/1.0", "is_secure": true, - "host": "test.example.com", + "host": "test.localhost", "path": "/api/test", "port": 443 } diff --git a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json index b1a558af13..9d89be4cef 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json +++ b/tests/Exceptionless.Tests/Controllers/Data/event-serialization-response.json @@ -1,48 +1,45 @@ { - "id": "", - "organization_id": "537650f3b77efe23a47914f3", - "project_id": "537650f3b77efe23a47914f4", - "stack_id": "", - "is_first_occurrence": true, - "created_utc": "2026-01-15T12:00:00", - "type": "error", - "date": "2026-01-15T12:00:00+00:00", - "tags": [ - "test", - "serialization" - ], - "message": "Test error for serialization verification", - "data": { - "@request": { - "user_agent": "TestAgent/1.0", - "http_method": "POST", - "is_secure": true, - "host": "test.example.com", - "port": 443, - "path": "/api/test", - "client_ip_address": "10.0.0.100", - "data": { - "@is_bot": false - } - }, - "@submission_client": { - "user_agent": "fluentrest", - "version": "11.0.0.0" - }, - "@environment": { - "processor_count": 8, - "total_physical_memory": 17179869184, - "available_physical_memory": 8589934592, - "command_line": "TestApp.exe --test", - "process_name": "TestApp", - "process_id": "12345", - "process_memory_size": 104857600, - "thread_id": "1", - "o_s_name": "Windows 11", - "o_s_version": "10.0.22621", - "ip_address": "192.168.1.100", - "machine_name": "TEST-MACHINE", - "runtime_version": ".NET 8.0.1" + "id": "", + "organization_id": "537650f3b77efe23a47914f3", + "project_id": "537650f3b77efe23a47914f4", + "stack_id": "", + "is_first_occurrence": true, + "created_utc": "2026-01-15T12:00:00", + "type": "error", + "date": "2026-01-15T12:00:00+00:00", + "tags": ["test", "serialization"], + "message": "Test error for serialization verification", + "data": { + "@request": { + "user_agent": "TestAgent/1.0", + "http_method": "POST", + "is_secure": true, + "host": "test.localhost", + "port": 443, + "path": "/api/test", + "client_ip_address": "10.0.0.100", + "data": { + "@is_bot": false + } + }, + "@submission_client": { + "user_agent": "fluentrest", + "version": "11.0.0.0" + }, + "@environment": { + "processor_count": 8, + "total_physical_memory": 17179869184, + "available_physical_memory": 8589934592, + "command_line": "TestApp.exe --test", + "process_name": "TestApp", + "process_id": "12345", + "process_memory_size": 104857600, + "thread_id": "1", + "o_s_name": "Windows 11", + "o_s_version": "10.0.22621", + "ip_address": "192.168.1.100", + "machine_name": "TEST-MACHINE", + "runtime_version": ".NET 8.0.1" + } } - } } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index fa5fdb40d6..5756ec39bf 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -5272,16 +5272,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } }, "application/*+json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } } - }, - "required": true + } }, "responses": { "200": { @@ -5327,16 +5340,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } }, "application/*+json": { "schema": { - "$ref": "#/components/schemas/NotificationSettings" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NotificationSettings" + } + ] } } - }, - "required": true + } }, "responses": { "200": { @@ -5893,8 +5919,8 @@ } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "The stacks were marked as fixed.", "content": { "application/json": { } } @@ -5933,8 +5959,8 @@ } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "The stacks were snoozed.", "content": { "application/json": { } } @@ -7435,7 +7461,6 @@ "stack_id", "is_first_occurrence", "created_utc", - "idx", "date" ], "type": "object", @@ -7478,7 +7503,10 @@ "format": "date-time" }, "idx": { - "type": "object", + "type": [ + "null", + "object" + ], "additionalProperties": { }, "description": "Used to store primitive data type custom data values for searching the event." }, diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 1205debd51..52cb4ccfe8 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -34,6 +34,7 @@ namespace Exceptionless.Tests.Controllers; public class EventControllerTests : IntegrationTestsBase { + private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly IOrganizationRepository _organizationRepository; private readonly StackData _stackData; private readonly RandomEventGenerator _randomEventGenerator; @@ -45,6 +46,7 @@ public class EventControllerTests : IntegrationTestsBase public EventControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { + _jsonSerializerOptions = GetService(); _organizationRepository = GetService(); _stackData = GetService(); _randomEventGenerator = GetService(); @@ -522,9 +524,16 @@ await CreateDataAsync(d => [Fact] public async Task WillGetStackEvents() { + var now = TimeProvider.GetUtcNow(); + + // Create events on different days for the same stack so they land in different + // daily index partitions. Dates must stay within org.CreatedUtc - 3d to avoid + // being filtered by the org creation cutoff (see GetRetentionUtcCutoff). var (stacks, _) = await CreateDataAsync(d => { - d.Event().TestProject(); + var ev = d.Event().TestProject().Date(now); + d.Event().Stack(ev).Date(now.AddDays(-1)); + d.Event().Stack(ev).Date(now.AddDays(-2)); }); Log.SetLogLevel(LogLevel.Trace); @@ -538,7 +547,7 @@ public async Task WillGetStackEvents() ); Assert.NotNull(result); - Assert.Single(result); + Assert.Equal(3, result.Count); } [Fact] @@ -650,7 +659,7 @@ public async Task CheckSummaryModeCounts(string filter, int expected) await CreateStacksAndEventsAsync(); Log.SetLogLevel(LogLevel.Trace); - var results = await SendRequestAsAsync>(r => r + var results = await SendRequestAsAsync>(r => r .AsGlobalAdminUser() .AppendPath("events") .QueryString("filter", filter) @@ -662,7 +671,7 @@ public async Task CheckSummaryModeCounts(string filter, int expected) Assert.Equal(expected, results.Count); // @! forces use of opposite of default filter inversion - results = await SendRequestAsAsync>(r => r + results = await SendRequestAsAsync>(r => r .AsGlobalAdminUser() .AppendPath("events") .QueryString("filter", $"@!{filter}") @@ -674,6 +683,53 @@ public async Task CheckSummaryModeCounts(string filter, int expected) Assert.Equal(expected, results.Count); } + [Fact] + public async Task GetEvents_SummaryMode_DeserializesEventSummaryModel() + { + // Arrange + await CreateStacksAndEventsAsync(); + + // Act + var results = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("filter", "status:open") + .QueryString("mode", "summary") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(results); + Assert.NotEmpty(results); + Assert.All(results, summary => Assert.NotEqual(default, summary.Date)); + } + + [Fact] + public async Task GetEvents_StackFrequentMode_DeserializesStackSummaryModelWithRequiredFields() + { + // Arrange + await CreateStacksAndEventsAsync(); + + // Act + var results = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("filter", "status:open") + .QueryString("mode", "stack_frequent") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(results); + Assert.NotEmpty(results); + Assert.All(results, summary => + { + Assert.False(String.IsNullOrWhiteSpace(summary.Title)); + Assert.NotEqual(default, summary.FirstOccurrence); + Assert.NotEqual(default, summary.LastOccurrence); + }); + } + [InlineData(null)] [InlineData("")] [InlineData("@!")] @@ -1551,7 +1607,7 @@ private static Dictionary ParseLinkHeaderValue(string[] links) return result; } - [Fact(Skip = "Foundatio bug with not passing in time provider to extension methods.")] + [Fact] public async Task PostEvent_WithEnvironmentAndRequestInfo_ReturnsCorrectSnakeCaseSerialization() { TimeProvider.SetUtcNow(new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)); @@ -1585,7 +1641,7 @@ await SendRequestAsync(r => r .Replace("", processedEvent.Id) .Replace("", processedEvent.StackId); - Assert.Equal(expectedJson, actualJson); + Assert.Equal(ToPrettyJson(expectedJson), ToPrettyJson(actualJson)); } [Fact] @@ -1777,4 +1833,13 @@ await SendRequestAsync(r => r Assert.Equal("Test naming conventions", ev.Message); Assert.Equal("ref-1234567890", ev.ReferenceId); } + + private string ToPrettyJson(string json) + { + using var document = JsonDocument.Parse(json); + var prettyJsonOptions = new JsonSerializerOptions(_jsonSerializerOptions) { + WriteIndented = true + }; + return JsonSerializer.Serialize(document.RootElement, prettyJsonOptions); + } } diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 5ff7288353..9514a29281 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -3,8 +3,8 @@ using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Web.Models; -using FluentRest; using Foundatio.Repositories; +using Foundatio.Repositories.Utility; using Xunit; namespace Exceptionless.Tests.Controllers; @@ -16,10 +16,14 @@ namespace Exceptionless.Tests.Controllers; public sealed class OrganizationControllerTests : IntegrationTestsBase { private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; public OrganizationControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _organizationRepository = GetService(); + _projectRepository = GetService(); + _userRepository = GetService(); } protected override async Task ResetDataAsync() @@ -150,7 +154,7 @@ public async Task GetAsync_ViewOrganization_IncludesIsOverMonthlyLimit() .StatusCodeShouldBeOk() ); - // Assert - IsOverMonthlyLimit is computed by AfterMap in AutoMapper + // Assert - IsOverMonthlyLimit is computed by OrganizationMapper Assert.NotNull(viewOrg); // The value can be true or false depending on usage, but the property should be set Assert.IsType(viewOrg.IsOverMonthlyLimit); @@ -244,4 +248,120 @@ await SendRequestAsync(r => r .StatusCodeShouldBeNotFound() ); } + + [Fact] + public async Task RemoveUserAsync_UserWithNotificationSettings_CleansUpNotificationSettings() + { + // Arrange + var organizationAdminUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(organizationAdminUser); + Assert.Contains(SampleDataService.TEST_ORG_ID, organizationAdminUser.OrganizationIds); + + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.NotificationSettings[organizationAdminUser.Id] = new NotificationSettings + { + SendDailySummary = true, + ReportNewErrors = true, + ReportCriticalErrors = true + }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + Assert.True(project.NotificationSettings.ContainsKey(organizationAdminUser.Id)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Delete() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "users", SampleDataService.TEST_ORG_USER_EMAIL) + .StatusCodeShouldBeOk() + ); + + await RefreshDataAsync(); + + // Assert + project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + Assert.False(project.NotificationSettings.ContainsKey(organizationAdminUser.Id)); + + organizationAdminUser = await _userRepository.GetByIdAsync(organizationAdminUser.Id); + Assert.NotNull(organizationAdminUser); + Assert.DoesNotContain(SampleDataService.TEST_ORG_ID, organizationAdminUser.OrganizationIds); + } + + [Fact] + public async Task RemoveUserAsync_WithExistingOrphanedNotificationSettings_CleansTargetAndHistoricalOrphans() + { + // Arrange + var organizationAdminUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(organizationAdminUser); + + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[organizationAdminUser.Id] = new NotificationSettings + { + SendDailySummary = true, + ReportNewErrors = true, + ReportCriticalErrors = true + }; + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + Assert.Null(await _userRepository.GetByIdAsync(orphanedUserId)); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Delete() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "users", SampleDataService.TEST_ORG_USER_EMAIL) + .StatusCodeShouldBeOk() + ); + + await RefreshDataAsync(); + + project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + + // Assert + Assert.DoesNotContain(organizationAdminUser.Id, project.NotificationSettings.Keys); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RemoveUserAsync_UserWithNotificationSettings_PreservesOtherUsersAndIntegrations() + { + // Arrange + var organizationAdminUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(organizationAdminUser); + + var globalAdmin = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(globalAdmin); + + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.NotificationSettings[organizationAdminUser.Id] = new NotificationSettings { ReportNewErrors = true }; + project.NotificationSettings[globalAdmin.Id] = new NotificationSettings { ReportCriticalErrors = true }; + project.NotificationSettings[Project.NotificationIntegrations.Slack] = new NotificationSettings { SendDailySummary = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Delete() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "users", SampleDataService.TEST_ORG_USER_EMAIL) + .StatusCodeShouldBeOk() + ); + + await RefreshDataAsync(); + + // Assert + project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + Assert.DoesNotContain(organizationAdminUser.Id, project.NotificationSettings.Keys); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); + } } diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index c394aaf853..feb7d6479a 100644 --- a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text.Json; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -7,7 +9,6 @@ using Exceptionless.Web.Models; using FluentRest; using Foundatio.Jobs; -using Foundatio.Repositories; using Xunit; namespace Exceptionless.Tests.Controllers; @@ -145,23 +146,306 @@ public async Task CanUpdateProjectWithExtraPayloadProperties() } [Fact] - public async Task CanGetProjectConfiguration() + public async Task GetConfigAsync_WithClientAuth_ReturnsConfigurationWithSettings() { + // Act var response = await SendRequestAsync(r => r .AsFreeOrganizationClientUser() .AppendPath("projects/config") .StatusCodeShouldBeOk() ); + // Assert - response headers Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); Assert.Equal("utf-8", response.Content.Headers.ContentType?.CharSet); Assert.True(response.Content.Headers.ContentLength.HasValue); Assert.True(response.Content.Headers.ContentLength > 0); + // Assert - deserialized model var config = await response.DeserializeAsync(); Assert.NotNull(config); Assert.True(config.Settings.GetBoolean("IncludeConditionalData")); Assert.Equal(0, config.Version); + + // Assert - raw JSON uses snake_case and correct structure + string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("version", out var versionProp), "Expected snake_case property 'version' in JSON"); + Assert.Equal(0, versionProp.GetInt32()); + Assert.True(root.TryGetProperty("settings", out var settingsProp), "Expected snake_case property 'settings' in JSON"); + Assert.Equal(JsonValueKind.Object, settingsProp.ValueKind); + Assert.True(settingsProp.TryGetProperty("IncludeConditionalData", out var settingValue), "Expected 'IncludeConditionalData' key in settings"); + Assert.Equal("true", settingValue.GetString()); + } + + [Fact] + public async Task GetConfigAsync_WithCurrentVersion_ReturnsNotModified() + { + // Arrange - get the current config version + var config = await SendRequestAsAsync(r => r + .AsFreeOrganizationClientUser() + .AppendPath("projects/config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(config); + + // Act - request with the current version + var response = await SendRequestAsync(r => r + .AsFreeOrganizationClientUser() + .AppendPath("projects/config") + .QueryString("v", config.Version.ToString()) + .ExpectedStatus(HttpStatusCode.NotModified) + ); + + // Assert + Assert.Equal(HttpStatusCode.NotModified, response.StatusCode); + } + + [Fact] + public async Task GetConfigAsync_WithStaleVersion_ReturnsUpdatedConfig() + { + // Arrange - get initial config + var config = await SendRequestAsAsync(r => r + .AsFreeOrganizationClientUser() + .AppendPath("projects/config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(config); + int initialVersion = config.Version; + + // Increment the version by setting a new config value + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "config") + .QueryString("key", "StaleVersionTest") + .Content(new ValueFromBody("StaleValue")) + .StatusCodeShouldBeOk() + ); + + // Act - request with the old (stale) version + var updatedConfig = await SendRequestAsAsync(r => r + .AsFreeOrganizationClientUser() + .AppendPath("projects/config") + .QueryString("v", initialVersion.ToString()) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updatedConfig); + Assert.True(updatedConfig.Version > initialVersion); + Assert.Equal("StaleValue", updatedConfig.Settings.GetString("StaleVersionTest")); + } + + [Fact] + public async Task SetConfigAsync_WithValidKeyAndValue_PersistsAndIncrementsVersion() + { + // Arrange - get initial config + var initialConfig = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(initialConfig); + + // Act - set a new config value + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "MyNewSetting") + .Content(new ValueFromBody("MyNewValue")) + .StatusCodeShouldBeOk() + ); + + // Assert - verify the setting was persisted + var updatedConfig = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(updatedConfig); + Assert.Equal("MyNewValue", updatedConfig.Settings.GetString("MyNewSetting")); + Assert.Equal(initialConfig.Version + 1, updatedConfig.Version); + } + + [Fact] + public async Task SetConfigAsync_WithEmptyKey_ReturnsBadRequest() + { + // Arrange - get initial config version + var configBefore = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configBefore); + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "") + .Content(new ValueFromBody("SomeValue")) + .StatusCodeShouldBeBadRequest() + ); + + // Assert - version should not change + var configAfter = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configAfter); + Assert.Equal(configBefore.Version, configAfter.Version); + } + + [Fact] + public async Task SetConfigAsync_WithEmptyValue_ReturnsBadRequest() + { + // Arrange - get initial config version + var configBefore = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configBefore); + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "TestKey") + .Content(new ValueFromBody("")) + .StatusCodeShouldBeBadRequest() + ); + + // Assert - version should not change + var configAfter = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configAfter); + Assert.Equal(configBefore.Version, configAfter.Version); + } + + [Fact] + public async Task SetConfigAsync_RoundTrip_JsonSerializesCorrectly() + { + // Arrange - set a config value + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "SerializationTest") + .Content(new ValueFromBody("TestValue123")) + .StatusCodeShouldBeOk() + ); + + // Act - get raw JSON from the API + var response = await SendRequestAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + + string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + + // Assert - validate JSON structure matches client expectations + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Should have snake_case property names + Assert.True(root.TryGetProperty("version", out _), "Expected 'version' property (snake_case)"); + Assert.False(root.TryGetProperty("Version", out _), "Should not have PascalCase 'Version' property"); + Assert.True(root.TryGetProperty("settings", out var settings), "Expected 'settings' property (snake_case)"); + Assert.False(root.TryGetProperty("Settings", out _), "Should not have PascalCase 'Settings' property"); + + // Settings should be a flat dictionary, not a wrapped object + Assert.Equal(JsonValueKind.Object, settings.ValueKind); + Assert.True(settings.TryGetProperty("SerializationTest", out var testValue)); + Assert.Equal("TestValue123", testValue.GetString()); + + // Settings keys should preserve original casing (not be snake_cased) + Assert.True(settings.TryGetProperty("IncludeConditionalData", out _), + "Settings dictionary keys should preserve original casing"); + } + + [Fact] + public async Task DeleteConfigAsync_WithExistingKey_RemovesSettingAndIncrementsVersion() + { + // Arrange - add a config setting first + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "ToBeDeleted") + .Content(new ValueFromBody("DeleteMe")) + .StatusCodeShouldBeOk() + ); + + var configBeforeDelete = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configBeforeDelete); + Assert.Equal("DeleteMe", configBeforeDelete.Settings.GetString("ToBeDeleted")); + + // Act - delete the config setting + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "ToBeDeleted") + .StatusCodeShouldBeOk() + ); + + // Assert - verify the setting was removed and version incremented + var configAfterDelete = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(configAfterDelete); + Assert.Null(configAfterDelete.Settings.GetString("ToBeDeleted", null)); + Assert.Equal(configBeforeDelete.Version + 1, configAfterDelete.Version); + } + + [Fact] + public async Task DeleteConfigAsync_WithNonExistentKey_ReturnsOkWithoutVersionChange() + { + // Arrange - get current config version + var configBefore = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(configBefore); + + // Act - delete a key that doesn't exist + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .QueryString("key", "NonExistentKey12345") + .StatusCodeShouldBeOk() + ); + + // Assert - version should not change + var configAfter = await SendRequestAsAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "config") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(configAfter); + Assert.Equal(configBefore.Version, configAfter.Version); } [Fact] diff --git a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs index bbd7f0235c..e12aa8dbd8 100644 --- a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs @@ -71,7 +71,7 @@ public async Task CanMarkFixed(string? version) Assert.NotNull(stack); Assert.False(stack.IsFixed()); - await SendRequestAsAsync(r => r + await SendRequestAsync(r => r .Post() .AsGlobalAdminUser() .AppendPath($"stacks/{stack.Id}/mark-fixed") @@ -168,4 +168,33 @@ await SendRequestAsync(r => r Assert.Single(stack.References); Assert.Contains(testUrl, stack.References); } + + [Fact] + public async Task GetAll_WithDateRangeFilter_ReturnsOnlyMatchingStacks() + { + // Arrange + var now = TimeProvider.GetUtcNow(); + var (stacks, _) = await CreateDataAsync(d => + { + d.Event().TestProject().Date(now.AddDays(-1)); + d.Event().TestProject().Date(now.AddDays(-3)); + }); + + Assert.Equal(2, stacks.Count); + var recentStack = stacks.Single(s => s.LastOccurrence >= now.AddDays(-2).UtcDateTime); + var oldStack = stacks.Single(s => s.LastOccurrence < now.AddDays(-2).UtcDateTime); + + // Act + var result = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPath("stacks") + .QueryString("time", "[now-2d TO now]") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.Contains(result, s => String.Equals(s.Id, recentStack.Id, StringComparison.Ordinal)); + Assert.DoesNotContain(result, s => String.Equals(s.Id, oldStack.Id, StringComparison.Ordinal)); + } } diff --git a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs index e446bad9f1..83a9828ef0 100644 --- a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs @@ -3,7 +3,6 @@ using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Web.Models; -using Foundatio.Repositories; using Microsoft.AspNetCore.Mvc; using Xunit; @@ -28,7 +27,7 @@ protected override async Task ResetDataAsync() [Fact] public async Task PostAsync_NewWebHook_MapsAllPropertiesToWebHook() { - // Arrange - Test AutoMapper: NewWebHook -> WebHook + // Arrange - Test Mapperly: NewWebHook -> WebHook var newWebHook = new NewWebHook { EventTypes = [WebHook.KnownEventTypes.StackPromoted, WebHook.KnownEventTypes.NewError], diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 041d5fe22b..7e0d8e8883 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -6,17 +6,17 @@ true - - + + - + - + - - - - + + + + diff --git a/tests/Exceptionless.Tests/IntegrationTestsBase.cs b/tests/Exceptionless.Tests/IntegrationTestsBase.cs index f486fbe473..46530e605f 100644 --- a/tests/Exceptionless.Tests/IntegrationTestsBase.cs +++ b/tests/Exceptionless.Tests/IntegrationTestsBase.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Authentication; using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; @@ -10,7 +11,6 @@ using Exceptionless.Tests.Mail; using Exceptionless.Tests.Utility; using FluentRest; -using FluentRest.NewtonsoftJson; using Foundatio.Caching; using Foundatio.Jobs; using Foundatio.Messaging; @@ -23,7 +23,6 @@ using Foundatio.Xunit; using Microsoft.AspNetCore.TestHost; using Nest; -using Newtonsoft.Json; using Xunit; using HttpMethod = System.Net.Http.HttpMethod; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -206,8 +205,8 @@ protected HttpClient CreateHttpClient() protected FluentClient CreateFluentClient() { - var settings = GetService(); - return new FluentClient(CreateHttpClient(), new NewtonsoftJsonSerializer(settings)); + var settings = GetService(); + return new FluentClient(CreateHttpClient(), new JsonContentSerializer(settings)); } protected async Task SendRequestAsync(Action configure) diff --git a/tests/Exceptionless.Tests/Jobs/FixStackStatsJobTests.cs b/tests/Exceptionless.Tests/Jobs/FixStackStatsJobTests.cs new file mode 100644 index 0000000000..fd58d41944 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/FixStackStatsJobTests.cs @@ -0,0 +1,270 @@ +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public class FixStackStatsJobTests : IntegrationTestsBase +{ + private readonly WorkItemJob _workItemJob; + private readonly IQueue _workItemQueue; + private readonly IStackRepository _stackRepository; + private readonly IEventRepository _eventRepository; + private readonly StackData _stackData; + private readonly EventData _eventData; + + private static readonly DateTime DefaultWindowStart = new(2026, 2, 10, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime DefaultWindowEnd = new(2026, 2, 23, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime InWindowDate = new(2026, 2, 15, 12, 0, 0, DateTimeKind.Utc); + + public FixStackStatsJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _workItemJob = GetService(); + _workItemQueue = GetService>(); + _stackRepository = GetService(); + _eventRepository = GetService(); + _stackData = GetService(); + _eventData = GetService(); + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenStackIsInBugWindowWithCorruptCounters_ShouldRebuildStackStatsFromEvents() + { + // Arrange + // Simulate the corrupted state: stack created in bug window with TotalOccurrences = 0 + TimeProvider.SetUtcNow(InWindowDate); + + var stack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + utcFirstOccurrence: InWindowDate, + utcLastOccurrence: InWindowDate, + totalOccurrences: 0) + , o => o.ImmediateConsistency()); + + // Events exist with known occurrence dates — as if they were posted but the Redis + // ValueTuple bug caused stack stat increments to be silently dropped. + var first = new DateTimeOffset(2026, 2, 11, 0, 0, 0, TimeSpan.Zero); + var middle = new DateTimeOffset(2026, 2, 15, 12, 0, 0, TimeSpan.Zero); + var last = new DateTimeOffset(2026, 2, 20, 0, 0, 0, TimeSpan.Zero); + await _eventRepository.AddAsync( + [ + _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, stackId: stack.Id, occurrenceDate: first), + _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, stackId: stack.Id, occurrenceDate: middle), + _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, stackId: stack.Id, occurrenceDate: last), + ], + o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, + UtcEnd = DefaultWindowEnd + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(3, stack.TotalOccurrences); + Assert.Equal(first.UtcDateTime, stack.FirstOccurrence); + Assert.Equal(last.UtcDateTime, stack.LastOccurrence); + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenAllEventsAreBeforeWindowStart_ShouldSkipRepair() + { + // Arrange + // All events for this stack are before the window start — the handler won't find them + // in the event aggregation, so the stack should not be touched. + TimeProvider.SetUtcNow(new DateTime(2026, 2, 5, 12, 0, 0, DateTimeKind.Utc)); + + var stack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + totalOccurrences: 0), o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [ + _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, stackId: stack.Id, + occurrenceDate: new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)) + ], + o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, // Feb 10 — after this stack's events (Feb 5) + UtcEnd = DefaultWindowEnd + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(0, stack.TotalOccurrences); // Not touched — events outside window + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenAllEventsAreAfterWindowEnd_ShouldSkipRepair() + { + // Arrange + // All events for this stack are after the window end — excluded from the aggregation. + TimeProvider.SetUtcNow(new DateTime(2026, 2, 24, 0, 0, 0, DateTimeKind.Utc)); + + var stack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + totalOccurrences: 0), o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [ + _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, stackId: stack.Id, + occurrenceDate: new DateTimeOffset(2026, 2, 24, 0, 0, 0, TimeSpan.Zero)) + ], + o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, + UtcEnd = DefaultWindowEnd // Feb 23 — before this stack's events (Feb 24) + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(0, stack.TotalOccurrences); // Not touched — events outside window + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenOrganizationIdIsSpecified_ShouldOnlyRepairThatOrg() + { + // Arrange + TimeProvider.SetUtcNow(InWindowDate); + + // Stack in the target org with corrupted counters + var targetStack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + utcFirstOccurrence: InWindowDate.AddDays(1), // wrong: too late + utcLastOccurrence: InWindowDate.AddDays(-1), // wrong: too early + totalOccurrences: 0), o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [_eventData.GenerateEvent(TestConstants.OrganizationId, TestConstants.ProjectId, targetStack.Id, + occurrenceDate: new DateTimeOffset(InWindowDate, TimeSpan.Zero))], + o => o.ImmediateConsistency()); + + // Stack in a different org — should not be touched + const string otherOrgId = TestConstants.OrganizationId2; + const string otherProjectId = "1ecd0826e447ad1e78877ab9"; + var otherStack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: otherOrgId, + projectId: otherProjectId, + utcFirstOccurrence: InWindowDate.AddDays(1), + utcLastOccurrence: InWindowDate.AddDays(-1), + totalOccurrences: 0), o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [_eventData.GenerateEvent(otherOrgId, otherProjectId, otherStack.Id, + occurrenceDate: new DateTimeOffset(InWindowDate, TimeSpan.Zero))], + o => o.ImmediateConsistency()); + + // Act: repair only the target org + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, + UtcEnd = DefaultWindowEnd, + OrganizationId = TestConstants.OrganizationId + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + targetStack = await _stackRepository.GetByIdAsync(targetStack.Id); + otherStack = await _stackRepository.GetByIdAsync(otherStack.Id); + + // Assert + Assert.NotNull(targetStack); + Assert.Equal(1, targetStack.TotalOccurrences); // Fixed + + Assert.NotNull(otherStack); + Assert.Equal(0, otherStack.TotalOccurrences); // Not touched — different org + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenStackHasNoEvents_ShouldLeaveCountersUnchanged() + { + // Arrange + // Stack is in the bug window but has no events — should be left as-is. + TimeProvider.SetUtcNow(InWindowDate); + + var stack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + totalOccurrences: 0), o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, + UtcEnd = DefaultWindowEnd + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(0, stack.TotalOccurrences); // Not touched — no events to derive stats from + } + + [Fact] + public async Task RunUntilEmptyAsync_WhenAggregatedTotalIsLowerThanCurrent_ShouldNotDecreaseTotalOccurrences() + { + // Arrange + TimeProvider.SetUtcNow(InWindowDate); + + var occurrenceDate = new DateTime(2026, 2, 15, 12, 0, 0, DateTimeKind.Utc); + + var stack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + utcFirstOccurrence: occurrenceDate, + utcLastOccurrence: occurrenceDate, + totalOccurrences: 10), o => o.ImmediateConsistency()); + + await _eventRepository.AddAsync( + [ + _eventData.GenerateEvent(TestConstants.OrganizationId, TestConstants.ProjectId, stack.Id, + occurrenceDate: new DateTimeOffset(occurrenceDate, TimeSpan.Zero)) + ], + o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new FixStackStatsWorkItem + { + UtcStart = DefaultWindowStart, + UtcEnd = DefaultWindowEnd + }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + stack = await _stackRepository.GetByIdAsync(stack.Id); + + // Assert + Assert.NotNull(stack); + Assert.Equal(10, stack.TotalOccurrences); + } +} diff --git a/tests/Exceptionless.Tests/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandlerTests.cs b/tests/Exceptionless.Tests/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandlerTests.cs new file mode 100644 index 0000000000..edd3d3ee27 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/WorkItemHandlers/UpdateProjectNotificationSettingsWorkItemHandlerTests.cs @@ -0,0 +1,304 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Utility; +using Xunit; + +namespace Exceptionless.Tests.Jobs.WorkItemHandlers; + +public class UpdateProjectNotificationSettingsWorkItemHandlerTests : IntegrationTestsBase +{ + private readonly WorkItemJob _workItemJob; + private readonly IQueue _workItemQueue; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; + private readonly UserData _userData; + private readonly ProjectData _projectData; + + public UpdateProjectNotificationSettingsWorkItemHandlerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _workItemJob = GetService(); + _workItemQueue = GetService>(); + _projectRepository = GetService(); + _userRepository = GetService(); + _userData = GetService(); + _projectData = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithOrphanedUserSettings_RemovesOrphanedEntries() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + int settingsCountBefore = project.NotificationSettings.Count; + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem()); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + Assert.Equal(settingsCountBefore - 1, project.NotificationSettings.Count); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithValidOrgMemberSettings_PreservesSettings() + { + // Arrange + var globalAdmin = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(globalAdmin); + + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + + int settingsCountBefore = project.NotificationSettings.Count; + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem()); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + Assert.Equal(settingsCountBefore, project.NotificationSettings.Count); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithSlackIntegrationKey_PreservesIntegrationSettings() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + project.NotificationSettings[Project.NotificationIntegrations.Slack] = new NotificationSettings { ReportNewErrors = true }; + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem()); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithDeletedUserAccount_RemovesOrphanedEntry() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string deletedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[deletedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + Assert.Null(await _userRepository.GetByIdAsync(deletedUserId)); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem()); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(deletedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithOrganizationIdFilter_OnlyProcessesTargetOrg() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act: cleanup a different org — should NOT affect our project + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId2 }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert: orphaned entry should still exist + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Contains(orphanedUserId, project.NotificationSettings.Keys); + + // Act: now cleanup the correct org + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithMultipleProjectsContainingOrphans_CleansAllProjects() + { + // Arrange: create a second project in the same org + var project1 = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project1); + + var project2 = _projectData.GenerateProject(generateId: true, organizationId: TestConstants.OrganizationId); + project2 = await _projectRepository.AddAsync(project2, o => o.ImmediateConsistency()); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + project1.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + project2.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportCriticalErrors = true }; + await _projectRepository.SaveAsync([project1, project2], o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert: both projects should be cleaned + project1 = await _projectRepository.GetByIdAsync(project1.Id); + project2 = await _projectRepository.GetByIdAsync(project2.Id); + Assert.NotNull(project1); + Assert.NotNull(project2); + Assert.DoesNotContain(orphanedUserId, project1.NotificationSettings.Keys); + Assert.DoesNotContain(orphanedUserId, project2.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithMoreThanOnePageOfProjects_CleansEveryProjectPage() + { + // Arrange + var firstProject = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(firstProject); + + string orphanedUserId = ObjectId.GenerateNewId().ToString(); + firstProject.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(firstProject, o => o.ImmediateConsistency()); + + var additionalProjects = Enumerable.Range(0, 55) + .Select(_ => + { + var project = _projectData.GenerateProject(generateId: true, organizationId: TestConstants.OrganizationId); + project.NotificationSettings[orphanedUserId] = new NotificationSettings { ReportCriticalErrors = true }; + return project; + }) + .ToList(); + + await _projectRepository.AddAsync(additionalProjects, o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + var refreshedProjects = await _projectRepository.GetByIdsAsync([firstProject.Id, ..additionalProjects.Select(project => project.Id)]); + Assert.Equal(56, refreshedProjects.Count); + Assert.All(refreshedProjects, project => Assert.DoesNotContain(orphanedUserId, project.NotificationSettings.Keys)); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithNoOrphans_MakesNoChanges() + { + // Arrange: project only has valid user settings (global admin from sample data) + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + var settingsBefore = new Dictionary(project.NotificationSettings); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem()); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.Equal(settingsBefore.Count, project.NotificationSettings.Count); + foreach (var key in settingsBefore.Keys) + Assert.Contains(key, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithMixedOrphanTypes_RemovesAllOrphansAndPreservesValid() + { + // Arrange + var globalAdmin = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(globalAdmin); + + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + + string deletedUserId = ObjectId.GenerateNewId().ToString(); + string removedFromOrgUserId = ObjectId.GenerateNewId().ToString(); + + var removedUser = _userData.GenerateUser(generateId: true, organizationId: TestConstants.OrganizationId2); + removedUser.Id = removedFromOrgUserId; + removedUser.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + await _userRepository.AddAsync(removedUser, o => o.ImmediateConsistency()); + + project.NotificationSettings[deletedUserId] = new NotificationSettings { ReportNewErrors = true }; + project.NotificationSettings[removedFromOrgUserId] = new NotificationSettings { ReportCriticalErrors = true }; + project.NotificationSettings[Project.NotificationIntegrations.Slack] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(deletedUserId, project.NotificationSettings.Keys); + Assert.DoesNotContain(removedFromOrgUserId, project.NotificationSettings.Keys); + Assert.Contains(globalAdmin.Id, project.NotificationSettings.Keys); + Assert.Contains(Project.NotificationIntegrations.Slack, project.NotificationSettings.Keys); + } + + [Fact] + public async Task RunUntilEmptyAsync_WithUserInDifferentOrganization_RemovesOrphanedEntry() + { + // Arrange: user exists but belongs to a different org, not this one + var otherOrgUser = _userData.GenerateUser(generateId: true, organizationId: TestConstants.OrganizationId2); + otherOrgUser.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + otherOrgUser = await _userRepository.AddAsync(otherOrgUser, o => o.ImmediateConsistency()); + + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + project.NotificationSettings[otherOrgUser.Id] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await _workItemQueue.EnqueueAsync(new UpdateProjectNotificationSettingsWorkItem { OrganizationId = TestConstants.OrganizationId }); + await _workItemJob.RunUntilEmptyAsync(TestCancellationToken); + + // Assert + project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + Assert.DoesNotContain(otherOrgUser.Id, project.NotificationSettings.Keys); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs new file mode 100644 index 0000000000..b5822934b6 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/InvoiceMapperTests.cs @@ -0,0 +1,104 @@ +using Exceptionless.Web.Mapping; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class InvoiceMapperTests +{ + private readonly InvoiceMapper _mapper; + + public InvoiceMapperTests() + { + _mapper = new InvoiceMapper(); + } + + [Fact] + public void MapToInvoiceGridModel_WithValidInvoice_StripsIdPrefix() + { + // Arrange + var source = new Stripe.Invoice + { + Id = "in_abc123", + Created = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc), + Paid = true + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.Equal("abc123", result.Id); + } + + [Fact] + public void MapToInvoiceGridModel_WithValidInvoice_MapsDateAndPaid() + { + // Arrange + var expectedDate = new DateTime(2025, 1, 15, 12, 0, 0, DateTimeKind.Utc); + var source = new Stripe.Invoice + { + Id = "in_5f8a3b2c1d4e", + Created = expectedDate, + Paid = true + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.Equal(expectedDate, result.Date); + Assert.True(result.Paid); + } + + [Fact] + public void MapToInvoiceGridModel_WithUnpaidInvoice_PaidIsFalse() + { + // Arrange + var source = new Stripe.Invoice + { + Id = "in_unpaid", + Created = DateTime.UtcNow, + Paid = false + }; + + // Act + var result = _mapper.MapToInvoiceGridModel(source); + + // Assert + Assert.False(result.Paid); + } + + [Fact] + public void MapToInvoiceGridModels_WithMultipleInvoices_MapsAll() + { + // Arrange + var invoices = new List + { + new() { Id = "in_invoice1", Created = DateTime.UtcNow, Paid = true }, + new() { Id = "in_invoice2", Created = DateTime.UtcNow, Paid = false }, + new() { Id = "in_invoice3", Created = DateTime.UtcNow, Paid = true } + }; + + // Act + var result = _mapper.MapToInvoiceGridModels(invoices); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("invoice1", result[0].Id); + Assert.Equal("invoice2", result[1].Id); + Assert.Equal("invoice3", result[2].Id); + } + + [Fact] + public void MapToInvoiceGridModels_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var invoices = new List(); + + // Act + var result = _mapper.MapToInvoiceGridModels(invoices); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs new file mode 100644 index 0000000000..7875ab22be --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/OrganizationMapperTests.cs @@ -0,0 +1,140 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class OrganizationMapperTests +{ + private readonly OrganizationMapper _mapper; + + public OrganizationMapperTests() + { + _mapper = new OrganizationMapper(TimeProvider.System); + } + + [Fact] + public void MapToOrganization_WithValidNewOrganization_MapsName() + { + // Arrange + var source = new NewOrganization { Name = "Test Organization" }; + + // Act + var result = _mapper.MapToOrganization(source); + + // Assert + Assert.Equal("Test Organization", result.Name); + } + + [Fact] + public void MapToViewOrganization_WithValidOrganization_MapsAllProperties() + { + // Arrange + var source = new Organization + { + Id = "537650f3b77efe23a47914f3", + Name = "Acme Organization", + PlanId = "free", + IsSuspended = false + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.Id); + Assert.Equal("Acme Organization", result.Name); + Assert.Equal("free", result.PlanId); + Assert.False(result.IsSuspended); + } + + [Fact] + public void MapToViewOrganization_WithSuspendedOrganization_MapsIsSuspended() + { + // Arrange + var source = new Organization + { + Id = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", + IsSuspended = true + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.True(result.IsSuspended); + } + + [Fact] + public void MapToViewOrganization_WithSuspensionCode_MapsEnumToString() + { + // Arrange + var source = new Organization + { + Id = "537650f3b77efe23a47914f3", + Name = "Suspended Organization", + IsSuspended = true, + SuspensionCode = SuspensionCode.Billing + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Equal("Billing", result.SuspensionCode); + } + + [Fact] + public void MapToViewOrganization_WithNullSuspensionCode_MapsToNull() + { + // Arrange + var source = new Organization + { + Id = "537650f3b77efe23a47914f3", + Name = "Active Organization", + SuspensionCode = null + }; + + // Act + var result = _mapper.MapToViewOrganization(source); + + // Assert + Assert.Null(result.SuspensionCode); + } + + [Fact] + public void MapToViewOrganizations_WithMultipleOrganizations_MapsAll() + { + // Arrange + var organizations = new List + { + new() { Id = "537650f3b77efe23a47914f3", Name = "Organization 1" }, + new() { Id = "1ecd0826e447ad1e78877666", Name = "Organization 2" }, + new() { Id = "1ecd0826e447ad1e78877777", Name = "Organization 3" } + }; + + // Act + var result = _mapper.MapToViewOrganizations(organizations); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("537650f3b77efe23a47914f3", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877666", result[1].Id); + Assert.Equal("1ecd0826e447ad1e78877777", result[2].Id); + } + + [Fact] + public void MapToViewOrganizations_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var organizations = new List(); + + // Act + var result = _mapper.MapToViewOrganizations(organizations); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs new file mode 100644 index 0000000000..6e2130946a --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/ProjectMapperTests.cs @@ -0,0 +1,123 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class ProjectMapperTests +{ + private readonly ProjectMapper _mapper; + + public ProjectMapperTests() + { + _mapper = new ProjectMapper(); + } + + [Fact] + public void MapToProject_WithValidNewProject_MapsNameAndOrganizationId() + { + // Arrange + var source = new NewProject + { + Name = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3" + }; + + // Act + var result = _mapper.MapToProject(source); + + // Assert + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + } + + [Fact] + public void MapToViewProject_WithValidProject_MapsAllProperties() + { + // Arrange + var source = new Project + { + Id = "537650f3b77efe23a47914f4", + Name = "Disintegrating Pistol", + OrganizationId = "537650f3b77efe23a47914f3", + DeleteBotDataEnabled = true + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f4", result.Id); + Assert.Equal("Disintegrating Pistol", result.Name); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.True(result.DeleteBotDataEnabled); + } + + [Fact] + public void MapToViewProject_WithSlackToken_SetsHasSlackIntegration() + { + // Arrange + var source = new Project + { + Id = "537650f3b77efe23a47914f4", + Name = "Project with Slack", + Data = new DataDictionary { { Project.KnownDataKeys.SlackToken, "test-token" } } + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.True(result.HasSlackIntegration); + } + + [Fact] + public void MapToViewProject_WithoutSlackToken_HasSlackIntegrationIsFalse() + { + // Arrange + var source = new Project + { + Id = "537650f3b77efe23a47914f4", + Name = "Project without Slack" + }; + + // Act + var result = _mapper.MapToViewProject(source); + + // Assert + Assert.False(result.HasSlackIntegration); + } + + [Fact] + public void MapToViewProjects_WithMultipleProjects_MapsAll() + { + // Arrange + var projects = new List + { + new() { Id = "537650f3b77efe23a47914f4", Name = "Project 1" }, + new() { Id = "1ecd0826e447ad1e78877a66", Name = "Project 2" } + }; + + // Act + var result = _mapper.MapToViewProjects(projects); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("537650f3b77efe23a47914f4", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78877a66", result[1].Id); + } + + [Fact] + public void MapToViewProjects_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var projects = new List(); + + // Act + var result = _mapper.MapToViewProjects(projects); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs new file mode 100644 index 0000000000..6ec4059a50 --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/TokenMapperTests.cs @@ -0,0 +1,113 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class TokenMapperTests +{ + private readonly TokenMapper _mapper; + + public TokenMapperTests() + { + _mapper = new TokenMapper(); + } + + [Fact] + public void MapToToken_WithValidNewToken_MapsOrganizationIdAndProjectId() + { + // Arrange + var source = new NewToken + { + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Notes = "API access token" + }; + + // Act + var result = _mapper.MapToToken(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("API access token", result.Notes); + } + + [Fact] + public void MapToToken_TypeNotCarriedFromNewToken_DefaultsToAuthenticationEnumZero() + { + // Arrange + var source = new NewToken + { + OrganizationId = "537650f3b77efe23a47914f3" + }; + + // Act + var result = _mapper.MapToToken(source); + + // Assert - NewToken has no Type property, and TokenMapper explicitly ignores Token.Type + // via [MapperIgnoreTarget], so it stays at the C# enum default (Authentication = 0). + // The controller sets Type = TokenType.Access in AddModelAsync after mapping. + Assert.Equal(TokenType.Authentication, result.Type); + } + + [Fact] + public void MapToViewToken_WithValidToken_MapsAllProperties() + { + // Arrange + var source = new Token + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + UserId = "1ecd0826e447ad1e78822555", + Notes = "Access token notes", + Type = TokenType.Access + }; + + // Act + var result = _mapper.MapToViewToken(source); + + // Assert + Assert.Equal("88cd0826e447a44e78877ab1", result.Id); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("1ecd0826e447ad1e78822555", result.UserId); + Assert.Equal("Access token notes", result.Notes); + } + + [Fact] + public void MapToViewTokens_WithMultipleTokens_MapsAll() + { + // Arrange + var tokens = new List + { + new() { Id = "88cd0826e447a44e78877ab1", OrganizationId = "537650f3b77efe23a47914f3" }, + new() { Id = "88cd0826e447a44e78877ab2", OrganizationId = "1ecd0826e447ad1e78877666" }, + new() { Id = "88cd0826e447a44e78877ab3", OrganizationId = "1ecd0826e447ad1e78877777" } + }; + + // Act + var result = _mapper.MapToViewTokens(tokens); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("88cd0826e447a44e78877ab1", result[0].Id); + Assert.Equal("88cd0826e447a44e78877ab2", result[1].Id); + Assert.Equal("88cd0826e447a44e78877ab3", result[2].Id); + } + + [Fact] + public void MapToViewTokens_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var tokens = new List(); + + // Act + var result = _mapper.MapToViewTokens(tokens); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs new file mode 100644 index 0000000000..3e0ef718aa --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/UserMapperTests.cs @@ -0,0 +1,131 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class UserMapperTests +{ + private readonly UserMapper _mapper; + + public UserMapperTests() + { + _mapper = new UserMapper(); + } + + [Fact] + public void MapToViewUser_WithValidUser_MapsAllProperties() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + FullName = "Eric Smith", + IsEmailAddressVerified = true, + IsActive = true + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Equal("1ecd0826e447ad1e78822555", result.Id); + Assert.Equal("user@localhost", result.EmailAddress); + Assert.Equal("Eric Smith", result.FullName); + Assert.True(result.IsEmailAddressVerified); + Assert.True(result.IsActive); + } + + [Fact] + public void MapToViewUser_WithRoles_MapsRoles() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "admin@localhost", + Roles = new HashSet { "user", "admin" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Contains("user", result.Roles); + Assert.Contains("admin", result.Roles); + } + + [Fact] + public void MapToViewUser_WithOrganizationIds_MapsOrganizationIds() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "user@localhost", + OrganizationIds = new HashSet { "537650f3b77efe23a47914f3", "1ecd0826e447ad1e78877666", "1ecd0826e447ad1e78877777" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + + // Assert + Assert.Equal(3, result.OrganizationIds.Count); + Assert.Contains("537650f3b77efe23a47914f3", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877666", result.OrganizationIds); + Assert.Contains("1ecd0826e447ad1e78877777", result.OrganizationIds); + } + + [Fact] + public void MapToViewUsers_WithMultipleUsers_MapsAll() + { + // Arrange + var users = new List + { + new() { Id = "1ecd0826e447ad1e78822555", EmailAddress = "user1@localhost" }, + new() { Id = "1ecd0826e447ad1e78822666", EmailAddress = "user2@localhost" } + }; + + // Act + var result = _mapper.MapToViewUsers(users); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("1ecd0826e447ad1e78822555", result[0].Id); + Assert.Equal("1ecd0826e447ad1e78822666", result[1].Id); + } + + [Fact] + public void MapToViewUsers_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var users = new List(); + + // Act + var result = _mapper.MapToViewUsers(users); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void MapToViewUser_MutatingRoles_DoesNotAffectSource() + { + // Arrange + var source = new User + { + Id = "1ecd0826e447ad1e78822555", + EmailAddress = "admin@localhost", + Roles = new HashSet { "user", "admin", "global" } + }; + + // Act + var result = _mapper.MapToViewUser(source); + result.Roles.Remove("global"); + + // Assert — source User.Roles is unaffected + Assert.Contains("global", source.Roles); + Assert.DoesNotContain("global", result.Roles); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs new file mode 100644 index 0000000000..0deff4747d --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/WebHookMapperTests.cs @@ -0,0 +1,76 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class WebHookMapperTests +{ + private readonly WebHookMapper _mapper; + + public WebHookMapperTests() + { + _mapper = new WebHookMapper(); + } + + [Fact] + public void MapToWebHook_WithValidNewWebHook_MapsAllProperties() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "537650f3b77efe23a47914f3", + ProjectId = "537650f3b77efe23a47914f4", + Url = "https://localhost/webhook", + EventTypes = ["error", "log"] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("537650f3b77efe23a47914f4", result.ProjectId); + Assert.Equal("https://localhost/webhook", result.Url); + Assert.Contains("error", result.EventTypes); + Assert.Contains("log", result.EventTypes); + } + + [Fact] + public void MapToWebHook_WithNullProjectId_MapsWithNullProjectId() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook" + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Null(result.ProjectId); + Assert.Equal("https://localhost/webhook", result.Url); + } + + [Fact] + public void MapToWebHook_WithEmptyEventTypes_MapsEmptyEventTypes() + { + // Arrange + var source = new NewWebHook + { + OrganizationId = "537650f3b77efe23a47914f3", + Url = "https://localhost/webhook", + EventTypes = [] + }; + + // Act + var result = _mapper.MapToWebHook(source); + + // Assert + Assert.Empty(result.EventTypes); + } +} diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index 74fff49ebc..beac5c5a1f 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -563,8 +563,9 @@ public void CanIndexExtendedData() ev.SetSessionId("123456789"); ev.CopyDataToIndex([]); + Assert.NotNull(ev.Idx); - Assert.False(ev.Idx.ContainsKey("first-name-s")); + Assert.False(ev.Idx!.ContainsKey("first-name-s")); Assert.True(ev.Idx.ContainsKey("isverified-b")); Assert.True(ev.Idx.ContainsKey("isverified1-b")); Assert.True(ev.Idx.ContainsKey("age-n")); diff --git a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs index 6e7ba9c3c4..c8e0cf9528 100644 --- a/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/EventRepositoryTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Text.Json; -using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; diff --git a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index 979497eabf..bd765b8129 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -168,6 +168,50 @@ public async Task CanIncrementEventCounterAsync() Assert.Equal(utcNow.AddDays(1), stack.LastOccurrence); } + [Fact] + public async Task SetEventCounterAsync_WhenIncomingValuesAreOlderOrLower_ShouldOnlyApplyMonotonicUpdates() + { + // Arrange + var originalFirst = new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc); + var originalLast = new DateTime(2026, 2, 16, 0, 0, 0, DateTimeKind.Utc); + var stack = await _repository.AddAsync(_stackData.GenerateStack( + generateId: true, + organizationId: TestConstants.OrganizationId, + projectId: TestConstants.ProjectId, + utcFirstOccurrence: originalFirst, + utcLastOccurrence: originalLast, + totalOccurrences: 10), o => o.ImmediateConsistency()); + // Act + await _repository.SetEventCounterAsync( + stack.Id, + originalFirst.AddDays(1), + originalLast.AddDays(-1), + 5, + sendNotifications: false); + + var unchanged = await _repository.GetByIdAsync(stack.Id); + + // Assert + Assert.Equal(10, unchanged.TotalOccurrences); + Assert.Equal(originalFirst, unchanged.FirstOccurrence); + Assert.Equal(originalLast, unchanged.LastOccurrence); + + // Act + await _repository.SetEventCounterAsync( + stack.Id, + originalFirst.AddDays(-1), + originalLast.AddDays(1), + 15, + sendNotifications: false); + + var updated = await _repository.GetByIdAsync(stack.Id); + + // Assert + Assert.Equal(15, updated.TotalOccurrences); + Assert.Equal(originalFirst.AddDays(-1), updated.FirstOccurrence); + Assert.Equal(originalLast.AddDays(1), updated.LastOccurrence); + } + [Fact] public async Task CanFindManyAsync() { @@ -199,11 +243,16 @@ public async Task GetStacksForCleanupAsync() var openStack10DaysOldWithReference = _stackData.GenerateStack(id: TestConstants.StackId3, utcLastOccurrence: utcNow.SubtractDays(10), status: StackStatus.Open); openStack10DaysOldWithReference.References.Add("test"); - await _repository.AddAsync(new List { - _stackData.GenerateStack(id: TestConstants.StackId, utcLastOccurrence: utcNow.SubtractDays(5), status: StackStatus.Open), - _stackData.GenerateStack(id: TestConstants.StackId2, utcLastOccurrence: utcNow.SubtractDays(10), status: StackStatus.Open), + await _repository.AddAsync( + new List + { + _stackData.GenerateStack(id: TestConstants.StackId, utcLastOccurrence: utcNow.SubtractDays(5), + status: StackStatus.Open), + _stackData.GenerateStack(id: TestConstants.StackId2, utcLastOccurrence: utcNow.SubtractDays(10), + status: StackStatus.Open), openStack10DaysOldWithReference, - _stackData.GenerateStack(id: TestConstants.StackId4, utcLastOccurrence: utcNow.SubtractDays(10), status: StackStatus.Fixed) + _stackData.GenerateStack(id: TestConstants.StackId4, utcLastOccurrence: utcNow.SubtractDays(10), + status: StackStatus.Fixed) }, o => o.ImmediateConsistency()); var stacks = await _repository.GetStacksForCleanupAsync(TestConstants.OrganizationId, utcNow.SubtractDays(8)); diff --git a/tests/Exceptionless.Tests/Serializer/Models/ClientConfigurationSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/ClientConfigurationSerializerTests.cs new file mode 100644 index 0000000000..e585d73ff4 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/ClientConfigurationSerializerTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests ClientConfiguration serialization through ITextSerializer. +/// Critical: Settings property uses init accessor. STJ must populate the +/// SettingsDictionary during deserialization so settings survive round-trips. +/// This is the exact bug that caused empty settings in production. +/// +public class ClientConfigurationSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ClientConfigurationSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesSettings() + { + // Arrange + var config = new ClientConfiguration { Version = 5 }; + config.Settings["IncludeConditionalData"] = "true"; + config.Settings["DataExclusions"] = "password"; + + // Act + string json = _serializer.SerializeToString(config); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(5, deserialized.Version); + Assert.Equal(2, deserialized.Settings.Count); + Assert.True(deserialized.Settings.GetBoolean("IncludeConditionalData")); + Assert.Equal("password", deserialized.Settings.GetString("DataExclusions")); + } + + [Fact] + public void SerializeToString_UsesSnakeCasePropertyNames() + { + // Arrange + var config = new ClientConfiguration { Version = 3 }; + config.Settings["TestKey"] = "TestValue"; + + // Act + string json = _serializer.SerializeToString(config); + + // Assert — property names should be snake_case, dictionary keys preserved + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("version", out _), "Expected snake_case 'version'"); + Assert.False(root.TryGetProperty("Version", out _), "Should not have PascalCase 'Version'"); + Assert.True(root.TryGetProperty("settings", out var settings), "Expected snake_case 'settings'"); + Assert.True(settings.TryGetProperty("TestKey", out var testVal), "Dictionary keys should preserve original casing"); + Assert.Equal("TestValue", testVal.GetString()); + } + + [Fact] + public void Deserialize_EmptySettings_ReturnsEmptyDictionary() + { + // Arrange + /* language=json */ + const string json = """{"version":1,"settings":{}}"""; + + // Act + var config = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(config); + Assert.Equal(1, config.Version); + Assert.NotNull(config.Settings); + Assert.Empty(config.Settings); + } + + [Fact] + public void Deserialize_MissingSettings_DefaultsToEmptyDictionary() + { + // Arrange + /* language=json */ + const string json = """{"version":2}"""; + + // Act + var config = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(config); + Assert.Equal(2, config.Version); + Assert.NotNull(config.Settings); + Assert.Empty(config.Settings); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/DataDictionarySerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/DataDictionarySerializerTests.cs new file mode 100644 index 0000000000..dae967ec08 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/DataDictionarySerializerTests.cs @@ -0,0 +1,148 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests DataDictionary serialization through ITextSerializer. +/// DataDictionary extends Dictionary<string, object?> directly, so STJ +/// handles it natively. These tests guard against regressions. +/// +public class DataDictionarySerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public DataDictionarySerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesEntries() + { + // Arrange + var data = new DataDictionary + { + { "StringKey", "value" }, + { "IntKey", 42 }, + { "BoolKey", true } + }; + + // Act + string json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Count); + Assert.Equal("value", deserialized.GetString("StringKey")); + } + + [Fact] + public void Deserialize_EmptyDictionary_ReturnsEmptyData() + { + // Arrange + /* language=json */ + const string json = """{}"""; + + // Act + var data = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(data); + Assert.Empty(data); + } + + /// + /// Reproduces production bug where JObject/JArray values in DataDictionary + /// (stored by Newtonsoft-based DataObjectConverter when reading from Elasticsearch) + /// serialize as nested empty arrays instead of proper JSON when written by STJ. + /// + [Fact] + public void Serialize_JObjectValue_WritesCorrectJson() + { + // Arrange — simulate Elasticsearch read path storing JObject in DataDictionary + var jObject = JObject.Parse(""" + { + "docsSecondari": [ + { "tipo": "CI", "numero": "AB123" }, + { "tipo": "PP", "numero": "CD456" } + ], + "docPrimario": { "tipo": "DL", "numero": "XY789" }, + "numeroDocumentiSecondari": 2, + "AlreadyImported": true + } + """); + + var data = new DataDictionary { ["TestUfficialeVO"] = jObject }; + + // Act + string json = _serializer.SerializeToString(data); + + // Assert — must contain actual property values, not nested empty arrays + Assert.Contains("docsSecondari", json); + Assert.Contains("CI", json); + Assert.Contains("AB123", json); + Assert.Contains("docPrimario", json); + Assert.Contains("XY789", json); + Assert.DoesNotContain("[[[]]]", json); + } + + /// + /// Verifies JArray values in DataDictionary serialize correctly. + /// + [Fact] + public void Serialize_JArrayValue_WritesCorrectJson() + { + // Arrange — simulate Elasticsearch storing JArray in DataDictionary + var jArray = JArray.Parse("""["tag1", "tag2", "tag3"]"""); + var data = new DataDictionary { ["Tags"] = jArray }; + + // Act + string json = _serializer.SerializeToString(data); + + // Assert + Assert.Contains("tag1", json); + Assert.Contains("tag2", json); + Assert.Contains("tag3", json); + Assert.DoesNotContain("[[]]", json); + } + + /// + /// Verifies deeply nested JObject structures serialize correctly, + /// matching the exact production data pattern that was broken. + /// + [Fact] + public void Serialize_DeeplyNestedJObject_PreservesStructure() + { + // Arrange — nested structure matching production data shape + var jObject = JObject.Parse(""" + { + "items": [ + { + "name": "item1", + "children": [ + { "id": 1, "value": "a" }, + { "id": 2, "value": "b" } + ] + } + ], + "count": 1 + } + """); + + var data = new DataDictionary { ["NestedData"] = jObject }; + + // Act + string json = _serializer.SerializeToString(data); + var deserialized = _serializer.Deserialize(json); + + // Assert — roundtrip preserves structure + Assert.NotNull(deserialized); + Assert.Contains("item1", json); + Assert.Contains("\"id\"", json); + Assert.Contains("\"value\"", json); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/ExtendedEntityChangedSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/ExtendedEntityChangedSerializerTests.cs new file mode 100644 index 0000000000..0544b65ffb --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/ExtendedEntityChangedSerializerTests.cs @@ -0,0 +1,73 @@ +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Models; +using Foundatio.Repositories.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests ExtendedEntityChanged serialization through ITextSerializer. +/// ExtendedEntityChanged has private set properties and a private constructor. +/// It is created via the Create() factory method but goes through message bus +/// serialization (ISerializer → STJ) in production (Redis). +/// +public class ExtendedEntityChangedSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ExtendedEntityChangedSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesProperties() + { + // Arrange + var entityChanged = new EntityChanged + { + Id = "abc123", + Type = typeof(Project).Name, + ChangeType = ChangeType.Saved, + Data = { { "OrganizationId", "org1" }, { "ProjectId", "proj1" }, { "StackId", "stack1" } } + }; + var model = ExtendedEntityChanged.Create(entityChanged); + + // Act + string json = _serializer.SerializeToString(model); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("abc123", deserialized.Id); + Assert.Equal("org1", deserialized.OrganizationId); + Assert.Equal("proj1", deserialized.ProjectId); + Assert.Equal("stack1", deserialized.StackId); + } + + [Fact] + public void Deserialize_RoundTrip_WithPartialData_PreservesAvailableProperties() + { + // Arrange — not all entity changes have all three IDs + var entityChanged = new EntityChanged + { + Id = "def456", + Type = typeof(Project).Name, + ChangeType = ChangeType.Removed, + Data = { { "OrganizationId", "org1" } } + }; + var model = ExtendedEntityChanged.Create(entityChanged); + + // Act + string json = _serializer.SerializeToString(model); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("def456", deserialized.Id); + Assert.Equal("org1", deserialized.OrganizationId); + Assert.Null(deserialized.ProjectId); + Assert.Null(deserialized.StackId); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/GenericArgumentsSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/GenericArgumentsSerializerTests.cs new file mode 100644 index 0000000000..b0c3f62a45 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/GenericArgumentsSerializerTests.cs @@ -0,0 +1,68 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests GenericArguments serialization through ITextSerializer. +/// GenericArguments extends Collection<string> directly. +/// +public class GenericArgumentsSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public GenericArgumentsSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesAllArguments() + { + // Arrange + var args = new GenericArguments { "TEvent", "TResult", "CancellationToken" }; + + // Act + string json = _serializer.SerializeToString(args); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Count); + Assert.Equal("TEvent", deserialized[0]); + Assert.Equal("TResult", deserialized[1]); + Assert.Equal("CancellationToken", deserialized[2]); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyCollection() + { + // Arrange + /* language=json */ + const string json = """[]"""; + + // Act + var args = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(args); + Assert.Empty(args); + } + + [Fact] + public void Deserialize_SingleArgument_RoundTrips() + { + // Arrange + var args = new GenericArguments { "Task`1" }; + + // Act + string json = _serializer.SerializeToString(args); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Single(deserialized); + Assert.Equal("Task`1", deserialized[0]); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/MessageContentSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/MessageContentSerializerTests.cs new file mode 100644 index 0000000000..d9deebc742 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/MessageContentSerializerTests.cs @@ -0,0 +1,52 @@ +using Exceptionless.Web.Utility.Results; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests MessageContent serialization through ITextSerializer. +/// MessageContent is a record returned from API endpoints. +/// Validates that the record primary constructor properties serialize correctly. +/// +public class MessageContentSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public MessageContentSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesProperties() + { + // Arrange + var message = new MessageContent("id123", "Something happened"); + + // Act + string json = _serializer.SerializeToString(message); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("id123", deserialized.Id); + Assert.Equal("Something happened", deserialized.Message); + } + + [Fact] + public void Deserialize_RoundTrip_WithNullId_PreservesMessage() + { + // Arrange + var message = new MessageContent("Operation completed successfully"); + + // Act + string json = _serializer.SerializeToString(message); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Null(deserialized.Id); + Assert.Equal("Operation completed successfully", deserialized.Message); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/ModuleCollectionSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/ModuleCollectionSerializerTests.cs new file mode 100644 index 0000000000..f8e191f37f --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/ModuleCollectionSerializerTests.cs @@ -0,0 +1,107 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests ModuleCollection serialization through ITextSerializer. +/// ModuleCollection extends Collection<Module> directly. +/// +public class ModuleCollectionSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ModuleCollectionSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesAllModules() + { + // Arrange + var collection = new ModuleCollection + { + new() + { + ModuleId = 1, + Name = "Exceptionless.Core", + Version = "8.1.0", + IsEntry = true, + CreatedDate = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc), + ModifiedDate = new DateTime(2026, 2, 10, 14, 0, 0, DateTimeKind.Utc), + Data = new DataDictionary { ["PublicKeyToken"] = "b77a5c561934e089" } + }, + new() + { + ModuleId = 2, + Name = "Foundatio", + Version = "11.0.0", + IsEntry = false + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + + Assert.Equal(1, deserialized[0].ModuleId); + Assert.Equal("Exceptionless.Core", deserialized[0].Name); + Assert.Equal("8.1.0", deserialized[0].Version); + Assert.True(deserialized[0].IsEntry); + Assert.NotNull(deserialized[0].CreatedDate); + Assert.NotNull(deserialized[0].ModifiedDate); + Assert.NotNull(deserialized[0].Data); + + Assert.Equal(2, deserialized[1].ModuleId); + Assert.Equal("Foundatio", deserialized[1].Name); + Assert.False(deserialized[1].IsEntry); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyCollection() + { + // Arrange + /* language=json */ + const string json = """[]"""; + + // Act + var collection = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(collection); + Assert.Empty(collection); + } + + [Fact] + public void SerializeToString_UsesSnakeCasePropertyNames() + { + // Arrange + var collection = new ModuleCollection + { + new() + { + ModuleId = 42, + Name = "System.Runtime", + IsEntry = false, + CreatedDate = DateTime.UtcNow + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + + // Assert + Assert.Contains("module_id", json); + Assert.Contains("is_entry", json); + Assert.Contains("created_date", json); + Assert.DoesNotContain("ModuleId", json); + Assert.DoesNotContain("IsEntry", json); + Assert.DoesNotContain("CreatedDate", json); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/ParameterCollectionSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/ParameterCollectionSerializerTests.cs new file mode 100644 index 0000000000..919b3363d3 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/ParameterCollectionSerializerTests.cs @@ -0,0 +1,102 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests ParameterCollection serialization through ITextSerializer. +/// ParameterCollection extends Collection<Parameter> directly. +/// +public class ParameterCollectionSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ParameterCollectionSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesAllParameters() + { + // Arrange + var collection = new ParameterCollection + { + new() + { + Name = "context", + Type = "PipelineContext", + TypeNamespace = "Exceptionless.Core.Pipeline", + Data = new DataDictionary { ["IsValid"] = true }, + GenericArguments = new GenericArguments { "EventContext" } + }, + new() + { + Name = "cancellationToken", + Type = "CancellationToken", + TypeNamespace = "System.Threading" + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + + Assert.Equal("context", deserialized[0].Name); + Assert.Equal("PipelineContext", deserialized[0].Type); + Assert.Equal("Exceptionless.Core.Pipeline", deserialized[0].TypeNamespace); + Assert.NotNull(deserialized[0].Data); + Assert.NotNull(deserialized[0].GenericArguments); + Assert.Single(deserialized[0].GenericArguments!); + Assert.Equal("EventContext", deserialized[0].GenericArguments![0]); + + Assert.Equal("cancellationToken", deserialized[1].Name); + Assert.Equal("CancellationToken", deserialized[1].Type); + Assert.Equal("System.Threading", deserialized[1].TypeNamespace); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyCollection() + { + // Arrange + /* language=json */ + const string json = """[]"""; + + // Act + var collection = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(collection); + Assert.Empty(collection); + } + + [Fact] + public void SerializeToString_UsesSnakeCasePropertyNames() + { + // Arrange + var collection = new ParameterCollection + { + new() + { + Name = "request", + Type = "HttpRequest", + TypeNamespace = "Microsoft.AspNetCore.Http", + GenericArguments = new GenericArguments { "string", "int" } + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + + // Assert + Assert.Contains("type_namespace", json); + Assert.Contains("generic_arguments", json); + Assert.DoesNotContain("TypeNamespace", json); + Assert.DoesNotContain("GenericArguments", json); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs index 5dafe0748c..8d77001c8a 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/PersistentEventSerializerTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; using Foundatio.Serializer; diff --git a/tests/Exceptionless.Tests/Serializer/Models/ProjectSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/ProjectSerializerTests.cs new file mode 100644 index 0000000000..b512a647f6 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/ProjectSerializerTests.cs @@ -0,0 +1,72 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests Project serialization through ITextSerializer. +/// Critical: Project.Configuration contains SettingsDictionary which must +/// survive round-trip through the cache serializer (STJ). +/// +public class ProjectSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public ProjectSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesConfigurationSettings() + { + // Arrange — simulates what the cache serializer does in ProjectRepository.GetConfigAsync + var project = new Project + { + Id = "test-project-id", + OrganizationId = "test-org-id", + Name = "Test Project" + }; + project.Configuration.Version = 10; + project.Configuration.Settings["IncludeConditionalData"] = "true"; + project.Configuration.Settings["DataExclusions"] = "password,secret"; + + // Act + string json = _serializer.SerializeToString(project); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("test-project-id", deserialized.Id); + Assert.Equal(10, deserialized.Configuration.Version); + Assert.Equal(2, deserialized.Configuration.Settings.Count); + Assert.True(deserialized.Configuration.Settings.GetBoolean("IncludeConditionalData")); + Assert.Equal("password,secret", deserialized.Configuration.Settings.GetString("DataExclusions")); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesBasicProperties() + { + // Arrange + var project = new Project + { + Id = "proj1", + OrganizationId = "org1", + Name = "My Project", + NextSummaryEndOfDayTicks = 637500000000000000, + IsConfigured = true + }; + + // Act + string json = _serializer.SerializeToString(project); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("proj1", deserialized.Id); + Assert.Equal("org1", deserialized.OrganizationId); + Assert.Equal("My Project", deserialized.Name); + Assert.True(deserialized.IsConfigured); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/SettingsDictionarySerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/SettingsDictionarySerializerTests.cs new file mode 100644 index 0000000000..a3128df05d --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/SettingsDictionarySerializerTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests SettingsDictionary serialization through ITextSerializer. +/// Critical: SettingsDictionary extends ObservableDictionary which implements +/// IDictionary via composition (not inheritance). STJ must serialize it as a +/// flat dictionary, not as an empty object. +/// +public class SettingsDictionarySerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public SettingsDictionarySerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void SerializeToString_WithEntries_SerializesAsFlatDictionary() + { + // Arrange + var settings = new SettingsDictionary + { + { "IncludeConditionalData", "true" }, + { "DataExclusions", "password,secret" } + }; + + // Act + string json = _serializer.SerializeToString(settings); + + // Assert — should be a flat dictionary, not a wrapped object + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.Object, root.ValueKind); + Assert.True(root.TryGetProperty("IncludeConditionalData", out var includeVal)); + Assert.Equal("true", includeVal.GetString()); + Assert.True(root.TryGetProperty("DataExclusions", out var exclusionsVal)); + Assert.Equal("password,secret", exclusionsVal.GetString()); + } + + [Fact] + public void Deserialize_FlatDictionaryJson_PopulatesEntries() + { + // Arrange + /* language=json */ + const string json = """{"IncludeConditionalData":"true","DataExclusions":"password,secret"}"""; + + // Act + var settings = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(settings); + Assert.Equal(2, settings.Count); + Assert.Equal("true", settings.GetString("IncludeConditionalData")); + Assert.Equal("password,secret", settings.GetString("DataExclusions")); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesAllEntries() + { + // Arrange + var original = new SettingsDictionary + { + { "BoolSetting", "true" }, + { "IntSetting", "42" }, + { "StringSetting", "hello" } + }; + + // Act + string json = _serializer.SerializeToString(original); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Count, deserialized.Count); + Assert.True(deserialized.GetBoolean("BoolSetting")); + Assert.Equal(42, deserialized.GetInt32("IntSetting")); + Assert.Equal("hello", deserialized.GetString("StringSetting")); + } + + [Fact] + public void Deserialize_EmptyDictionary_ReturnsEmptySettings() + { + // Arrange + /* language=json */ + const string json = """{}"""; + + // Act + var settings = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(settings); + Assert.Empty(settings); + } + + [Fact] + public void SerializeToString_PreservesOriginalKeyCasing() + { + // Arrange — dictionary keys should NOT be snake_cased + var settings = new SettingsDictionary + { + { "@@DataExclusions", "password" }, + { "IncludePrivateInformation", "true" }, + { "MyCustomSetting", "value" } + }; + + // Act + string json = _serializer.SerializeToString(settings); + + // Assert + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("@@DataExclusions", out _)); + Assert.True(root.TryGetProperty("IncludePrivateInformation", out _)); + Assert.True(root.TryGetProperty("MyCustomSetting", out _)); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/StackFrameCollectionSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/StackFrameCollectionSerializerTests.cs new file mode 100644 index 0000000000..08e11c47c2 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/StackFrameCollectionSerializerTests.cs @@ -0,0 +1,104 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests StackFrameCollection serialization through ITextSerializer. +/// StackFrameCollection extends Collection<StackFrame> directly. +/// Individual StackFrame serialization is covered in StackFrameSerializerTests. +/// +public class StackFrameCollectionSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public StackFrameCollectionSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesAllFrames() + { + // Arrange + var collection = new StackFrameCollection + { + new() + { + Name = "ProcessEventAsync", + DeclaringNamespace = "Exceptionless.Core.Pipeline", + DeclaringType = "EventPipeline", + FileName = "EventPipeline.cs", + LineNumber = 142, + Column = 25 + }, + new() + { + Name = "ExecuteAsync", + DeclaringNamespace = "Exceptionless.Core.Jobs", + DeclaringType = "EventPostsJob", + FileName = "EventPostsJob.cs", + LineNumber = 88 + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + Assert.Equal("ProcessEventAsync", deserialized[0].Name); + Assert.Equal("EventPipeline.cs", deserialized[0].FileName); + Assert.Equal(142, deserialized[0].LineNumber); + Assert.Equal(25, deserialized[0].Column); + Assert.Equal("ExecuteAsync", deserialized[1].Name); + Assert.Equal("EventPostsJob.cs", deserialized[1].FileName); + Assert.Equal(88, deserialized[1].LineNumber); + Assert.Null(deserialized[1].Column); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyCollection() + { + // Arrange + /* language=json */ + const string json = """[]"""; + + // Act + var collection = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(collection); + Assert.Empty(collection); + } + + [Fact] + public void SerializeToString_UsesSnakeCasePropertyNames() + { + // Arrange + var collection = new StackFrameCollection + { + new() + { + Name = "Main", + DeclaringType = "Program", + FileName = "Program.cs", + LineNumber = 10 + } + }; + + // Act + string json = _serializer.SerializeToString(collection); + + // Assert + Assert.Contains("declaring_type", json); + Assert.Contains("file_name", json); + Assert.Contains("line_number", json); + Assert.DoesNotContain("DeclaringType", json); + Assert.DoesNotContain("FileName", json); + Assert.DoesNotContain("LineNumber", json); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/Models/TagSetSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/TagSetSerializerTests.cs new file mode 100644 index 0000000000..30fbed387c --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/TagSetSerializerTests.cs @@ -0,0 +1,56 @@ +using Exceptionless.Core.Models; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +/// +/// Tests TagSet serialization through ITextSerializer. +/// TagSet extends HashSet<string?> directly, so STJ handles it natively. +/// These tests guard against regressions. +/// +public class TagSetSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public TagSetSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_RoundTrip_PreservesValues() + { + // Arrange + var tags = new TagSet(); + tags.Add("Error"); + tags.Add("Critical"); + tags.Add("Production"); + + // Act + string json = _serializer.SerializeToString(tags); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Count); + Assert.Contains("Error", deserialized); + Assert.Contains("Critical", deserialized); + Assert.Contains("Production", deserialized); + } + + [Fact] + public void Deserialize_EmptyArray_ReturnsEmptyTagSet() + { + // Arrange + /* language=json */ + const string json = """[]"""; + + // Act + var tags = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(tags); + Assert.Empty(tags); + } +} diff --git a/tests/Exceptionless.Tests/Serializer/SerializerTests.cs b/tests/Exceptionless.Tests/Serializer/SerializerTests.cs index f3a1565100..9d9d6f51f5 100644 --- a/tests/Exceptionless.Tests/Serializer/SerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/SerializerTests.cs @@ -1,6 +1,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Services; using Exceptionless.Serializer; using Foundatio.Repositories.Extensions; using Foundatio.Serializer; @@ -12,8 +13,11 @@ namespace Exceptionless.Tests.Serializer; public class SerializerTests : TestWithServices { + private readonly ITextSerializer _serializer; + public SerializerTests(ITestOutputHelper output) : base(output) { + _serializer = GetService(); } [Fact] @@ -115,11 +119,10 @@ public void CanDeserializeWebHook() Version = WebHook.KnownVersions.Version2 }; - var serializer = GetService(); - string json = serializer.SerializeToString(hook); + string json = _serializer.SerializeToString(hook); Assert.Equal("{\"id\":\"test\",\"event_types\":[\"NewError\"],\"is_enabled\":true,\"version\":\"v2\",\"created_utc\":\"0001-01-01T00:00:00\"}", json); - var model = serializer.Deserialize(json); + var model = _serializer.Deserialize(json); Assert.Equal(hook.Id, model.Id); Assert.Equal(hook.EventTypes, model.EventTypes); Assert.Equal(hook.Version, model.Version); @@ -130,14 +133,205 @@ public void CanDeserializeProject() { string json = "{\"last_event_date_utc\":\"2020-10-18T20:54:04.3457274+01:00\", \"created_utc\":\"0001-01-01T00:00:00\",\"updated_utc\":\"2020-09-21T04:41:32.7458321Z\"}"; - var serializer = GetService(); - var model = serializer.Deserialize(json); + var model = _serializer.Deserialize(json); Assert.NotNull(model); Assert.NotNull(model.LastEventDateUtc); Assert.NotEqual(DateTime.MinValue, model.LastEventDateUtc); Assert.Equal(DateTime.MinValue, model.CreatedUtc); Assert.NotEqual(DateTime.MinValue, model.UpdatedUtc); } + + [Fact] + public void SerializeToString_ValueTupleOfStrings_SerializesFields() + { + // Arrange — with IncludeFields=true, ValueTuple fields are serialized. + // Compile-time names (OrganizationId, etc.) are erased at runtime; fields are always Item1/Item2/Item3. + // LowerCaseUnderscoreNamingPolicy converts Item1 → item1, Item2 → item2, Item3 → item3. + var tuple = (OrganizationId: "org1", ProjectId: "proj1", StackId: "stack1"); + + // Act + string json = _serializer.SerializeToString(tuple); + + // Assert + Assert.Equal("{\"item1\":\"org1\",\"item2\":\"proj1\",\"item3\":\"stack1\"}", json); + } + + [Fact] + public void SerializeToString_ValueTupleOfInts_SerializesFields() + { + // Arrange + var tuple = (A: 1, B: 2, C: 3); + + // Act + string json = _serializer.SerializeToString(tuple); + + // Assert + Assert.Equal("{\"item1\":1,\"item2\":2,\"item3\":3}", json); + } + + [Fact] + public void SerializeToString_ValueTupleOfMixed_SerializesFields() + { + // Arrange + var tuple = (Name: "test", Count: 42, Active: true); + + // Act + string json = _serializer.SerializeToString(tuple); + + // Assert + Assert.Equal("{\"item1\":\"test\",\"item2\":42,\"item3\":true}", json); + } + + [Fact] + public void SerializeToString_TwoDistinctValueTuples_ProduceDifferentJson() + { + // Arrange + var tuple1 = (OrgId: "org1", ProjId: "proj1", StackId: "stack1"); + var tuple2 = (OrgId: "org2", ProjId: "proj2", StackId: "stack2"); + + // Act + string json1 = _serializer.SerializeToString(tuple1); + string json2 = _serializer.SerializeToString(tuple2); + + // Assert — distinct tuples produce distinct JSON (no Redis sorted-set collision) + Assert.Equal("{\"item1\":\"org1\",\"item2\":\"proj1\",\"item3\":\"stack1\"}", json1); + Assert.Equal("{\"item1\":\"org2\",\"item2\":\"proj2\",\"item3\":\"stack2\"}", json2); + } + + [Fact] + public void SerializeToString_ValueTuple_UsesGenericFieldNames() + { + // Arrange — ValueTuple field names are erased at runtime; fields are always Item1/Item2/Item3 + // regardless of the compile-time names. Records preserve named properties (organization_id, etc.), + // making them the correct choice for serialized cache keys. + var tuple = (OrganizationId: "org1", ProjectId: "proj1", StackId: "stack1"); + + // Act + string json = _serializer.SerializeToString(tuple); + + // Assert — item1/item2/item3, NOT organization_id/project_id/stack_id + Assert.Equal("{\"item1\":\"org1\",\"item2\":\"proj1\",\"item3\":\"stack1\"}", json); + } + + [Fact] + public void SerializeToString_StructWithFields_SerializesFields() + { + // Arrange — with IncludeFields=true, structs with public fields serialize correctly + var value = new FieldOnlyStruct { Key = "abc", Value = 42 }; + + // Act + string json = _serializer.SerializeToString(value); + + // Assert + Assert.Equal("{\"key\":\"abc\",\"value\":42}", json); + } + + [Fact] + public void SerializeToString_StackUsageKey_RoundtripsCorrectly() + { + // Arrange + var key = new StackUsageKey("org1", "proj1", "stack1"); + + // Act + string json = _serializer.SerializeToString(key); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.Equal("{\"organization_id\":\"org1\",\"project_id\":\"proj1\",\"stack_id\":\"stack1\"}", json); + Assert.Equal(key, deserialized); + } + + [Fact] + public void SerializeToString_DistinctStackUsageKeys_ProduceDifferentJson() + { + // Arrange + var key1 = new StackUsageKey("org1", "proj1", "stack1"); + var key2 = new StackUsageKey("org2", "proj2", "stack2"); + + // Act + string json1 = _serializer.SerializeToString(key1); + string json2 = _serializer.SerializeToString(key2); + + // Assert + Assert.NotEqual(json1, json2); + } + + [Fact] + public void SerializeToString_RecordStruct_RoundtripsCorrectly() + { + // Arrange + var value = new SampleRecordStruct("key1", 42); + + // Act + string json = _serializer.SerializeToString(value); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.Equal("{\"key\":\"key1\",\"value\":42}", json); + Assert.Equal(value, deserialized); + } + + [Fact] + public void SerializeToString_ClassWithProperties_RoundtripsCorrectly() + { + // Arrange + var value = new SampleClass { Name = "test", Count = 7 }; + + // Act + string json = _serializer.SerializeToString(value); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("{\"name\":\"test\",\"count\":7}", json); + Assert.Equal("test", deserialized.Name); + Assert.Equal(7, deserialized.Count); + } + + [Fact] + public void SerializeToString_PrimitiveTypes_RoundtripCorrectly() + { + // Act & Assert — each primitive type verified inline + Assert.Equal("42", _serializer.SerializeToString(42)); + Assert.Equal("42", _serializer.Deserialize("42").ToString()); + + Assert.Equal("99", _serializer.SerializeToString(99L)); + Assert.Equal(99L, _serializer.Deserialize("99")); + + Assert.Equal("true", _serializer.SerializeToString(true)); + Assert.True(_serializer.Deserialize("true")); + + string roundtripped = _serializer.Deserialize(_serializer.SerializeToString("hello")); + Assert.Equal("hello", roundtripped); + } + + [Fact] + public void SerializeToString_DateTime_RoundtripsCorrectly() + { + // Arrange + var dt = new DateTime(2026, 2, 22, 12, 0, 0, DateTimeKind.Utc); + + // Act + string json = _serializer.SerializeToString(dt); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.Equal(dt, deserialized); + } + + public struct FieldOnlyStruct + { + public string Key; + public int Value; + } + + public record struct SampleRecordStruct(string Key, int Value); + + public class SampleClass + { + public string Name { get; set; } = ""; + public int Count { get; set; } + } } public record SomeModel diff --git a/tests/Exceptionless.Tests/Services/StackServiceTests.cs b/tests/Exceptionless.Tests/Services/StackServiceTests.cs index db1e001618..a20a959201 100644 --- a/tests/Exceptionless.Tests/Services/StackServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/StackServiceTests.cs @@ -39,7 +39,7 @@ public async Task IncrementUsage_OnlyChangeCache() Assert.Equal(DateTime.MinValue, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMinDateCacheKey(stack.Id))); Assert.Equal(DateTime.MinValue, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMaxDateCacheKey(stack.Id))); Assert.Equal(0, await _cache.GetAsync(StackService.GetStackOccurrenceCountCacheKey(stack.Id), 0)); - var occurrenceSet = await _cache.GetListAsync<(string OrganizationId, string ProjectId, string StackId)>(StackService.GetStackOccurrenceSetCacheKey()); + var occurrenceSet = await _cache.GetListAsync(StackService.GetStackOccurrenceSetCacheKey()); Assert.True(occurrenceSet.IsNull || !occurrenceSet.HasValue || occurrenceSet.Value.Count == 0); var firstUtcNow = DateTime.UtcNow.Floor(TimeSpan.FromMilliseconds(1)); @@ -56,7 +56,7 @@ public async Task IncrementUsage_OnlyChangeCache() Assert.Equal(firstUtcNow, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMinDateCacheKey(stack.Id))); Assert.Equal(firstUtcNow, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMaxDateCacheKey(stack.Id))); Assert.Equal(1, await _cache.GetAsync(StackService.GetStackOccurrenceCountCacheKey(stack.Id), 0)); - occurrenceSet = await _cache.GetListAsync<(string OrganizationId, string ProjectId, string StackId)>(StackService.GetStackOccurrenceSetCacheKey()); + occurrenceSet = await _cache.GetListAsync(StackService.GetStackOccurrenceSetCacheKey()); Assert.Single(occurrenceSet.Value); var secondUtcNow = DateTime.UtcNow.Floor(TimeSpan.FromMilliseconds(1)); @@ -66,7 +66,7 @@ public async Task IncrementUsage_OnlyChangeCache() Assert.Equal(firstUtcNow, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMinDateCacheKey(stack.Id))); Assert.Equal(secondUtcNow, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMaxDateCacheKey(stack.Id))); Assert.Equal(3, await _cache.GetAsync(StackService.GetStackOccurrenceCountCacheKey(stack.Id), 0)); - occurrenceSet = await _cache.GetListAsync<(string OrganizationId, string ProjectId, string StackId)>(StackService.GetStackOccurrenceSetCacheKey()); + occurrenceSet = await _cache.GetListAsync(StackService.GetStackOccurrenceSetCacheKey()); Assert.Single(occurrenceSet.Value); } @@ -105,7 +105,7 @@ public async Task IncrementUsageConcurrently() Assert.Equal(maxOccurrenceDate, await _cache.GetUnixTimeMillisecondsAsync(StackService.GetStackOccurrenceMaxDateCacheKey(stack2.Id))); Assert.Equal(200, await _cache.GetAsync(StackService.GetStackOccurrenceCountCacheKey(stack2.Id), 0)); - var occurrenceSet = await _cache.GetListAsync<(string OrganizationId, string ProjectId, string StackId)>(StackService.GetStackOccurrenceSetCacheKey()); + var occurrenceSet = await _cache.GetListAsync(StackService.GetStackOccurrenceSetCacheKey()); Assert.Equal(2, occurrenceSet.Value.Count); async Task IncrementUsageBatch() diff --git a/tests/http/admin.http b/tests/http/admin.http index e0f7742354..bfc3249eca 100644 --- a/tests/http/admin.http +++ b/tests/http/admin.http @@ -85,3 +85,27 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/admin/maintenance/update-project-default-bot-lists Authorization: Bearer {{token}} +### Fix Stack Stats (defaults: utcStart=now-90d, utcEnd=null=>now) +GET {{apiUrl}}/admin/maintenance/fix-stack-stats +Authorization: Bearer {{token}} + +### Fix Stack Stats (explicit UTC window) +GET {{apiUrl}}/admin/maintenance/fix-stack-stats?utcStart=2026-02-10T00:00:00Z&utcEnd=2026-02-23T00:00:00Z +Authorization: Bearer {{token}} + +### Admin Stats +GET {{apiUrl}}/admin/stats +Authorization: Bearer {{token}} + +### Migrations +GET {{apiUrl}}/admin/migrations +Authorization: Bearer {{token}} + +### Elasticsearch Cluster Info +GET {{apiUrl}}/admin/elasticsearch +Authorization: Bearer {{token}} + +### Elasticsearch Snapshots +GET {{apiUrl}}/admin/elasticsearch/snapshots +Authorization: Bearer {{token}} +