From 535d23f295c6283997f4e9303bffdac1ae12206a Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 8 Sep 2026 23:50:29 +0530 Subject: [PATCH 01/20] feat(local): Add browser viewer for local telemetry Add the Local Vite viewer and --open support for serve and run. The receiver exposes the hosted UI only as a read-only loopback SSE stream with Private Network Access support. --- .../src/content/docs/agent-guidance.md | 4 + apps/local/.gitignore | 24 + apps/local/.oxlintrc.json | 8 + apps/local/components.json | 21 + apps/local/index.html | 14 + apps/local/package.json | 43 + apps/local/public/favicon.svg | 53 + apps/local/public/sentry-cli-light.svg | 8 + apps/local/public/sentry-cli.svg | 8 + apps/local/src/App.integration.test.tsx | 110 ++ apps/local/src/App.tsx | 226 +++ apps/local/src/components/theme-provider.tsx | 6 + apps/local/src/components/theme-toggle.tsx | 21 + apps/local/src/components/ui/badge.tsx | 29 + apps/local/src/components/ui/button.tsx | 42 + apps/local/src/components/ui/card.tsx | 29 + apps/local/src/index.css | 78 + apps/local/src/lib/presentation.test.ts | 18 + apps/local/src/lib/presentation.ts | 21 + apps/local/src/lib/spotlight.test.ts | 83 + apps/local/src/lib/spotlight.ts | 105 ++ apps/local/src/lib/utils.ts | 6 + apps/local/src/main.tsx | 13 + apps/local/tsconfig.app.json | 32 + apps/local/tsconfig.json | 14 + apps/local/tsconfig.node.json | 23 + apps/local/vite.config.ts | 21 + .../sentry-cli/skills/sentry-cli/SKILL.md | 4 + .../skills/sentry-cli/references/local.md | 2 + packages/cli/src/commands/local/run.ts | 29 +- packages/cli/src/commands/local/server.ts | 103 +- packages/cli/src/commands/local/ui.ts | 84 + packages/cli/test/commands/local/run.test.ts | 73 + .../cli/test/commands/local/server.test.ts | 192 ++- packages/cli/test/commands/local/ui.test.ts | 57 + pnpm-lock.yaml | 1473 ++++++++++++++++- 36 files changed, 3019 insertions(+), 58 deletions(-) create mode 100644 apps/local/.gitignore create mode 100644 apps/local/.oxlintrc.json create mode 100644 apps/local/components.json create mode 100644 apps/local/index.html create mode 100644 apps/local/package.json create mode 100644 apps/local/public/favicon.svg create mode 100644 apps/local/public/sentry-cli-light.svg create mode 100644 apps/local/public/sentry-cli.svg create mode 100644 apps/local/src/App.integration.test.tsx create mode 100644 apps/local/src/App.tsx create mode 100644 apps/local/src/components/theme-provider.tsx create mode 100644 apps/local/src/components/theme-toggle.tsx create mode 100644 apps/local/src/components/ui/badge.tsx create mode 100644 apps/local/src/components/ui/button.tsx create mode 100644 apps/local/src/components/ui/card.tsx create mode 100644 apps/local/src/index.css create mode 100644 apps/local/src/lib/presentation.test.ts create mode 100644 apps/local/src/lib/presentation.ts create mode 100644 apps/local/src/lib/spotlight.test.ts create mode 100644 apps/local/src/lib/spotlight.ts create mode 100644 apps/local/src/lib/utils.ts create mode 100644 apps/local/src/main.tsx create mode 100644 apps/local/tsconfig.app.json create mode 100644 apps/local/tsconfig.json create mode 100644 apps/local/tsconfig.node.json create mode 100644 apps/local/vite.config.ts create mode 100644 packages/cli/src/commands/local/ui.ts create mode 100644 packages/cli/test/commands/local/ui.test.ts diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 78041cbd29..d712ed9218 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -125,6 +125,10 @@ sentry log list --query "severity:error" # SDK sends to both. sentry local run -- npm run dev # or: python manage.py runserver, etc. +# From a CLI source checkout, run the Local UI in a second terminal, then open it. +pnpm --filter local dev +sentry local run --open -- npm run dev + # Watch only AI/agent (gen_ai, mcp) spans while iterating on an agent. sentry local -f ai diff --git a/apps/local/.gitignore b/apps/local/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/apps/local/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/local/.oxlintrc.json b/apps/local/.oxlintrc.json new file mode 100644 index 0000000000..6fa991dad2 --- /dev/null +++ b/apps/local/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/local/components.json b/apps/local/components.json new file mode 100644 index 0000000000..8bfc737f96 --- /dev/null +++ b/apps/local/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/local/index.html b/apps/local/index.html new file mode 100644 index 0000000000..7315287091 --- /dev/null +++ b/apps/local/index.html @@ -0,0 +1,14 @@ + + + + + + + + Sentry CLI + + +
+ + + diff --git a/apps/local/package.json b/apps/local/package.json new file mode 100644 index 0000000000..546a27204e --- /dev/null +++ b/apps/local/package.json @@ -0,0 +1,43 @@ +{ + "name": "local", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:integration": "vitest run src/App.integration.test.tsx" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.3.3", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.43.0", + "next-themes": "^0.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@spotlightjs/spotlight": "^4.11.7", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eventsource": "^4.1.0", + "jsdom": "^28.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.9" + } +} diff --git a/apps/local/public/favicon.svg b/apps/local/public/favicon.svg new file mode 100644 index 0000000000..7ad47f5046 --- /dev/null +++ b/apps/local/public/favicon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/local/public/sentry-cli-light.svg b/apps/local/public/sentry-cli-light.svg new file mode 100644 index 0000000000..e124651b61 --- /dev/null +++ b/apps/local/public/sentry-cli-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/public/sentry-cli.svg b/apps/local/public/sentry-cli.svg new file mode 100644 index 0000000000..74e2b4731a --- /dev/null +++ b/apps/local/public/sentry-cli.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx new file mode 100644 index 0000000000..f0da2d19fe --- /dev/null +++ b/apps/local/src/App.integration.test.tsx @@ -0,0 +1,110 @@ +import { createSpotlightBuffer } from '@spotlightjs/spotlight/sdk' +import { EventSource as NodeEventSource } from 'eventsource' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { ThemeProvider } from '@/components/theme-provider.tsx' +import App from './App.tsx' +import { + buildApp, + tryListen, +} from '../../../packages/cli/src/commands/local/server.ts' + +const SENTRY_CONTENT_TYPE = 'application/x-sentry-envelope' + +function envelope(transaction: string): string { + return [ + JSON.stringify({ event_id: crypto.randomUUID() }), + JSON.stringify({ type: 'transaction' }), + JSON.stringify({ transaction, timestamp: 1_700_000_000 }), + '', + ].join('\n') +} + +async function startReceiver() { + return tryListen(buildApp(createSpotlightBuffer(10)), 0, '127.0.0.1') +} + +async function stopReceiver( + server: Awaited>['server'] +) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} + +async function sendEnvelope(port: number, transaction: string) { + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: 'POST', + headers: { 'Content-Type': SENTRY_CONTENT_TYPE }, + body: envelope(transaction), + }) + expect(response.status).toBe(204) +} + +function renderViewer(port: number) { + window.history.replaceState( + null, + '', + `/#stream=${encodeURIComponent(`http://127.0.0.1:${port}/stream`)}` + ) + return render( + + + + ) +} + +describe('local receiver to viewer integration', () => { + beforeEach(() => { + window.localStorage.clear() + vi.stubGlobal('EventSource', NodeEventSource) + vi.stubGlobal('matchMedia', () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + window.history.replaceState(null, '', '/') + }) + + test('renders a transaction sent after the viewer connects', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + + await sendEnvelope(port, 'GET /live') + + await screen.findByText('transaction') + await screen.findByText(/GET \/live/) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('replays an envelope buffered before the viewer opens', async () => { + const { server, port } = await startReceiver() + + try { + await sendEnvelope(port, 'GET /buffered') + renderViewer(port) + + await screen.findByText('Connected to local receiver') + await screen.findByText(/GET \/buffered/) + } finally { + cleanup() + await stopReceiver(server) + } + }) +}) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx new file mode 100644 index 0000000000..e4d760709f --- /dev/null +++ b/apps/local/src/App.tsx @@ -0,0 +1,226 @@ +import { Eye, Radio, ShieldCheck, Terminal } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { ThemeToggle } from '@/components/theme-toggle.tsx' +import { Badge } from '@/components/ui/badge.tsx' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card.tsx' +import { + getConnectionPresentation, + type ConnectionState, +} from '@/lib/presentation.ts' +import { + appendBounded, + decodeEnvelope, + getPreferredStreamUrl, + getStreamUrlFromHash, + SENTRY_ENVELOPE_EVENT, + STREAM_STORAGE_KEY, + type LocalFeedItem, +} from '@/lib/spotlight.ts' + +function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { + if (timestamp === undefined) { + return 'unknown time' + } + const milliseconds = + typeof timestamp === 'number' && timestamp < 1_000_000_000_000 + ? timestamp * 1_000 + : timestamp + const date = new Date(milliseconds) + return Number.isNaN(date.getTime()) ? String(timestamp) : date.toLocaleTimeString() +} + +function getSavedStream(): string | null { + try { + return window.localStorage.getItem(STREAM_STORAGE_KEY) + } catch { + return null + } +} + +function saveStreamUrl(streamUrl: string): void { + try { + window.localStorage.setItem(STREAM_STORAGE_KEY, streamUrl) + } catch { + // Private browsing or browser policy can disable storage; the current tab still works. + } +} + +export default function App() { + const [streamUrl] = useState(() => + getPreferredStreamUrl(window.location.hash, getSavedStream()) + ) + const [connection, setConnection] = useState( + streamUrl ? 'connecting' : 'missing' + ) + const [items, setItems] = useState([]) + const [message, setMessage] = useState() + const fallbackEventId = useRef(0) + const presentation = getConnectionPresentation(connection) + const badgeVariant = presentation.tone === 'neutral' ? 'default' : presentation.tone + + useEffect(() => { + const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) + if (fragmentStreamUrl) { + saveStreamUrl(fragmentStreamUrl) + } + if (window.location.hash) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`) + } + }, []) + + useEffect(() => { + if (!streamUrl) { + return + } + + const source = new EventSource(streamUrl) + const onEnvelope = (event: Event) => { + try { + const messageEvent = event as MessageEvent + const eventId = messageEvent.lastEventId || `event-${fallbackEventId.current++}` + const decoded = decodeEnvelope(messageEvent.data, eventId) + setItems((current) => appendBounded(current, decoded)) + setMessage(undefined) + } catch { + setMessage('Received an event that could not be decoded.') + } + } + + source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) + source.onopen = () => { + setConnection('connected') + setMessage(undefined) + } + source.onerror = () => { + setConnection('reconnecting') + setMessage('Could not reach the local receiver. Retrying…') + } + + return () => { + source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) + source.close() + } + }, [streamUrl]) + + return ( +
+
+
+
+ Sentry CLI + +
+
+ + + +
+
+ +
+
+
+
+

Live local telemetry

+

Event stream

+

+ A read-only view of envelopes received by Sentry Local on this machine. +

+
+
+
+
+ + {connection === 'missing' ? ( + + + Connect a local receiver + + Start the receiver from your project, then this page will receive its stream. + + + + + sentry local serve --open + + + + ) : null} + + {message ? ( +
+ {message} +
+ ) : null} + + + +
+ Incoming events + Transactions, errors, logs, and other envelope items. +
+
+ + {items.length === 0 ? ( +
+
+ ) : ( +
    + {items.map((item) => ( +
  1. +
    + {item.type} + +
    +
    +                          {item.text}
    +                        
    +
  2. + ))} +
+ )} +
+
+
+ + +
+
+
+ ) +} diff --git a/apps/local/src/components/theme-provider.tsx b/apps/local/src/components/theme-provider.tsx new file mode 100644 index 0000000000..e48a960b74 --- /dev/null +++ b/apps/local/src/components/theme-provider.tsx @@ -0,0 +1,6 @@ +import type { ThemeProviderProps } from 'next-themes' +import { ThemeProvider as NextThemesProvider } from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/apps/local/src/components/theme-toggle.tsx b/apps/local/src/components/theme-toggle.tsx new file mode 100644 index 0000000000..0086118cea --- /dev/null +++ b/apps/local/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' +import { Button } from '@/components/ui/button.tsx' + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + return ( + + ) +} diff --git a/apps/local/src/components/ui/badge.tsx b/apps/local/src/components/ui/badge.tsx new file mode 100644 index 0000000000..1d6abd39af --- /dev/null +++ b/apps/local/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +const badgeVariants = cva( + 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap', + { + variants: { + variant: { + default: 'border-transparent bg-secondary text-secondary-foreground', + success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + warning: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<'span'> & VariantProps) { + return +} + +export { Badge } diff --git a/apps/local/src/components/ui/button.tsx b/apps/local/src/components/ui/button.tsx new file mode 100644 index 0000000000..64e43f17e4 --- /dev/null +++ b/apps/local/src/components/ui/button.tsx @@ -0,0 +1,42 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +const buttonVariants = cva( + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + ghost: 'hover:bg-accent hover:text-accent-foreground', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-4 py-2', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return +} + +export { Button } diff --git a/apps/local/src/components/ui/card.tsx b/apps/local/src/components/ui/card.tsx new file mode 100644 index 0000000000..298dc6758f --- /dev/null +++ b/apps/local/src/components/ui/card.tsx @@ -0,0 +1,29 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +function Card({ className, ...props }: React.ComponentProps<'section'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return

+} + +function CardDescription({ className, ...props }: React.ComponentProps<'p'>) { + return

+} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return

+} + +export { Card, CardContent, CardDescription, CardHeader, CardTitle } diff --git a/apps/local/src/index.css b/apps/local/src/index.css new file mode 100644 index 0000000000..621c8928ac --- /dev/null +++ b/apps/local/src/index.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.985 0.002 285); + --foreground: oklch(0.18 0.02 285); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 285); + --primary: oklch(0.56 0.25 292); + --primary-foreground: oklch(0.99 0.005 285); + --secondary: oklch(0.95 0.015 285); + --secondary-foreground: oklch(0.3 0.025 285); + --muted: oklch(0.95 0.012 285); + --muted-foreground: oklch(0.48 0.02 285); + --accent: oklch(0.94 0.02 285); + --accent-foreground: oklch(0.25 0.025 285); + --destructive: oklch(0.59 0.22 25); + --border: oklch(0.9 0.012 285); + --input: oklch(0.9 0.012 285); + --ring: oklch(0.56 0.25 292); + --radius: 0.7rem; +} + +.dark { + --background: oklch(0.16 0.018 285); + --foreground: oklch(0.96 0.008 285); + --card: oklch(0.2 0.018 285); + --card-foreground: oklch(0.96 0.008 285); + --primary: oklch(0.72 0.22 292); + --primary-foreground: oklch(0.2 0.018 285); + --secondary: oklch(0.27 0.02 285); + --secondary-foreground: oklch(0.95 0.008 285); + --muted: oklch(0.25 0.018 285); + --muted-foreground: oklch(0.7 0.018 285); + --accent: oklch(0.3 0.022 285); + --accent-foreground: oklch(0.96 0.008 285); + --destructive: oklch(0.7 0.2 25); + --border: oklch(0.31 0.018 285); + --input: oklch(0.31 0.018 285); + --ring: oklch(0.72 0.22 292); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); +} + +@layer base { + * { + @apply border-border; + } + + html { + @apply bg-background; + } + + body { + @apply min-w-80 bg-background text-foreground antialiased; + } +} diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts new file mode 100644 index 0000000000..caeddc7514 --- /dev/null +++ b/apps/local/src/lib/presentation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { getConnectionPresentation } from './presentation.ts' + +describe('getConnectionPresentation', () => { + test('describes an active local receiver as connected', () => { + expect(getConnectionPresentation('connected')).toEqual({ + label: 'Connected to local receiver', + tone: 'success', + }) + }) + + test('explains when no local stream has been provided', () => { + expect(getConnectionPresentation('missing')).toEqual({ + label: 'Waiting for a local stream', + tone: 'neutral', + }) + }) +}) diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts new file mode 100644 index 0000000000..956a8d89e4 --- /dev/null +++ b/apps/local/src/lib/presentation.ts @@ -0,0 +1,21 @@ +export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'missing' + +export type ConnectionPresentation = { + label: string + tone: 'neutral' | 'success' | 'warning' +} + +export function getConnectionPresentation( + connection: ConnectionState +): ConnectionPresentation { + switch (connection) { + case 'connected': + return { label: 'Connected to local receiver', tone: 'success' } + case 'connecting': + return { label: 'Connecting to local receiver', tone: 'neutral' } + case 'reconnecting': + return { label: 'Reconnecting to local receiver', tone: 'warning' } + case 'missing': + return { label: 'Waiting for a local stream', tone: 'neutral' } + } +} diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts new file mode 100644 index 0000000000..df91259554 --- /dev/null +++ b/apps/local/src/lib/spotlight.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { + appendBounded, + decodeEnvelope, + getPreferredStreamUrl, + getStreamUrlFromHash, + getStreamUrlFromStorage, +} from "./spotlight.js"; + +describe("getStreamUrlFromHash", () => { + test("accepts a loopback stream URL", () => { + expect( + getStreamUrlFromHash( + "#stream=http%3A%2F%2Flocalhost%3A8969%2Fstream" + ) + ).toBe("http://localhost:8969/stream"); + }); + + test("rejects a non-loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Fexample.com%2Fstream") + ).toBeUndefined(); + }); + + test("restores a valid saved loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://127.0.0.1:8969/stream")).toBe( + "http://127.0.0.1:8969/stream" + ); + }); + + test("rejects a saved non-loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://example.com/stream")).toBeUndefined(); + }); + + test("prefers a fresh CLI fragment over a saved stream URL", () => { + expect( + getPreferredStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + "http://127.0.0.1:8969/stream" + ) + ).toBe("http://localhost:9000/stream"); + }); +}); + +describe("decodeEnvelope", () => { + test("turns envelope items into displayable feed entries", () => { + expect( + decodeEnvelope( + '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /users","timestamp":1}]]]', + "event-1" + ) + ).toEqual([ + { + id: "event-1:0", + type: "transaction", + timestamp: 1, + text: '{\n "transaction": "GET /users",\n "timestamp": 1\n}', + }, + ]); + }); + + test("rejects malformed envelopes", () => { + expect(() => decodeEnvelope("not json", "event-1")).toThrow( + "Invalid Sentry envelope" + ); + }); +}); + +describe("appendBounded", () => { + test("keeps the newest 500 items", () => { + const entries = Array.from({ length: 501 }, (_, index) => ({ + id: String(index), + type: "transaction", + text: String(index), + })); + + const result = appendBounded([], entries); + + expect(result).toHaveLength(500); + expect(result[0]?.id).toBe("1"); + expect(result.at(-1)?.id).toBe("500"); + }); +}); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts new file mode 100644 index 0000000000..891c908d3b --- /dev/null +++ b/apps/local/src/lib/spotlight.ts @@ -0,0 +1,105 @@ +export const SENTRY_ENVELOPE_EVENT = "application/x-sentry-envelope" +export const STREAM_STORAGE_KEY = "sentry.local.stream-url" +const MAX_FEED_ITEMS = 500 + +export type LocalFeedItem = { + id: string + type: string + timestamp?: number | string + text: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase() + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" +} + +function normalizeStreamUrl(stream: string | null): string | undefined { + if (!stream) { + return undefined + } + try { + const url = new URL(stream) + if ( + url.protocol !== "http:" || + !isLoopbackHost(url.hostname) || + url.pathname !== "/stream" || + url.username || + url.password || + url.search || + url.hash + ) { + return undefined + } + return url.toString() + } catch { + return undefined + } +} + +/** Read the CLI-provided stream endpoint without allowing arbitrary targets. */ +export function getStreamUrlFromHash(hash: string): string | undefined { + return normalizeStreamUrl( + new URLSearchParams(hash.replace(/^#/, "")).get("stream") + ) +} + +/** Validate a stream endpoint saved by an earlier CLI-launched tab. */ +export function getStreamUrlFromStorage(stream: string | null): string | undefined { + return normalizeStreamUrl(stream) +} + +/** Prefer a newly supplied CLI endpoint over a previously saved one. */ +export function getPreferredStreamUrl( + hash: string, + savedStream: string | null +): string | undefined { + return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream) +} + +/** Decode the event payload emitted by the local receiver's SSE endpoint. */ +export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + throw new Error("Invalid Sentry envelope") + } + + if (!Array.isArray(parsed) || !Array.isArray(parsed[1])) { + throw new Error("Invalid Sentry envelope") + } + + return parsed[1].flatMap((item, index) => { + if (!Array.isArray(item) || item.length < 2 || !isRecord(item[0])) { + return [] + } + const header = item[0] + const payload = item[1] + const timestamp = isRecord(payload) ? payload.timestamp : undefined + return [ + { + id: `${eventId}:${index}`, + type: typeof header.type === "string" ? header.type : "unknown", + timestamp: + typeof timestamp === "number" || typeof timestamp === "string" + ? timestamp + : undefined, + text: JSON.stringify(payload, null, 2) ?? String(payload), + }, + ] + }) +} + +/** Append received items while keeping the in-memory UI feed bounded. */ +export function appendBounded( + existing: readonly LocalFeedItem[], + incoming: readonly LocalFeedItem[], + limit = MAX_FEED_ITEMS +): LocalFeedItem[] { + return [...existing, ...incoming].slice(-limit) +} diff --git a/apps/local/src/lib/utils.ts b/apps/local/src/lib/utils.ts new file mode 100644 index 0000000000..fed2fe91e4 --- /dev/null +++ b/apps/local/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/local/src/main.tsx b/apps/local/src/main.tsx new file mode 100644 index 0000000000..17e14c367e --- /dev/null +++ b/apps/local/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' +import { ThemeProvider } from './components/theme-provider.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/apps/local/tsconfig.app.json b/apps/local/tsconfig.app.json new file mode 100644 index 0000000000..140f47f712 --- /dev/null +++ b/apps/local/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/apps/local/tsconfig.json b/apps/local/tsconfig.json new file mode 100644 index 0000000000..d0b2bca504 --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/local/tsconfig.node.json b/apps/local/tsconfig.node.json new file mode 100644 index 0000000000..8455dcbc2c --- /dev/null +++ b/apps/local/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts new file mode 100644 index 0000000000..401ef37755 --- /dev/null +++ b/apps/local/vite.config.ts @@ -0,0 +1,21 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react(),tailwindcss()], + resolve: { + alias: { + '@': resolve(dirname(fileURLToPath(import.meta.url)), './src'), + }, + }, + server: { + port: 5173, + strictPort: true, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4b8b785ba6..f1a71fb6a6 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -137,6 +137,10 @@ sentry log list --query "severity:error" # SDK sends to both. sentry local run -- npm run dev # or: python manage.py runserver, etc. +# From a CLI source checkout, run the Local UI in a second terminal, then open it. +pnpm --filter local dev +sentry local run --open -- npm run dev + # Watch only AI/agent (gen_ai, mcp) spans while iterating on an agent. sentry local -f ai diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 86d030075d..ff547b5ce8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -22,6 +22,7 @@ Start the local dev server and tail events - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` +- `--open - Open Sentry Local UI in the browser` ### `sentry local run ` @@ -35,6 +36,7 @@ Run a command with the local dev server enabled - `-t, --timeout - Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode) - (default: "0")` - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Include selected event attributes in output` +- `--open - Open Sentry Local UI in the browser` **Examples:** diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index b2561b0bab..4e09c5fcd9 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -44,6 +44,11 @@ import { parsePort, tryListen, } from "./server.js"; +import { + formatLocalServerUrl, + openLocalUiIfRequested, + validateOpenHost, +} from "./ui.js"; type RunFlags = { readonly port: number; @@ -53,6 +58,7 @@ type RunFlags = { readonly filter?: readonly FilterValue[]; readonly format: FormatValue; readonly attributes: boolean; + readonly open: boolean; }; /** Buffer size for the auto-started background server. */ @@ -197,7 +203,7 @@ async function startBackgroundServer({ const buffer = createSpotlightBuffer(BUFFER_SIZE); const app = buildApp(buffer); const { server, port: boundPort } = await tryListen(app, port, host); - const url = `http://${host}:${boundPort}`; + const url = formatLocalServerUrl(host, boundPort); const subscriptionId = buffer.subscribe((container) => { try { @@ -242,7 +248,7 @@ async function openEventTail({ useJson, showAttributes, }: EventTailOptions): Promise { - const url = `http://${host}:${port}`; + const url = formatLocalServerUrl(host, port); if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); @@ -349,7 +355,8 @@ export const runCommand = buildCommand({ "SENTRY_SPOTLIGHT (server-side SDKs read this automatically), the\n" + "framework-prefixed client variants (NEXT_PUBLIC_, VITE_, etc.), and\n" + "SENTRY_TRACES_SAMPLE_RATE=1. Use --format json to stream versioned\n" + - "NDJSON observations to stdout for agents.\n\n" + + "NDJSON observations to stdout for agents. Use --open to launch the\n" + + "Sentry Local UI.\n\n" + "Example:\n" + " sentry local run -- npm run dev\n" + " sentry local run -- python manage.py runserver", @@ -407,6 +414,11 @@ export const runCommand = buildCommand({ brief: "Include selected event attributes in output", default: false, }, + open: { + kind: "boolean", + brief: "Open Sentry Local UI in the browser", + default: false, + }, }, aliases: { p: "port", @@ -419,6 +431,11 @@ export const runCommand = buildCommand({ }, auth: false, async *func(this: SentryContext, flags: RunFlags, ...rawArgs: string[]) { + if (flags.open && flags.verify) { + throw new ValidationError("--open cannot be used with --verify.", "open"); + } + validateOpenHost(flags.open, flags.host); + const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; const { args, commandSource } = await resolveArgs(stripped, this.cwd); @@ -427,7 +444,7 @@ export const runCommand = buildCommand({ return; } - let url = `http://${flags.host}:${flags.port}`; + let url = formatLocalServerUrl(flags.host, flags.port); const useJson = flags.format === "json"; const activeFilters = new Set(flags.filter); @@ -440,6 +457,8 @@ export const runCommand = buildCommand({ }); url = tail.url; + await openLocalUiIfRequested(flags.open, url); + const spotlightUrl = `${url}/stream`; const wrangler = await injectWranglerSpotlightBinding( args, @@ -575,7 +594,7 @@ async function* runWithVerify( flags.port, flags.host ); - const url = `http://${flags.host}:${boundPort}`; + const url = formatLocalServerUrl(flags.host, boundPort); logger.info(`Verify server listening on ${bold(url)}`); const spotlightUrl = `${url}/stream`; diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index cb3960488d..3c23f3c586 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -37,6 +37,11 @@ import { SENTRY_CONTENT_TYPE, } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; +import { + formatLocalServerUrl, + openLocalUiIfRequested, + validateOpenHost, +} from "./ui.js"; /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; @@ -99,6 +104,7 @@ type LocalFlags = { readonly filter: FilterValue[]; readonly format: FormatValue; readonly attributes: boolean; + readonly open: boolean; }; /** @@ -121,6 +127,26 @@ export function parsePort(value: string): number { /** Match localhost origins on any port (http or https), including IPv6. */ const LOCALHOST_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; +const LOCAL_UI_ORIGIN = "https://local.sentry.dev"; + +function isHostedUiStreamRequest(request: { + method: string; + path: string; + header: (name: string) => string | undefined; +}): boolean { + if ( + request.header("origin") !== LOCAL_UI_ORIGIN || + request.path !== "/stream" + ) { + return false; + } + + return ( + request.method === "GET" || + (request.method === "OPTIONS" && + request.header("access-control-request-method") === "GET") + ); +} /** * Build the Hono application. @@ -193,14 +219,60 @@ export function buildApp( await next(); }); - app.use( - "*", - cors({ - origin: (origin) => (LOCALHOST_ORIGIN_RE.test(origin) ? origin : null), - allowMethods: ["GET", "POST", "OPTIONS"], - allowHeaders: ["Content-Type", "Content-Encoding", "User-Agent"], - }) - ); + const localhostCors = cors({ + origin: (origin) => (LOCALHOST_ORIGIN_RE.test(origin) ? origin : null), + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "Content-Encoding", + "User-Agent", + "Last-Event-ID", + ], + }); + const hostedStreamCors = cors({ + origin: LOCAL_UI_ORIGIN, + allowMethods: ["GET", "OPTIONS"], + allowHeaders: ["Last-Event-ID"], + }); + + app.use("*", async (c, next) => { + const hostedUiOrigin = c.req.header("origin") === LOCAL_UI_ORIGIN; + const hostedStreamRequest = isHostedUiStreamRequest(c.req); + + // The hosted UI may only read the event stream. CORS alone cannot stop a + // simple cross-origin POST, so reject any other hosted-origin request. + if (hostedUiOrigin && !hostedStreamRequest) { + return c.body(null, 403); + } + + if (!hostedStreamRequest) { + return localhostCors(c, next); + } + + if (c.req.method === "OPTIONS") { + const isPrivateNetworkRequest = + c.req.header("access-control-request-private-network") === "true"; + c.header("Access-Control-Allow-Origin", LOCAL_UI_ORIGIN); + c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Last-Event-ID"); + if (isPrivateNetworkRequest) { + c.header("Access-Control-Allow-Private-Network", "true"); + } + c.header( + "Vary", + [ + "Origin", + "Access-Control-Request-Headers", + ...(isPrivateNetworkRequest + ? ["Access-Control-Request-Private-Network"] + : []), + ].join(", ") + ); + return c.body(null, 204); + } + + return await hostedStreamCors(c, next); + }); app.get("/health", (c) => c.text("OK")); @@ -721,7 +793,7 @@ export const serverCommand = buildCommand({ "Sentry SDKs in your dev stack and tails them to the terminal.\n\n" + "If a server is already listening on the port, the command connects\n" + "as an SSE consumer and tails events from it. Otherwise it starts\n" + - "its own server.\n\n" + + "its own server. Use --open to launch the Sentry Local UI.\n\n" + "Press Ctrl-C to stop.", }, parameters: { @@ -763,6 +835,11 @@ export const serverCommand = buildCommand({ "Show a grouped attribute table (user vs SDK) under each transaction", default: false, }, + open: { + kind: "boolean", + brief: "Open Sentry Local UI in the browser", + default: false, + }, }, aliases: { p: "port", @@ -775,11 +852,13 @@ export const serverCommand = buildCommand({ }, auth: false, async *func(this: SentryContext, flags: LocalFlags) { + validateOpenHost(flags.open, flags.host); const activeFilters = new Set(flags.filter); - const url = `http://${flags.host}:${flags.port}`; + const url = formatLocalServerUrl(flags.host, flags.port); if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); + await openLocalUiIfRequested(flags.open, url); if (activeFilters.size > 0) { logger.info(`Filtering: ${[...activeFilters].join(", ")}`); } @@ -841,7 +920,7 @@ export const serverCommand = buildCommand({ flags.host ); - const listenUrl = `http://${flags.host}:${boundPort}`; + const listenUrl = formatLocalServerUrl(flags.host, boundPort); logger.info("Sentry Local Dev Server"); logger.info(` Ingest: ${bold(`${listenUrl}/stream`)}`); logger.info(` Events: ${bold(`${listenUrl}/stream`)} (SSE)`); @@ -861,6 +940,8 @@ export const serverCommand = buildCommand({ logger.info(""); logger.info("Press Ctrl-C to stop."); + await openLocalUiIfRequested(flags.open, listenUrl); + await waitForShutdown(server); logger.log("Server stopped."); }, diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts new file mode 100644 index 0000000000..dc36b58175 --- /dev/null +++ b/packages/cli/src/commands/local/ui.ts @@ -0,0 +1,84 @@ +import { openBrowser } from "../../lib/browser.js"; +import { getCliEnvironment } from "../../lib/constants.js"; +import { ValidationError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; + +const DEVELOPMENT_UI_URL = "http://localhost:5173"; +const PRODUCTION_UI_URL = "https://local.sentry.dev"; + +function normalizeHostname(host: string): string { + return host.replace(/^\[|\]$/g, "").toLowerCase(); +} + +/** Whether a host is safe for the browser-only local UI connection. */ +export function isLoopbackHost(host: string): boolean { + return ["localhost", "127.0.0.1", "::1"].includes(normalizeHostname(host)); +} + +/** Reject a host that the browser-only UI cannot safely reach. */ +export function assertLoopbackHostForUi(host: string): void { + if (!isLoopbackHost(host)) { + throw new ValidationError( + "--open requires a loopback --host (localhost, 127.0.0.1, or ::1).", + "host" + ); + } +} + +/** Validate an --open host before the caller creates or attaches a receiver. */ +export function validateOpenHost(requested: boolean, host: string): void { + if (requested) { + assertLoopbackHostForUi(host); + } +} + +/** Format an HTTP origin, including the brackets required for IPv6 URLs. */ +export function formatLocalServerUrl(host: string, port: number): string { + const normalized = normalizeHostname(host); + const urlHost = normalized.includes(":") ? `[${normalized}]` : normalized; + return `http://${urlHost}:${port}`; +} + +/** Pick the independently served local UI URL for this CLI build. */ +export function getLocalUiBaseUrl( + environment: string = getCliEnvironment() +): string { + return environment === "development" ? DEVELOPMENT_UI_URL : PRODUCTION_UI_URL; +} + +/** Build the browser URL without exposing the local stream to the UI host. */ +export function buildLocalUiUrl(receiverUrl: string): string { + const receiver = new URL(receiverUrl); + assertLoopbackHostForUi(receiver.hostname); + + const streamUrl = new URL("/stream", receiver).toString(); + const uiUrl = new URL(getLocalUiBaseUrl()); + uiUrl.hash = new URLSearchParams({ stream: streamUrl }).toString(); + return uiUrl.toString(); +} + +/** Open the independently served local UI without affecting capture on failure. */ +export async function openLocalUi(receiverUrl: string): Promise { + const uiUrl = buildLocalUiUrl(receiverUrl); + let opened = false; + try { + opened = await openBrowser(uiUrl); + } catch (error) { + logger.debug("Could not open Sentry Local UI", error); + } + if (opened) { + logger.info("Opening Sentry Local UI..."); + return; + } + logger.warn(`Could not open browser. Visit ${uiUrl}`); +} + +/** Launch the UI only when the caller explicitly requested it. */ +export async function openLocalUiIfRequested( + requested: boolean, + receiverUrl: string +): Promise { + if (requested) { + await openLocalUi(receiverUrl); + } +} diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index bb61ad4e2a..b73610bc7f 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -16,6 +16,7 @@ import { shutdownServer, } from "../../../src/commands/local/run.js"; import { buildApp, tryListen } from "../../../src/commands/local/server.js"; +import { openBrowser } from "../../../src/lib/browser.js"; import { CliError, ValidationError } from "../../../src/lib/errors.js"; import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; import { TEST_TMP_DIR } from "../../constants.js"; @@ -51,6 +52,10 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn().mockResolvedValue(true), +})); + type RunFunc = ( this: unknown, flags: { @@ -61,6 +66,7 @@ type RunFunc = ( format?: "human" | "json"; attributes?: boolean; filter?: ("error" | "transaction" | "log" | "ai")[]; + open?: boolean; }, ...args: string[] ) => Promise; @@ -161,6 +167,73 @@ describe("sentry local run", () => { ); }); + test("opens the development UI with the bound receiver stream", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const func = (await runCommand.loader()) as unknown as RunFunc; + + await func.call( + makeContext(), + { + port: 0, + host: "127.0.0.1", + verify: false, + timeout: 0, + open: true, + }, + "true" + ); + + expect(openBrowserMock).toHaveBeenCalledWith( + expect.stringMatching( + /^http:\/\/localhost:5173\/#stream=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fstream$/ + ) + ); + }); + + test("rejects --open with --verify before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "localhost", + verify: true, + timeout: 0, + open: true, + }, + "true" + ) + ).rejects.toMatchObject({ + field: "open", + message: "--open cannot be used with --verify.", + }); + }); + + test("rejects a non-loopback --open host before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "0.0.0.0", + verify: false, + timeout: 0, + open: true, + }, + "true" + ) + ).rejects.toMatchObject({ + field: "host", + message: + "--open requires a loopback --host (localhost, 127.0.0.1, or ::1).", + }); + }); + test("injects SENTRY_SPOTLIGHT as a Wrangler Worker binding", async () => { await writeFile(join(tmpDir, "wrangler.jsonc"), "{}"); const fakeWrangler = join(tmpDir, "wrangler"); diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts index 0d17b431df..870b98a602 100644 --- a/packages/cli/test/commands/local/server.test.ts +++ b/packages/cli/test/commands/local/server.test.ts @@ -7,18 +7,76 @@ import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import { Hono } from "hono"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { buildApp, feedSSELine, isServerRunning, parsePort, SERVER_IDENTIFIER, + serverCommand, tryListen, } from "../../../src/commands/local/server.js"; +import { openBrowser } from "../../../src/lib/browser.js"; import { ValidationError } from "../../../src/lib/errors.js"; import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn().mockResolvedValue(true), +})); + +type ServeFunc = ( + this: object, + flags: { + port: number; + host: string; + quiet: boolean; + filter: ("error" | "transaction" | "log" | "ai")[]; + format: "human" | "json"; + attributes: boolean; + open: boolean; + } +) => Promise; + +function interceptShutdownSignal() { + let handler: (() => void) | undefined; + const originalOn = process.on.bind(process); + const originalOnce = process.once.bind(process); + const onSpy = vi.spyOn(process, "on").mockImplementation((( + event: string, + listener: () => void + ) => { + if (event === "SIGINT") { + handler = listener; + return process; + } + return originalOn(event, listener); + }) as typeof process.on); + const onceSpy = vi.spyOn(process, "once").mockImplementation((( + event: string, + listener: () => void + ) => { + if (event === "SIGINT") { + handler = listener; + return process; + } + return originalOnce(event, listener); + }) as typeof process.once); + + return { + trigger() { + if (!handler) { + throw new Error("Expected a SIGINT handler"); + } + handler(); + }, + restore() { + onSpy.mockRestore(); + onceSpy.mockRestore(); + }, + }; +} + describe("parsePort", () => { test("parses valid port numbers", () => { expect(parsePort("8969")).toBe(8969); @@ -43,6 +101,73 @@ describe("parsePort", () => { }); }); +describe("sentry local serve --open", () => { + const flags = { + host: "127.0.0.1", + quiet: true, + filter: [], + format: "human" as const, + attributes: false, + open: true, + }; + + test("opens the UI after starting an owned receiver", async () => { + const signal = interceptShutdownSignal(); + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const func = (await serverCommand.loader()) as unknown as ServeFunc; + + try { + const command = func.call({}, { ...flags, port: 0 }); + await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1)); + signal.trigger(); + await command; + } finally { + signal.restore(); + } + + expect(openBrowserMock).toHaveBeenCalledWith( + expect.stringMatching( + /^http:\/\/localhost:5173\/#stream=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fstream$/ + ) + ); + }); + + test("opens the UI when attaching to an existing receiver", async () => { + const signal = interceptShutdownSignal(); + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const savedFetch = globalThis.fetch; + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (!realFetch) { + throw new Error("Expected the test preload to retain the native fetch"); + } + globalThis.fetch = realFetch; + const { server, port } = await tryListen( + buildApp(createSpotlightBuffer(10)), + 0, + "127.0.0.1" + ); + const func = (await serverCommand.loader()) as unknown as ServeFunc; + + try { + const command = func.call({}, { ...flags, port }); + await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1)); + signal.trigger(); + await command; + } finally { + signal.restore(); + globalThis.fetch = savedFetch; + await new Promise((resolve) => server.close(() => resolve())); + } + + expect(openBrowserMock).toHaveBeenCalledWith( + `http://localhost:5173/#stream=http%3A%2F%2F127.0.0.1%3A${port}%2Fstream` + ); + }); +}); + describe("feedSSELine", () => { function makeState() { return { eventType: "", dataLines: [], id: "" }; @@ -257,6 +382,71 @@ describe("buildApp", () => { ); }); + test("CORS allows the hosted local UI to read the event stream", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + headers: { Origin: "https://local.sentry.dev" }, + }); + expect(res.headers.get("access-control-allow-origin")).toBe( + "https://local.sentry.dev" + ); + if (res.body) { + await res.body.cancel(); + } + }); + + test("CORS permits the SSE resume header from the hosted local UI", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + method: "OPTIONS", + headers: { + Origin: "https://local.sentry.dev", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Last-Event-ID", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(res.headers.get("access-control-allow-headers")).toContain( + "Last-Event-ID" + ); + expect(res.headers.get("access-control-allow-private-network")).toBe( + "true" + ); + expect(res.headers.get("vary")).toContain( + "Access-Control-Request-Private-Network" + ); + }); + + test("rejects hosted-origin writes to the local receiver", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + method: "POST", + headers: { + Origin: "https://local.sentry.dev", + "Content-Type": SENTRY_CONTENT_TYPE, + }, + body: '{"type":"event"}\n{}', + }); + expect(res.status).toBe(403); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + + test("CORS blocks lookalike hosted UI origins", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/health", { + headers: { Origin: "https://not-local.sentry.dev" }, + }); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + test("CORS blocks non-localhost origins", async () => { const buffer = createSpotlightBuffer(10); const app = buildApp(buffer); diff --git a/packages/cli/test/commands/local/ui.test.ts b/packages/cli/test/commands/local/ui.test.ts new file mode 100644 index 0000000000..6c41a19837 --- /dev/null +++ b/packages/cli/test/commands/local/ui.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from "vitest"; +import { + assertLoopbackHostForUi, + buildLocalUiUrl, + formatLocalServerUrl, + getLocalUiBaseUrl, + openLocalUi, +} from "../../../src/commands/local/ui.js"; +import { openBrowser } from "../../../src/lib/browser.js"; + +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn(), +})); + +describe("local UI URLs", () => { + test("uses the Vite UI in development", () => { + expect(getLocalUiBaseUrl("development")).toBe("http://localhost:5173"); + }); + + test("uses the hosted UI outside development", () => { + expect(getLocalUiBaseUrl("production")).toBe("https://local.sentry.dev"); + expect(getLocalUiBaseUrl("nightly")).toBe("https://local.sentry.dev"); + }); + + test("formats IPv6 loopback URLs correctly", () => { + expect(formatLocalServerUrl("::1", 8969)).toBe("http://[::1]:8969"); + expect(buildLocalUiUrl("http://[::1]:8969")).toBe( + "http://localhost:5173/#stream=http%3A%2F%2F%5B%3A%3A1%5D%3A8969%2Fstream" + ); + }); + + test("refuses to send the browser to a non-loopback receiver", () => { + expect(() => buildLocalUiUrl("http://example.com:8969")).toThrow( + "--open requires a loopback --host" + ); + }); + + test("validates an --open host before a receiver is started", () => { + expect(() => assertLoopbackHostForUi("0.0.0.0")).toThrow( + "--open requires a loopback --host" + ); + }); + + test("does not fail capture when the browser cannot be opened", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockResolvedValueOnce(false); + + await expect(openLocalUi("http://localhost:8969")).resolves.toBeUndefined(); + }); + + test("does not fail capture when browser launch rejects unexpectedly", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockRejectedValueOnce(new Error("browser unavailable")); + + await expect(openLocalUi("http://localhost:8969")).resolves.toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08a9717ba2..0e17e6f164 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,13 +38,13 @@ importers: dependencies: '@astrojs/starlight': specifier: ^0.41.5 - version: 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3) + version: 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3) '@sentry/astro': specifier: ^10.38.0 - version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@sentry/starlight-theme': specifier: ^0.8.0 - version: 0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3)) + version: 0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3)) '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(react@19.2.8) @@ -53,7 +53,7 @@ importers: version: 2.0.0(react@19.2.8) astro: specifier: ^7.1.1 - version: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + version: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) sharp: specifier: ^0.35.0 version: 0.35.3(@types/node@24.13.3) @@ -65,6 +65,73 @@ importers: specifier: ^4.118.0 version: 4.118.0 + apps/local: + dependencies: + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.43.0 + version: 1.43.0(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + eventsource: + specifier: ^4.1.0 + version: 4.1.0 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + oxlint: + specifier: ^1.79.0 + version: 1.82.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + vitest: + specifier: ^4.1.9 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + packages/cli: devDependencies: '@anthropic-ai/sdk': @@ -234,7 +301,7 @@ importers: version: 1.4.2(typescript@5.9.3) vitest: specifier: ^4.1.9 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) wrap-ansi: specifier: ^10.0.0 version: 10.0.0 @@ -256,6 +323,9 @@ packages: express: optional: true + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@ai-sdk/provider-utils@2.2.8': resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} engines: {node: '>=18'} @@ -331,6 +401,20 @@ packages: '@apm-js-collab/tracing-hooks@0.13.0': resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/compiler-binding-darwin-arm64@0.3.2': resolution: {integrity: sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -486,6 +570,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -555,6 +643,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bruits/satteri-darwin-arm64@0.9.5': resolution: {integrity: sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==} cpu: [arm64] @@ -665,6 +757,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} @@ -846,6 +974,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@expressive-code/core@0.44.1': resolution: {integrity: sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==} @@ -1299,6 +1436,123 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pagefind/darwin-arm64@1.5.2': resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} cpu: [arm64] @@ -1356,78 +1610,174 @@ packages: '@posthog/types@1.399.0': resolution: {integrity: sha512-/WDwBzqIPko8VJ1B+0rlso2XQEz9+2sqtsY9Tqy3p1GhgTqsFakcz/PmMpAnA321LTEZVRcO6x5hAwABV4yrDw==} + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1439,12 +1789,24 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1796,6 +2158,115 @@ packages: '@stricli/core@1.3.0': resolution: {integrity: sha512-LnBe2HntygaLDU5trtjiC3J4C/YkmIZuM0XB52IF4qaLqJH09kgD1fnZiS2gaYUnt0nAN2a+1b4PSOBcfPrN4Q==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: 8.0.16 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trpc/server@11.18.0': resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} hasBin: true @@ -1805,6 +2276,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1862,6 +2336,11 @@ packages: '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} @@ -1935,6 +2414,22 @@ packages: vue-router: optional: true + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: 8.0.16 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -2001,6 +2496,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -2039,6 +2538,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2059,6 +2562,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2166,6 +2672,9 @@ packages: bcp-47@2.1.1: resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + binpatch@0.4.2: resolution: {integrity: sha512-aShVdFVzDrqVRlyhDQabzKV6b9suWegARr0IPTer58eNQoo1KqpuCr+VtbwwuirmfXqmWuuxsoZlXJN1tTZBcw==} engines: {node: '>=22.15.0'} @@ -2268,6 +2777,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -2417,9 +2929,17 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2429,6 +2949,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2472,6 +2995,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2510,6 +3036,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2518,6 +3048,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2804,6 +3338,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemesplit@2.6.0: resolution: {integrity: sha512-rG9w2wAfkpg0DILa1pjnjNfucng3usON360shisqIMUBw/87pojcBSrHmeE4UwryAuBih7g8m1oilf5/u8EWdQ==} @@ -2902,6 +3439,10 @@ packages: resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2921,10 +3462,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -3039,6 +3588,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3065,6 +3617,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -3088,6 +3644,15 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3129,72 +3694,142 @@ packages: launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -3217,6 +3852,15 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.43.0: + resolution: {integrity: sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macho-unsign@2.0.6: resolution: {integrity: sha512-YkIVGFnpVHJMMwfy4bHo79Vy05ddVk/PZGSCmmiCT4zepx+FMP/JAt9hOoXuc31s2bbcOtnzznOGca5fRhgZOg==} engines: {node: '>=18.12.0'} @@ -3500,6 +4144,12 @@ packages: resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -3585,6 +4235,19 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3649,6 +4312,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3734,6 +4400,10 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + posthog-node@5.47.2: resolution: {integrity: sha512-u62sg9bAE+GO8NUzBnE0+PMgelTOoCfvFwcMvUK/zxvZLO8A25j3zbjXxJo2iBC3Cs6zzuJXENsFtsf58pk+tg==} engines: {node: ^20.20.0 || >=22.22.0} @@ -3748,6 +4418,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -3774,6 +4448,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} @@ -3799,6 +4477,14 @@ packages: react-devtools-core@7.0.1: resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -3922,6 +4608,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3936,6 +4627,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4139,10 +4834,23 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -4188,6 +4896,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -4195,9 +4910,17 @@ packages: tokenx@1.6.0: resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4250,6 +4973,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4473,6 +5201,49 @@ packages: yaml: optional: true + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: 0.28.1 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -4522,6 +5293,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4532,6 +5307,18 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4611,6 +5398,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -4695,6 +5489,8 @@ snapshots: optionalDependencies: express: 5.2.1 + '@acemir/cssom@0.9.31': {} + '@ai-sdk/provider-utils@2.2.8(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -4812,6 +5608,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/compiler-binding-darwin-arm64@0.3.2': optional: true @@ -4907,13 +5723,13 @@ snapshots: hast-util-from-html: 2.0.3 satteri: 0.9.5 - '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))': + '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.2 '@astrojs/markdown-remark': 7.2.2 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -4939,23 +5755,23 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3)': + '@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astrojs/markdown-satteri': 0.3.5 - '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) - astro-expressive-code: 0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + astro-expressive-code: 0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) bcp-47: 2.1.1 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 hast-util-to-string: 3.0.1 hastscript: 9.0.1 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.3.6(typescript@6.0.3) js-yaml: 4.3.1 klona: 2.0.6 magic-string: 0.30.21 @@ -5061,6 +5877,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5121,6 +5939,10 @@ snapshots: '@biomejs/cli-win32-x64@2.3.8': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bruits/satteri-darwin-arm64@0.9.5': optional: true @@ -5206,6 +6028,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@ctrl/tinycolor@4.2.0': {} '@emnapi/core@1.10.0': @@ -5323,6 +6169,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1': {} + '@expressive-code/core@0.44.1': dependencies: '@ctrl/tinycolor': 4.2.0 @@ -5821,6 +6669,65 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.148.0': {} + + '@oxlint/binding-android-arm-eabi@1.82.0': + optional: true + + '@oxlint/binding-android-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-x64@1.82.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.82.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.82.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.82.0': + optional: true + '@pagefind/darwin-arm64@1.5.2': optional: true @@ -5860,48 +6767,100 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@posthog/core@1.46.1': + '@posthog/core@1.46.1': + dependencies: + '@posthog/types': 1.399.0 + + '@posthog/types@1.399.0': {} + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@posthog/types': 1.399.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - '@posthog/types@1.399.0': {} + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true '@rolldown/binding-android-arm64@1.0.3': optional: true + '@rolldown/binding-android-arm64@1.2.7': + optional: true + '@rolldown/binding-darwin-arm64@1.0.3': optional: true + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + '@rolldown/binding-darwin-x64@1.0.3': optional: true + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + '@rolldown/binding-freebsd-x64@1.0.3': optional: true + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -5912,9 +6871,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0': @@ -5930,14 +6895,14 @@ snapshots: valibot: 1.4.2(typescript@5.9.3) zod: 4.4.3 - '@sentry/astro@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))': + '@sentry/astro@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@sentry/browser': 10.69.0 '@sentry/conventions': 0.16.0 '@sentry/core': 10.69.0 '@sentry/node': 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) '@sentry/vite-plugin': 5.4.0 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' @@ -6164,9 +7129,9 @@ snapshots: optionalDependencies: react: 19.2.8 - '@sentry/starlight-theme@0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3))': + '@sentry/starlight-theme@0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3))': dependencies: - '@astrojs/starlight': 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3) + '@astrojs/starlight': 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3) '@fontsource-variable/rubik': 5.2.8 '@fontsource/ibm-plex-mono': 5.2.7 marked: 18.0.7 @@ -6306,6 +7271,95 @@ snapshots: '@stricli/core@1.3.0': {} + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@trpc/server@11.18.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -6315,6 +7369,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6354,7 +7410,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 22.20.1 + '@types/node': 24.13.3 form-data: 4.0.6 '@types/node@18.19.130': @@ -6377,13 +7433,17 @@ snapshots: '@types/qrcode-terminal@0.12.2': {} + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + '@types/react@19.2.18': dependencies: csstype: 3.2.3 '@types/sax@1.2.7': dependencies: - '@types/node': 22.20.1 + '@types/node': 24.13.3 '@types/semver@7.8.0': {} @@ -6401,6 +7461,11 @@ snapshots: optionalDependencies: react: 19.2.8 + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -6413,7 +7478,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -6424,13 +7489,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -6481,6 +7554,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -6514,6 +7589,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -6531,6 +7608,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -6545,13 +7626,13 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)): + astro-expressive-code@0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0): + astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.3.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@astrojs/internal-helpers': 0.10.2 @@ -6601,8 +7682,8 @@ snapshots: ultrahtml: 1.7.0 unifont: 0.7.4 unstorage: 1.17.5 - vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -6695,6 +7776,10 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + binpatch@0.4.2: {} binpunch@1.0.0: {} @@ -6788,6 +7873,10 @@ snapshots: cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-boxes@4.0.1: {} cli-cursor@4.0.0: @@ -6911,12 +8000,28 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6945,6 +8050,8 @@ snapshots: direction@2.0.1: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -6981,10 +8088,17 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} + entities@8.1.0: {} + environment@1.1.0: {} error-stack-parser-es@1.0.5: {} @@ -7349,6 +8463,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemesplit@2.6.0: dependencies: js-base64: 3.9.1 @@ -7582,6 +8698,12 @@ snapshots: hono@4.12.34: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -7600,6 +8722,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -7607,15 +8736,22 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@8.0.1: {} humanize-ms@1.2.1: dependencies: ms: 2.1.3 - i18next@26.3.6(typescript@5.9.3): + i18next@26.3.6(typescript@6.0.3): optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 iconv-lite@0.7.3: dependencies: @@ -7715,6 +8851,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@4.0.1: {} @@ -7736,6 +8874,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + jose@6.2.3: {} jpeg-js@0.4.4: {} @@ -7755,6 +8895,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.1 + cssstyle: 6.2.0 + 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 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-schema-to-zod@2.8.1: {} @@ -7780,39 +8947,88 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.9.0 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + 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 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -7846,6 +9062,12 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.43.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + macho-unsign@2.0.6: {} magic-string@0.30.21: @@ -8401,6 +9623,11 @@ snapshots: neotraverse@1.0.1: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -8472,6 +9699,28 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + oxlint@1.82.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8548,6 +9797,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} patch-console@2.0.0: {} @@ -8611,6 +9864,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + posthog-node@5.47.2: dependencies: '@posthog/core': 1.46.1 @@ -8619,6 +9878,12 @@ snapshots: dependencies: commander: 9.5.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -8638,6 +9903,8 @@ snapshots: proxy-from-env@1.1.0: {} + punycode@2.3.1: {} + pure-rand@8.4.2: {} qrcode-terminal@0.12.0: {} @@ -8665,6 +9932,13 @@ snapshots: - bufferutil - utf-8-validate + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + react-reconciler@0.33.0(react@19.2.8): dependencies: react: 19.2.8 @@ -8880,6 +10154,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + router@2.2.0: dependencies: debug: 4.4.3 @@ -8911,6 +10206,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -9189,8 +10488,16 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -9242,12 +10549,26 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + toidentifier@1.0.1: {} tokenx@1.6.0: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -9281,6 +10602,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.4: {} ultracite@6.3.10(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3)): @@ -9433,42 +10756,59 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0): + vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 rolldown: 1.0.3 tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.5 + yaml: 2.9.0 + + vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 esbuild: 0.28.1 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.5 yaml: 2.9.0 - vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.0.3 + postcss: 8.5.28 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 esbuild: 0.28.1 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.5 yaml: 2.9.0 - vitefu@1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): optionalDependencies: - vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9477,7 +10817,7 @@ snapshots: es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.4 pathe: 2.0.3 picomatch: 4.0.5 std-env: 4.2.0 @@ -9485,21 +10825,68 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.1 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 28.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9562,6 +10949,10 @@ snapshots: ws@8.21.1: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} xz-decompress@0.2.3: {} From af06efaa83002c8e04fe0671a86e6328d052f59f Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 8 Sep 2026 23:59:35 +0530 Subject: [PATCH 02/20] fix(local): Sync UI test dependency lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e17e6f164..ae8d71e2bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: specifier: ^4.3.3 version: 4.3.3 devDependencies: + '@spotlightjs/spotlight': + specifier: ^4.11.7 + version: 4.11.8(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.12.34)) '@testing-library/react': specifier: ^16.3.2 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) From 062600b6eb75b6d08596ed5b30405d62da77c25f Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 00:18:52 +0530 Subject: [PATCH 03/20] feat(local): Simplify event viewer Present envelope items as compact, expandable JSON entries with focused Shiki highlighting. --- apps/local/package.json | 4 + apps/local/src/App.integration.test.tsx | 28 ++++- apps/local/src/App.tsx | 133 +++++++++++------------- apps/local/src/components/json-view.tsx | 65 ++++++++++++ apps/local/src/index.css | 19 ++++ pnpm-lock.yaml | 12 +++ 6 files changed, 184 insertions(+), 77 deletions(-) create mode 100644 apps/local/src/components/json-view.tsx diff --git a/apps/local/package.json b/apps/local/package.json index 546a27204e..ec2b184fd5 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -15,6 +15,10 @@ "node": "^20.19.0 || >=22.12.0" }, "dependencies": { + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", "@radix-ui/react-slot": "^1.3.3", "@tailwindcss/vite": "^4.3.3", "class-variance-authority": "^0.7.1", diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index f0da2d19fe..e6e736417e 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -1,7 +1,7 @@ import { createSpotlightBuffer } from '@spotlightjs/spotlight/sdk' import { EventSource as NodeEventSource } from 'eventsource' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ThemeProvider } from '@/components/theme-provider.tsx' import App from './App.tsx' import { @@ -76,7 +76,7 @@ describe('local receiver to viewer integration', () => { window.history.replaceState(null, '', '/') }) - test('renders a transaction sent after the viewer connects', async () => { + test('renders live transactions as collapsed, expandable JSON entries', async () => { const { server, port } = await startReceiver() try { @@ -84,9 +84,23 @@ describe('local receiver to viewer integration', () => { await screen.findByText('Connected to local receiver') await sendEnvelope(port, 'GET /live') + await screen.findByLabelText('View transaction event') + await sendEnvelope(port, 'GET /live-2') - await screen.findByText('transaction') - await screen.findByText(/GET \/live/) + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + const disclosures = screen.getAllByLabelText('View transaction event') + expect(disclosures[0]?.closest('details')?.open).toBe(false) + + fireEvent.click(disclosures[0]!) + + expect(disclosures[0]?.closest('details')?.open).toBe(true) + const code = await screen.findByTestId('highlighted-json') + expect(code.textContent).toContain('GET /live') + await waitFor(() => { + expect(screen.getByTestId('highlighted-json').querySelector('.shiki')).not.toBeNull() + }) } finally { cleanup() await stopReceiver(server) @@ -101,7 +115,11 @@ describe('local receiver to viewer integration', () => { renderViewer(port) await screen.findByText('Connected to local receiver') - await screen.findByText(/GET \/buffered/) + const disclosure = await screen.findByLabelText('View transaction event') + fireEvent.click(disclosure) + expect((await screen.findByTestId('highlighted-json')).textContent).toContain( + 'GET /buffered' + ) } finally { cleanup() await stopReceiver(server) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index e4d760709f..7f89cd7796 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,5 +1,6 @@ -import { Eye, Radio, ShieldCheck, Terminal } from 'lucide-react' +import { ChevronDown, Terminal } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import { JsonView } from '@/components/json-view.tsx' import { ThemeToggle } from '@/components/theme-toggle.tsx' import { Badge } from '@/components/ui/badge.tsx' import { @@ -23,6 +24,10 @@ import { type LocalFeedItem, } from '@/lib/spotlight.ts' +type EventEntryProps = { + item: LocalFeedItem +} + function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { if (timestamp === undefined) { return 'unknown time' @@ -51,6 +56,38 @@ function saveStreamUrl(streamUrl: string): void { } } +function EventEntry({ item }: EventEntryProps) { + const [isOpen, setIsOpen] = useState(false) + + return ( +
  • +
    setIsOpen(event.currentTarget.open)} + > + + {item.type} +
    + +
    +
    + {isOpen ? ( +
    + +
    + ) : null} +
    +
  • + ) +} + export default function App() { const [streamUrl] = useState(() => getPreferredStreamUrl(window.location.hash, getSavedStream()) @@ -62,7 +99,6 @@ export default function App() { const [message, setMessage] = useState() const fallbackEventId = useRef(0) const presentation = getConnectionPresentation(connection) - const badgeVariant = presentation.tone === 'neutral' ? 'default' : presentation.tone useEffect(() => { const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) @@ -110,45 +146,32 @@ export default function App() { return (
    -
    +
    Sentry CLI
    - + + {presentation.label} +
    -
    -
    -
    -
    -

    Live local telemetry

    -

    Event stream

    -

    - A read-only view of envelopes received by Sentry Local on this machine. -

    -
    -
    -
    -
    +
    +
    +

    Events

    {connection === 'missing' ? ( @@ -172,53 +195,19 @@ export default function App() {
    ) : null} - - -
    - Incoming events - Transactions, errors, logs, and other envelope items. -
    -
    - - {items.length === 0 ? ( -
    -
    - ) : ( -
      - {items.map((item) => ( -
    1. -
      - {item.type} - -
      -
      -                          {item.text}
      -                        
      -
    2. - ))} -
    - )} -
    -
    -
    - - + {items.length === 0 ? ( +
    +
    + ) : ( +
      + {items.map((item) => ( + + ))} +
    + )} +

    diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx new file mode 100644 index 0000000000..e307d1be6e --- /dev/null +++ b/apps/local/src/components/json-view.tsx @@ -0,0 +1,65 @@ +import { createHighlighterCore } from '@shikijs/core' +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' +import json from '@shikijs/langs/json' +import githubDark from '@shikijs/themes/github-dark' +import githubLight from '@shikijs/themes/github-light' +import { useEffect, useState } from 'react' + +type JsonViewProps = { + code: string +} + +const highlighter = createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + langs: [json], + themes: [githubLight, githubDark], +}) + +/** Render JSON with Shiki only after its containing event has been expanded. */ +export function JsonView({ code }: JsonViewProps) { + const [html, setHtml] = useState() + + useEffect(() => { + let disposed = false + + void highlighter + .then((instance) => + instance.codeToHtml(code, { + lang: 'json', + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + }) + ) + .then((result) => { + if (!disposed) { + setHtml(result) + } + }) + .catch(() => { + // Keep the readable, escaped source fallback if syntax highlighting cannot load. + }) + + return () => { + disposed = true + } + }, [code]) + + if (!html) { + return ( +
    +        {code}
    +      
    + ) + } + + return ( +
    + ) +} diff --git a/apps/local/src/index.css b/apps/local/src/index.css index 621c8928ac..eccada4c90 100644 --- a/apps/local/src/index.css +++ b/apps/local/src/index.css @@ -76,3 +76,22 @@ @apply min-w-80 bg-background text-foreground antialiased; } } + +@layer components { + .json-view pre { + @apply m-0 bg-transparent p-0 font-mono text-inherit; + } + + .json-view .shiki { + background-color: transparent !important; + padding: 0 !important; + } + + .json-view .shiki span { + color: var(--shiki-light); + } + + .dark .json-view .shiki span { + color: var(--shiki-dark); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae8d71e2bd..9ec4f73934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,18 @@ importers: apps/local: dependencies: + '@shikijs/core': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/engine-javascript': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/langs': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/themes': + specifier: ^3.23.0 + version: 3.23.0 '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) From 5f300808892c9bf69532df79f0854941efbdcd12 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 00:27:37 +0530 Subject: [PATCH 04/20] fix(local): Fill event viewer viewport Use the remaining dynamic viewport height for the scrollable event list. --- apps/local/src/App.integration.test.tsx | 3 +++ apps/local/src/App.tsx | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index e6e736417e..2e7ed0caae 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -90,6 +90,9 @@ describe('local receiver to viewer integration', () => { await waitFor(() => { expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) }) + const eventList = screen.getByTestId('event-list') + expect(eventList.className).toContain('flex-1') + expect(eventList.className).not.toContain('max-h-[42rem]') const disclosures = screen.getAllByLabelText('View transaction event') expect(disclosures[0]?.closest('details')?.open).toBe(false) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 7f89cd7796..f66d42ca3f 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -145,9 +145,9 @@ export default function App() { }, [streamUrl]) return ( -
    -
    -
    +
    +
    +
    Sentry CLI @@ -169,12 +169,12 @@ export default function App() {
    -
    -
    -

    Events

    +
    +
    +

    Events

    {connection === 'missing' ? ( - + Connect a local receiver @@ -190,18 +190,18 @@ export default function App() { ) : null} {message ? ( -
    +
    {message}
    ) : null} {items.length === 0 ? ( -
    +
    ) : ( -
      +
        {items.map((item) => ( ))} From 92b0913d61468584f702e46c808bdf9a5762e4b3 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 00:42:33 +0530 Subject: [PATCH 05/20] feat(local): Add event detail workspace Use a full-width master/detail layout for live envelope items. --- apps/local/src/App.integration.test.tsx | 22 +++--- apps/local/src/App.tsx | 94 ++++++++++++++++--------- 2 files changed, 73 insertions(+), 43 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 2e7ed0caae..8f92189e7b 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -76,7 +76,7 @@ describe('local receiver to viewer integration', () => { window.history.replaceState(null, '', '/') }) - test('renders live transactions as collapsed, expandable JSON entries', async () => { + test('keeps the selected live transaction open in the detail pane', async () => { const { server, port } = await startReceiver() try { @@ -93,14 +93,18 @@ describe('local receiver to viewer integration', () => { const eventList = screen.getByTestId('event-list') expect(eventList.className).toContain('flex-1') expect(eventList.className).not.toContain('max-h-[42rem]') - const disclosures = screen.getAllByLabelText('View transaction event') - expect(disclosures[0]?.closest('details')?.open).toBe(false) + const detail = screen.getByTestId('event-detail') + expect(detail.textContent).toContain('GET /live') + expect(detail.textContent).not.toContain('GET /live-2') - fireEvent.click(disclosures[0]!) + const events = screen.getAllByLabelText('View transaction event') + fireEvent.click(events[1]!) - expect(disclosures[0]?.closest('details')?.open).toBe(true) - const code = await screen.findByTestId('highlighted-json') - expect(code.textContent).toContain('GET /live') + await waitFor(() => { + expect(screen.getByTestId('event-detail').textContent).toContain('GET /live-2') + }) + const code = screen.getByTestId('highlighted-json') + expect(code.textContent).toContain('GET /live-2') await waitFor(() => { expect(screen.getByTestId('highlighted-json').querySelector('.shiki')).not.toBeNull() }) @@ -118,9 +122,7 @@ describe('local receiver to viewer integration', () => { renderViewer(port) await screen.findByText('Connected to local receiver') - const disclosure = await screen.findByLabelText('View transaction event') - fireEvent.click(disclosure) - expect((await screen.findByTestId('highlighted-json')).textContent).toContain( + expect((await screen.findByTestId('event-detail')).textContent).toContain( 'GET /buffered' ) } finally { diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index f66d42ca3f..f892de8c0a 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Terminal } from 'lucide-react' +import { Terminal } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { JsonView } from '@/components/json-view.tsx' import { ThemeToggle } from '@/components/theme-toggle.tsx' @@ -26,6 +26,8 @@ import { type EventEntryProps = { item: LocalFeedItem + isSelected: boolean + onSelect: (id: string) => void } function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { @@ -56,38 +58,47 @@ function saveStreamUrl(streamUrl: string): void { } } -function EventEntry({ item }: EventEntryProps) { - const [isOpen, setIsOpen] = useState(false) - +function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { return (
      1. -
        setIsOpen(event.currentTarget.open)} +
        +
    + + ) } +type EventDetailProps = { + item: LocalFeedItem +} + +function EventDetail({ item }: EventDetailProps) { + return ( +
    +
    + {item.type} + +
    +
    + +
    +
    + ) +} + export default function App() { const [streamUrl] = useState(() => getPreferredStreamUrl(window.location.hash, getSavedStream()) @@ -96,9 +107,11 @@ export default function App() { streamUrl ? 'connecting' : 'missing' ) const [items, setItems] = useState([]) + const [selectedItemId, setSelectedItemId] = useState() const [message, setMessage] = useState() const fallbackEventId = useRef(0) const presentation = getConnectionPresentation(connection) + const selectedItem = items.find((item) => item.id === selectedItemId) ?? items[0] useEffect(() => { const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) @@ -146,7 +159,7 @@ export default function App() { return (
    -
    +
    Sentry CLI @@ -169,9 +182,8 @@ export default function App() {
    -
    +
    -

    Events

    {connection === 'missing' ? ( @@ -201,11 +213,27 @@ export default function App() {

    Waiting for events

    ) : ( -
      - {items.map((item) => ( - - ))} -
    +
    + + {selectedItem ? : null} +
    )}
    From bca02e74e1388fe06a26f510a0d2623d5e738ee7 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 00:48:38 +0530 Subject: [PATCH 06/20] style(local): Tighten event workspace Use square panels, aligned headers, and smaller workspace gutters. --- apps/local/src/App.integration.test.tsx | 1 + apps/local/src/App.tsx | 27 ++++++++++++++----------- apps/local/src/components/json-view.tsx | 4 ++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 8f92189e7b..079611bcbe 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -93,6 +93,7 @@ describe('local receiver to viewer integration', () => { const eventList = screen.getByTestId('event-list') expect(eventList.className).toContain('flex-1') expect(eventList.className).not.toContain('max-h-[42rem]') + expect(screen.getByRole('complementary', { name: 'Events' })).not.toBeNull() const detail = screen.getByTestId('event-detail') expect(detail.textContent).toContain('GET /live') expect(detail.textContent).not.toContain('GET /live-2') diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index f892de8c0a..3d890d5577 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -65,7 +65,7 @@ function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { type="button" aria-label={`View ${item.type} event`} aria-current={isSelected ? 'true' : undefined} - className={`flex w-full cursor-pointer items-center justify-between gap-4 rounded-md px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset ${ + className={`flex w-full cursor-pointer items-center justify-between gap-3 px-2 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset ${ isSelected ? 'bg-muted text-foreground' : 'hover:bg-muted/60' }`} onClick={() => onSelect(item.id)} @@ -88,7 +88,7 @@ type EventDetailProps = { function EventDetail({ item }: EventDetailProps) { return (
    -
    +
    {item.type}
    @@ -159,8 +159,8 @@ export default function App() { return (
    -
    -
    +
    +
    Sentry CLI @@ -182,8 +182,8 @@ export default function App() {
    -
    -
    +
    +
    {connection === 'missing' ? ( @@ -208,19 +208,22 @@ export default function App() { ) : null} {items.length === 0 ? ( -
    +
    ) : ( -
    -
    ) } @@ -235,7 +363,7 @@ export default function App() { ))} - {selectedItem ? : null} + {selectedItem ? : null}
    )}
    diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts index df91259554..4533e49876 100644 --- a/apps/local/src/lib/spotlight.test.ts +++ b/apps/local/src/lib/spotlight.test.ts @@ -46,15 +46,26 @@ describe("decodeEnvelope", () => { test("turns envelope items into displayable feed entries", () => { expect( decodeEnvelope( - '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /users","timestamp":1}]]]', + '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /api/users/42","start_timestamp":1700000000,"timestamp":1700000000.00668,"contexts":{"trace":{"trace_id":"trace-123","span_id":"span-456","op":"http.server","data":{"http.method":"GET","http.url":"http://127.0.0.1:3030/api/users/42","http.response.status_code":200,"sentry.origin":"auto.http.otel.http"}}}}]]]', "event-1" ) ).toEqual([ { id: "event-1:0", type: "transaction", - timestamp: 1, - text: '{\n "transaction": "GET /users",\n "timestamp": 1\n}', + timestamp: 1700000000.00668, + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + statusCode: 200, + durationMs: 6.68, + traceId: "trace-123", + spanId: "span-456", + operation: "http.server", + origin: "auto.http.otel.http", + }, + text: '{\n "transaction": "GET /api/users/42",\n "start_timestamp": 1700000000,\n "timestamp": 1700000000.00668,\n "contexts": {\n "trace": {\n "trace_id": "trace-123",\n "span_id": "span-456",\n "op": "http.server",\n "data": {\n "http.method": "GET",\n "http.url": "http://127.0.0.1:3030/api/users/42",\n "http.response.status_code": 200,\n "sentry.origin": "auto.http.otel.http"\n }\n }\n }\n}', }, ]); }); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts index 891c908d3b..912e3cff61 100644 --- a/apps/local/src/lib/spotlight.ts +++ b/apps/local/src/lib/spotlight.ts @@ -7,12 +7,78 @@ export type LocalFeedItem = { type: string timestamp?: number | string text: string + metadata?: EventMetadata +} + +export type EventMetadata = { + title: string + method?: string + route?: string + statusCode?: number + durationMs?: number + traceId?: string + spanId?: string + operation?: string + origin?: string } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function getRoute(url: string | undefined): string | undefined { + if (!url) { + return undefined + } + try { + const parsed = new URL(url) + return `${parsed.pathname}${parsed.search}` + } catch { + return url.startsWith("/") ? url : undefined + } +} + +function getEventMetadata(payload: unknown, type: string): EventMetadata { + const event = isRecord(payload) ? payload : {} + const transaction = getString(event.transaction) + const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/) + const contexts = isRecord(event.contexts) ? event.contexts : {} + const trace = isRecord(contexts.trace) ? contexts.trace : {} + const data = isRecord(trace.data) ? trace.data : {} + const request = isRecord(event.request) ? event.request : {} + const method = + getString(request.method) ?? getString(data["http.method"]) ?? transactionMatch?.[1] + const url = getString(request.url) ?? getString(data["http.url"]) + const route = getRoute(url) ?? transactionMatch?.[2] + const startTimestamp = getNumber(event.start_timestamp) + const timestamp = getNumber(event.timestamp) + const durationMs = + startTimestamp !== undefined && timestamp !== undefined + ? Math.round((timestamp - startTimestamp) * 100_000) / 100 + : undefined + + return { + title: transaction ?? route ?? type, + method, + route, + statusCode: + getNumber(request.status_code) ?? getNumber(data["http.response.status_code"]), + durationMs, + traceId: getString(trace.trace_id) ?? getString(event.trace_id), + spanId: getString(trace.span_id) ?? getString(event.span_id), + operation: getString(trace.op) ?? getString(event.transaction_op), + origin: getString(data["sentry.origin"]), + } +} + function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase() return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" @@ -81,15 +147,17 @@ export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { const header = item[0] const payload = item[1] const timestamp = isRecord(payload) ? payload.timestamp : undefined + const type = typeof header.type === "string" ? header.type : "unknown" return [ { id: `${eventId}:${index}`, - type: typeof header.type === "string" ? header.type : "unknown", + type, timestamp: typeof timestamp === "number" || typeof timestamp === "string" ? timestamp : undefined, text: JSON.stringify(payload, null, 2) ?? String(payload), + metadata: getEventMetadata(payload, type), }, ] }) From e4e61866d4571572ac0d963a70b790339582ee83 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 00:57:00 +0530 Subject: [PATCH 08/20] style(local): Remove event workspace frame Place the navigation and master/detail workspace flush with the viewport. --- apps/local/src/App.integration.test.tsx | 5 +++++ apps/local/src/App.tsx | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 15673175e0..9b8bf5bf26 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -83,6 +83,11 @@ describe('local receiver to viewer integration', () => { renderViewer(port) await screen.findByText('Connected to local receiver') + const shell = screen.getByTestId('app-shell') + expect(shell.className).not.toContain('px-') + expect(shell.className).not.toContain('py-') + expect(screen.getByRole('banner').className).not.toContain('border-b') + await sendEnvelope(port, 'GET /live') await screen.findByLabelText('View transaction event') await sendEnvelope(port, 'GET /live-2') diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index d940bf3e2a..2c26ad75cb 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -287,8 +287,11 @@ export default function App() { return (
    -
    -
    +
    +
    Sentry CLI @@ -310,8 +313,8 @@ export default function App() {
    -
    -
    +
    +
    {connection === 'missing' ? ( From c8d44bafccd6263108cdcec0f5bc54cfcf2dff13 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 01:12:53 +0530 Subject: [PATCH 09/20] feat(local): Add live event workspace context Add feed filters, selected-event metrics, trace relationships, and non-disruptive new-event handling. --- apps/local/src/App.integration.test.tsx | 214 ++++++++++++++++++- apps/local/src/App.tsx | 263 ++++++++++++++++++++++-- apps/local/src/lib/spotlight.ts | 2 + 3 files changed, 459 insertions(+), 20 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 9b8bf5bf26..12c151eeb4 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -11,11 +11,36 @@ import { const SENTRY_CONTENT_TYPE = 'application/x-sentry-envelope' -function envelope(transaction: string): string { +type EnvelopeOptions = { + level?: string + operation?: string + statusCode?: number + startTimestamp?: number + traceId?: string + type?: string +} + +function envelope(transaction: string, options: EnvelopeOptions = {}): string { return [ JSON.stringify({ event_id: crypto.randomUUID() }), - JSON.stringify({ type: 'transaction' }), - JSON.stringify({ transaction, timestamp: 1_700_000_000 }), + JSON.stringify({ type: options.type ?? 'transaction' }), + JSON.stringify({ + transaction, + timestamp: 1_700_000_000, + ...(options.startTimestamp ? { start_timestamp: options.startTimestamp } : {}), + ...(options.level ? { level: options.level } : {}), + ...(options.traceId || options.operation + ? { + contexts: { + trace: { + ...(options.traceId ? { trace_id: options.traceId } : {}), + ...(options.operation ? { op: options.operation } : {}), + }, + }, + } + : {}), + ...(options.statusCode ? { request: { status_code: options.statusCode } } : {}), + }), '', ].join('\n') } @@ -32,11 +57,15 @@ async function stopReceiver( }) } -async function sendEnvelope(port: number, transaction: string) { +async function sendEnvelope( + port: number, + transaction: string, + options?: EnvelopeOptions +) { const response = await fetch(`http://127.0.0.1:${port}/stream`, { method: 'POST', headers: { 'Content-Type': SENTRY_CONTENT_TYPE }, - body: envelope(transaction), + body: envelope(transaction, options), }) expect(response.status).toBe(204) } @@ -138,4 +167,179 @@ describe('local receiver to viewer integration', () => { await stopReceiver(server) } }) + + test('does not mark the receiver replay as new events', async () => { + const { server, port } = await startReceiver() + + try { + await sendEnvelope(port, 'GET /buffered-first') + await sendEnvelope(port, 'GET /buffered-second') + renderViewer(port) + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + expect(screen.queryByRole('button', { name: /View .* new event/ })).toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('filters the feed by event class', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /healthy') + await sendEnvelope(port, 'GET /broken', { type: 'event', level: 'error' }) + + await screen.findByLabelText('View transaction event') + await screen.findByLabelText('View event event') + + fireEvent.click(screen.getByRole('button', { name: 'Errors (1)' })) + + expect(screen.queryByLabelText('View transaction event')).toBeNull() + expect(screen.getByLabelText('View event event').textContent).toContain( + '/broken' + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('shows other retained events from the selected trace', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /trace/first', { traceId: 'shared-trace' }) + await sendEnvelope(port, 'GET /trace/second', { traceId: 'shared-trace' }) + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + + expect(screen.getByRole('heading', { name: 'Related trace items' })).not.toBeNull() + expect(screen.getByText('1 related event')).not.toBeNull() + expect(screen.getByRole('button', { name: 'View related GET /trace/second' })).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('offers new events without interrupting the selected detail', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /selected') + fireEvent.click(await screen.findByLabelText('View transaction event')) + await sendEnvelope(port, 'GET /new') + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + + expect(screen.getByTestId('event-detail').textContent).toContain('GET /selected') + const newEvents = screen.getByRole('button', { name: 'View 1 new event' }) + fireEvent.click(newEvents) + + expect(screen.getByTestId('event-detail').textContent).toContain('GET /new') + expect(screen.queryByRole('button', { name: 'View 1 new event' })).toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('only offers new events that match the active filter', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /first-error', { type: 'event', level: 'error' }) + await screen.findByLabelText('View event event') + fireEvent.click(screen.getByRole('button', { name: 'Errors (1)' })) + await sendEnvelope(port, 'GET /healthy') + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'All (2)' })).not.toBeNull() + }) + expect(screen.queryByRole('button', { name: /View 1 new event/ })).toBeNull() + + await sendEnvelope(port, 'GET /second-error', { type: 'event', level: 'error' }) + + const newEvents = await screen.findByRole('button', { name: 'View 1 new event' }) + fireEvent.click(newEvents) + expect(screen.getByTestId('event-detail').textContent).toContain('GET /second-error') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('summarizes the selected HTTP event before its fields', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /summary', { + operation: 'http.server', + startTimestamp: 1_699_999_999.99383, + statusCode: 201, + traceId: 'summary-trace', + }) + + const summary = await screen.findByLabelText('Event summary') + expect(summary.textContent).toContain('201') + expect(summary.textContent).toContain('6.17ms') + expect(summary.textContent).toContain('http.server') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('moves between detail tabs with arrow keys', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /tabs') + + const overview = await screen.findByRole('tab', { name: 'Overview' }) + fireEvent.keyDown(overview, { key: 'ArrowRight' }) + + expect(screen.getByRole('tab', { name: 'JSON' }).getAttribute('aria-selected')).toBe( + 'true' + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('reserves space for the CLI logo', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + + const logo = await screen.findByAltText('Sentry CLI') + expect(logo.getAttribute('width')).toBe('117') + expect(logo.getAttribute('height')).toBe('20') + } finally { + cleanup() + await stopReceiver(server) + } + }) }) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 2c26ad75cb..18238c2379 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,5 +1,5 @@ import { Terminal } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { type KeyboardEvent, useEffect, useRef, useState } from 'react' import { JsonView } from '@/components/json-view.tsx' import { ThemeToggle } from '@/components/theme-toggle.tsx' import { Badge } from '@/components/ui/badge.tsx' @@ -31,6 +31,28 @@ type EventEntryProps = { onSelect: (id: string) => void } +type EventFilter = 'all' | 'errors' | 'transactions' | 'logs' + +const eventFilters: { id: EventFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'errors', label: 'Errors' }, + { id: 'transactions', label: 'Transactions' }, + { id: 'logs', label: 'Logs' }, +] + +function matchesEventFilter(item: LocalFeedItem, filter: EventFilter): boolean { + if (filter === 'all') { + return true + } + + if (filter === 'errors') { + const { level, statusCode } = getMetadata(item) + return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) + } + + return item.type === (filter === 'transactions' ? 'transaction' : 'log') +} + function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { if (timestamp === undefined) { return 'unknown time' @@ -121,13 +143,28 @@ function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { type EventDetailProps = { item: LocalFeedItem + relatedItems: LocalFeedItem[] + onSelect: (id: string) => void } +type DetailTab = 'overview' | 'json' + +const detailTabs: DetailTab[] = ['overview', 'json'] + type DetailField = { label: string value: string } +function SummaryMetric({ label, value, tone }: DetailField & { tone?: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ) +} + function DetailSection({ title, fields }: { title: string; fields: DetailField[] }) { if (fields.length === 0) { return null @@ -150,8 +187,8 @@ function DetailSection({ title, fields }: { title: string; fields: DetailField[] ) } -function EventDetail({ item }: EventDetailProps) { - const [tab, setTab] = useState<'overview' | 'json'>('overview') +function EventDetail({ item, relatedItems, onSelect }: EventDetailProps) { + const [tab, setTab] = useState('overview') const metadata = getMetadata(item) const duration = formatDuration(metadata.durationMs) const eventFields: DetailField[] = [ @@ -172,6 +209,47 @@ function EventDetail({ item }: EventDetailProps) { ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), ...(metadata.origin ? [{ label: 'Origin', value: metadata.origin }] : []), ] + const summaryFields = [ + ...(metadata.statusCode !== undefined + ? [ + { + label: 'Status', + value: String(metadata.statusCode), + tone: getStatusClass(metadata.statusCode), + }, + ] + : []), + ...(duration ? [{ label: 'Duration', value: duration }] : []), + ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), + ...(metadata.traceId + ? [{ label: 'Trace', value: metadata.traceId.slice(0, 8) }] + : []), + ] + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = detailTabs.indexOf(tab) + const nextIndex = + event.key === 'ArrowRight' + ? (currentIndex + 1) % detailTabs.length + : event.key === 'ArrowLeft' + ? (currentIndex - 1 + detailTabs.length) % detailTabs.length + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? detailTabs.length - 1 + : undefined + + if (nextIndex === undefined) { + return + } + + event.preventDefault() + const nextTab = detailTabs[nextIndex]! + setTab(nextTab) + requestAnimationFrame(() => { + document.getElementById(`event-detail-tab-${nextTab}`)?.focus() + }) + } return (
    @@ -209,17 +295,64 @@ function EventDetail({ item }: EventDetailProps) {
    {tab === 'overview' ? ( -
    +
    + {summaryFields.length > 0 ? ( +
    + {summaryFields.map((field) => ( + + ))} +
    + ) : null}
    + {relatedItems.length > 0 ? ( +
    +
    +

    + Related trace items +

    + + {relatedItems.length} related event{relatedItems.length === 1 ? '' : 's'} + +
    +
      + {relatedItems.map((relatedItem) => { + const relatedMetadata = getMetadata(relatedItem) + return ( +
    1. + +
    2. + ) + })} +
    +
    + ) : null}
    ) : ( -
    +
    )} @@ -236,10 +369,49 @@ export default function App() { ) const [items, setItems] = useState([]) const [selectedItemId, setSelectedItemId] = useState() + const [lastViewedItemId, setLastViewedItemId] = useState() + const [filter, setFilter] = useState('all') const [message, setMessage] = useState() const fallbackEventId = useRef(0) const presentation = getConnectionPresentation(connection) const selectedItem = items.find((item) => item.id === selectedItemId) ?? items[0] + const visibleItems = items.filter((item) => matchesEventFilter(item, filter)) + const relatedItems = selectedItem?.metadata?.traceId + ? items.filter( + (item) => + item.id !== selectedItem.id && + item.metadata?.traceId === selectedItem.metadata?.traceId + ) + : [] + const lastViewedIndex = lastViewedItemId + ? items.findIndex((item) => item.id === lastViewedItemId) + : -1 + const unseenItems = + lastViewedItemId === undefined + ? [] + : lastViewedIndex === -1 + ? items + : items.slice(lastViewedIndex + 1) + const newItems = unseenItems.filter((item) => matchesEventFilter(item, filter)) + const newItemCount = newItems.length + + const markItemsSeen = () => { + setLastViewedItemId(items.at(-1)?.id) + } + + const selectItem = (id: string) => { + setSelectedItemId(id) + markItemsSeen() + } + + const selectFilter = (nextFilter: EventFilter) => { + setFilter(nextFilter) + const nextSelectedItem = items.find((item) => matchesEventFilter(item, nextFilter)) + if (nextSelectedItem) { + setSelectedItemId(nextSelectedItem.id) + } + markItemsSeen() + } useEffect(() => { const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) @@ -293,8 +465,20 @@ export default function App() { >
    - Sentry CLI - + Sentry CLI +
    @@ -349,24 +533,73 @@ export default function App() { aria-labelledby="event-list-heading" className="flex min-h-0 w-80 shrink-0 flex-col border-r border-border bg-muted/30" > -
    -

    Events

    +
    +
    +

    Events

    + + {items.length} / 500 + +
    +
    + {eventFilters.map((eventFilter) => { + const count = items.filter((item) => + matchesEventFilter(item, eventFilter.id) + ).length + + return ( + + ) + })} +
    + {newItemCount > 0 ? ( + + ) : null}
      - {items.map((item) => ( + {visibleItems.map((item) => ( ))}
    - {selectedItem ? : null} + {selectedItem ? ( + + ) : null}
    )}
    diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts index 912e3cff61..6e19fb1d1e 100644 --- a/apps/local/src/lib/spotlight.ts +++ b/apps/local/src/lib/spotlight.ts @@ -12,6 +12,7 @@ export type LocalFeedItem = { export type EventMetadata = { title: string + level?: string method?: string route?: string statusCode?: number @@ -67,6 +68,7 @@ function getEventMetadata(payload: unknown, type: string): EventMetadata { return { title: transaction ?? route ?? type, + level: getString(event.level), method, route, statusCode: From 57deb754ddb2c7416db3446645c3e5abe0a3bf24 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 08:47:40 +0530 Subject: [PATCH 10/20] feat(local): Add trace inspector workspace --- apps/local/src/App.integration.test.tsx | 134 ++++++- apps/local/src/App.tsx | 341 ++++++++++-------- .../src/components/receiver-controls.tsx | 104 ++++++ apps/local/src/components/trace-waterfall.tsx | 167 +++++++++ apps/local/src/components/ui/badge.tsx | 1 + apps/local/src/lib/spotlight.test.ts | 100 +++++ apps/local/src/lib/spotlight.ts | 73 +++- apps/local/src/lib/trace-model.test.ts | 162 +++++++++ apps/local/src/lib/trace-model.ts | 235 ++++++++++++ 9 files changed, 1145 insertions(+), 172 deletions(-) create mode 100644 apps/local/src/components/receiver-controls.tsx create mode 100644 apps/local/src/components/trace-waterfall.tsx create mode 100644 apps/local/src/lib/trace-model.test.ts create mode 100644 apps/local/src/lib/trace-model.ts diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 12c151eeb4..74034dd52d 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -14,6 +14,8 @@ const SENTRY_CONTENT_TYPE = 'application/x-sentry-envelope' type EnvelopeOptions = { level?: string operation?: string + spanId?: string + spans?: unknown[] statusCode?: number startTimestamp?: number traceId?: string @@ -34,12 +36,14 @@ function envelope(transaction: string, options: EnvelopeOptions = {}): string { contexts: { trace: { ...(options.traceId ? { trace_id: options.traceId } : {}), + ...(options.spanId ? { span_id: options.spanId } : {}), ...(options.operation ? { op: options.operation } : {}), }, }, } : {}), ...(options.statusCode ? { request: { status_code: options.statusCode } } : {}), + ...(options.spans ? { spans: options.spans } : {}), }), '', ].join('\n') @@ -131,6 +135,7 @@ describe('local receiver to viewer integration', () => { const detail = screen.getByTestId('event-detail') expect(detail.textContent).toContain('GET /live') expect(detail.textContent).not.toContain('GET /live-2') + expect(detail.textContent).not.toContain('GETGET /live') const events = screen.getAllByLabelText('View transaction event') fireEvent.click(events[1]!) @@ -210,7 +215,67 @@ describe('local receiver to viewer integration', () => { } }) - test('shows other retained events from the selected trace', async () => { + test('marks an error entry in the mixed event feed', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /broken', { type: 'event', level: 'error' }) + + expect((await screen.findByLabelText('View event event')).textContent).toContain('Error') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('filters retained events from the top navigation search', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /customers/42') + await sendEnvelope(port, 'POST /orders') + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + fireEvent.change(screen.getByRole('searchbox', { name: 'Search events' }), { + target: { value: 'customers' }, + }) + + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(1) + expect(screen.getByTestId('event-list').textContent).toContain('/customers/42') + expect(screen.getByTestId('event-list').textContent).not.toContain('/orders') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('clears the local viewer feed from receiver options', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /clear-me') + await screen.findByLabelText('View transaction event') + + fireEvent.click(screen.getByRole('button', { name: 'Receiver options' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear all events' })) + + expect(screen.queryByLabelText('View transaction event')).toBeNull() + expect(screen.getByText('Waiting for events')).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('summarizes other retained events in the selected trace overview', async () => { const { server, port } = await startReceiver() try { @@ -223,9 +288,9 @@ describe('local receiver to viewer integration', () => { expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) }) - expect(screen.getByRole('heading', { name: 'Related trace items' })).not.toBeNull() - expect(screen.getByText('1 related event')).not.toBeNull() - expect(screen.getByRole('button', { name: 'View related GET /trace/second' })).not.toBeNull() + const details = screen.getByRole('table', { name: 'Event details' }) + expect(details.textContent).toContain('Related events') + expect(details.textContent).toContain('1') } finally { cleanup() await stopReceiver(server) @@ -285,7 +350,7 @@ describe('local receiver to viewer integration', () => { } }) - test('summarizes the selected HTTP event before its fields', async () => { + test('shows a copyable, grouped inspector table in the selected event overview', async () => { const { server, port } = await startReceiver() try { @@ -298,10 +363,61 @@ describe('local receiver to viewer integration', () => { traceId: 'summary-trace', }) - const summary = await screen.findByLabelText('Event summary') - expect(summary.textContent).toContain('201') - expect(summary.textContent).toContain('6.17ms') - expect(summary.textContent).toContain('http.server') + const details = await screen.findByRole('table', { name: 'Event details' }) + expect(details.textContent).toContain('201') + expect(details.textContent).toContain('6.17ms') + expect(details.textContent).toContain('http.server') + expect(screen.getByRole('rowheader', { name: 'Method' })).not.toBeNull() + expect(screen.getByRole('rowheader', { name: 'Trace ID' })).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Copy Route' })) + expect(screen.getByRole('status', { name: 'Route copied' })).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('shows a trace waterfall for a transaction with child spans', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /waterfall', { + operation: 'http.server', + spanId: 'root-span', + traceId: 'waterfall-trace', + startTimestamp: 1_699_999_999.988, + spans: [ + { + span_id: 'db-span', + parent_span_id: 'root-span', + op: 'db.query', + description: 'SELECT * FROM orders', + start_timestamp: 1_699_999_999.99, + timestamp: 1_699_999_999.994, + }, + { + span_id: 'http-span', + parent_span_id: 'root-span', + op: 'http.client', + description: 'GET https://inventory.test/items/42', + start_timestamp: 1_699_999_999.995, + timestamp: 1_699_999_999.998, + }, + ], + }) + + fireEvent.click(await screen.findByRole('tab', { name: 'Trace' })) + + expect(screen.getByRole('region', { name: 'Trace waterfall' })).not.toBeNull() + expect(screen.getByRole('columnheader', { name: 'Span' })).not.toBeNull() + expect(screen.getByRole('columnheader', { name: 'Timeline' })).not.toBeNull() + expect(screen.getByLabelText('Trace summary').textContent).toContain('3 spans') + expect(screen.getByText('db.query')).not.toBeNull() + expect(screen.getByText('http.client')).not.toBeNull() + expect(screen.getByTestId('waterfall-bar-db-span')).not.toBeNull() } finally { cleanup() await stopReceiver(server) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 18238c2379..738406c9d5 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,7 +1,8 @@ -import { Terminal } from 'lucide-react' -import { type KeyboardEvent, useEffect, useRef, useState } from 'react' +import { Check, Copy, Search, Terminal } from 'lucide-react' +import { Fragment, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' import { JsonView } from '@/components/json-view.tsx' -import { ThemeToggle } from '@/components/theme-toggle.tsx' +import { ReceiverControls } from '@/components/receiver-controls.tsx' +import { TraceWaterfall } from '@/components/trace-waterfall.tsx' import { Badge } from '@/components/ui/badge.tsx' import { Card, @@ -24,6 +25,7 @@ import { type EventMetadata, type LocalFeedItem, } from '@/lib/spotlight.ts' +import { buildTraceGroups, type TraceGroup } from '@/lib/trace-model.ts' type EventEntryProps = { item: LocalFeedItem @@ -46,13 +48,38 @@ function matchesEventFilter(item: LocalFeedItem, filter: EventFilter): boolean { } if (filter === 'errors') { - const { level, statusCode } = getMetadata(item) - return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) + return isErrorEvent(item) } return item.type === (filter === 'transactions' ? 'transaction' : 'log') } +function isErrorEvent(item: LocalFeedItem): boolean { + const { level, statusCode } = getMetadata(item) + return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) +} + +function matchesSearch(item: LocalFeedItem, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return true + } + + const metadata = getMetadata(item) + return [ + item.type, + item.text, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.spanId, + metadata.operation, + metadata.origin, + ].some((value) => value?.toLowerCase().includes(normalizedQuery)) +} + function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { if (timestamp === undefined) { return 'unknown time' @@ -108,6 +135,7 @@ function saveStreamUrl(streamUrl: string): void { function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { const metadata = getMetadata(item) const duration = formatDuration(metadata.durationMs) + const isError = isErrorEvent(item) return (
  • @@ -122,7 +150,9 @@ function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { >
    - {metadata.method ?? item.type} + + {isError ? 'Error' : metadata.method ?? metadata.operation ?? item.type} + {metadata.route ?? metadata.title}
    @@ -143,88 +173,80 @@ function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { type EventDetailProps = { item: LocalFeedItem + trace?: TraceGroup relatedItems: LocalFeedItem[] - onSelect: (id: string) => void } -type DetailTab = 'overview' | 'json' - -const detailTabs: DetailTab[] = ['overview', 'json'] +type DetailTab = 'overview' | 'trace' | 'json' type DetailField = { label: string value: string } -function SummaryMetric({ label, value, tone }: DetailField & { tone?: string }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ) +type DetailGroup = { + label: string + fields: DetailField[] } -function DetailSection({ title, fields }: { title: string; fields: DetailField[] }) { - if (fields.length === 0) { - return null +function copyText(value: string): void { + const fallbackCopy = () => { + const input = document.createElement('textarea') + input.value = value + input.setAttribute('readonly', '') + input.style.position = 'fixed' + input.style.opacity = '0' + document.body.appendChild(input) + input.select() + document.execCommand?.('copy') + input.remove() } - return ( -
    -

    - {title} -

    -
    - {fields.map((field) => ( -
    -
    {field.label}
    -
    {field.value}
    -
    - ))} -
    -
    - ) + if (navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(value).catch(fallbackCopy) + return + } + + fallbackCopy() } -function EventDetail({ item, relatedItems, onSelect }: EventDetailProps) { +function EventDetail({ item, trace, relatedItems }: EventDetailProps) { const [tab, setTab] = useState('overview') + const [copiedField, setCopiedField] = useState() + const detailTabs: DetailTab[] = trace?.spans.length ? ['overview', 'trace', 'json'] : ['overview', 'json'] const metadata = getMetadata(item) const duration = formatDuration(metadata.durationMs) - const eventFields: DetailField[] = [ - { label: 'Transaction', value: metadata.title }, - { label: 'Received', value: formatTimestamp(item.timestamp) }, - ] - const requestFields: DetailField[] = [ - ...(metadata.method ? [{ label: 'Method', value: metadata.method }] : []), - ...(metadata.route ? [{ label: 'Route', value: metadata.route }] : []), - ...(metadata.statusCode !== undefined - ? [{ label: 'Status', value: String(metadata.statusCode) }] - : []), - ...(duration ? [{ label: 'Duration', value: duration }] : []), - ] - const traceFields: DetailField[] = [ - ...(metadata.traceId ? [{ label: 'Trace ID', value: metadata.traceId }] : []), - ...(metadata.spanId ? [{ label: 'Span ID', value: metadata.spanId }] : []), - ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), - ...(metadata.origin ? [{ label: 'Origin', value: metadata.origin }] : []), - ] - const summaryFields = [ - ...(metadata.statusCode !== undefined - ? [ - { - label: 'Status', - value: String(metadata.statusCode), - tone: getStatusClass(metadata.statusCode), - }, - ] - : []), - ...(duration ? [{ label: 'Duration', value: duration }] : []), - ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), - ...(metadata.traceId - ? [{ label: 'Trace', value: metadata.traceId.slice(0, 8) }] - : []), + const detailGroups: DetailGroup[] = [ + { + label: 'Request', + fields: [ + ...(metadata.method ? [{ label: 'Method', value: metadata.method }] : []), + ...(metadata.route ? [{ label: 'Route', value: metadata.route }] : []), + ...(metadata.statusCode !== undefined + ? [{ label: 'Status', value: String(metadata.statusCode) }] + : []), + ...(duration ? [{ label: 'Duration', value: duration }] : []), + ], + }, + { + label: 'Trace context', + fields: [ + ...(metadata.traceId ? [{ label: 'Trace ID', value: metadata.traceId }] : []), + ...(metadata.spanId ? [{ label: 'Span ID', value: metadata.spanId }] : []), + ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), + ...(metadata.origin ? [{ label: 'Origin', value: metadata.origin }] : []), + ...(relatedItems.length > 0 + ? [ + { + label: 'Related events', + value: `${relatedItems.length} other event${relatedItems.length === 1 ? '' : 's'} in this trace`, + }, + ] + : []), + ], + }, ] + const hasDetailFields = detailGroups.some((group) => group.fields.length > 0) const handleTabKeyDown = (event: KeyboardEvent) => { const currentIndex = detailTabs.indexOf(tab) @@ -259,8 +281,8 @@ function EventDetail({ item, relatedItems, onSelect }: EventDetailProps) { >
    - {metadata.method ?? item.type} - {metadata.route ?? metadata.title} + {metadata.operation ?? item.type} + {metadata.title}
    @@ -277,6 +299,21 @@ function EventDetail({ item, relatedItems, onSelect }: EventDetailProps) { > Overview + {trace?.spans.length ? ( + + ) : null}
    {tab === 'overview' ? ( -
    - {summaryFields.length > 0 ? ( -
    - {summaryFields.map((field) => ( - - ))} -
    - ) : null} -
    - - -
    - -
    - {relatedItems.length > 0 ? ( -
    -
    -

    - Related trace items -

    - - {relatedItems.length} related event{relatedItems.length === 1 ? '' : 's'} - -
    -
      - {relatedItems.map((relatedItem) => { - const relatedMetadata = getMetadata(relatedItem) - return ( -
    1. - -
    2. - ) - })} -
    -
    - ) : null} +
    +
    + {hasDetailFields ? ( + + + + {detailGroups.map((group) => + group.fields.length > 0 ? ( + + + + + {group.fields.map((field) => { + const copied = copiedField === field.label + return ( + + + + + ) + })} + + ) : null + )} + +
    Captured event details
    + {group.label} +
    + {field.label} + +
    + {field.value} + +
    +
    + ) : ( +

    No additional event details.

    + )} + {copiedField ? {copiedField} copied : null}
    + ) : tab === 'trace' && trace ? ( +
    + +
    ) : (
    @@ -371,11 +407,17 @@ export default function App() { const [selectedItemId, setSelectedItemId] = useState() const [lastViewedItemId, setLastViewedItemId] = useState() const [filter, setFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') const [message, setMessage] = useState() const fallbackEventId = useRef(0) const presentation = getConnectionPresentation(connection) + const traces = useMemo(() => buildTraceGroups(items), [items]) const selectedItem = items.find((item) => item.id === selectedItemId) ?? items[0] + const selectedTrace = selectedItem?.metadata?.traceId + ? traces.find((trace) => trace.id === selectedItem.metadata?.traceId) + : undefined const visibleItems = items.filter((item) => matchesEventFilter(item, filter)) + const searchedItems = visibleItems.filter((item) => matchesSearch(item, searchQuery)) const relatedItems = selectedItem?.metadata?.traceId ? items.filter( (item) => @@ -392,7 +434,9 @@ export default function App() { : lastViewedIndex === -1 ? items : items.slice(lastViewedIndex + 1) - const newItems = unseenItems.filter((item) => matchesEventFilter(item, filter)) + const newItems = unseenItems.filter( + (item) => matchesEventFilter(item, filter) && matchesSearch(item, searchQuery) + ) const newItemCount = newItems.length const markItemsSeen = () => { @@ -406,13 +450,22 @@ export default function App() { const selectFilter = (nextFilter: EventFilter) => { setFilter(nextFilter) - const nextSelectedItem = items.find((item) => matchesEventFilter(item, nextFilter)) + const nextSelectedItem = items.find( + (item) => matchesEventFilter(item, nextFilter) && matchesSearch(item, searchQuery) + ) if (nextSelectedItem) { setSelectedItemId(nextSelectedItem.id) } markItemsSeen() } + const clearItems = () => { + setItems([]) + setSelectedItemId(undefined) + setLastViewedItemId(undefined) + setSearchQuery('') + } + useEffect(() => { const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) if (fragmentStreamUrl) { @@ -480,20 +533,19 @@ export default function App() { height="20" />
    -
    - - - {presentation.label} - - + +
    +
  • @@ -582,7 +634,7 @@ export default function App() { data-testid="event-list" className="min-h-0 flex-1 space-y-px overflow-y-auto" > - {visibleItems.map((item) => ( + {searchedItems.map((item) => ( ))} + {searchedItems.length === 0 ? ( +
  • No matching events.
  • + ) : null} {selectedItem ? ( ) : null}
    diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx new file mode 100644 index 0000000000..2847bdd960 --- /dev/null +++ b/apps/local/src/components/receiver-controls.tsx @@ -0,0 +1,104 @@ +import { ChevronDown, Moon, Sun, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTheme } from 'next-themes' +import type { ConnectionPresentation } from '@/lib/presentation.ts' + +type ReceiverControlsProps = { + connection: ConnectionPresentation + eventCount: number + onClear: () => void +} + +/** Status and viewer-only actions kept together without exposing receiver mutations. */ +export function ReceiverControls({ connection, eventCount, onClear }: ReceiverControlsProps) { + const [isOpen, setIsOpen] = useState(false) + const rootRef = useRef(null) + const { resolvedTheme, setTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + useEffect(() => { + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + document.addEventListener('mousedown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('mousedown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) + } + }, []) + + const dotClassName = + connection.tone === 'success' + ? 'bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]' + : connection.tone === 'warning' + ? 'bg-amber-500' + : 'bg-muted-foreground' + + return ( +
    +
    + + + + {connection.label.replace(' to local receiver', '')} + + {connection.label} + + +
    + {isOpen ? ( +
    + + +
    + ) : null} +
    + ) +} diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx new file mode 100644 index 0000000000..062c04aba5 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.tsx @@ -0,0 +1,167 @@ +import type { TraceGroup, TraceSpan } from '@/lib/trace-model.ts' + +type TraceWaterfallProps = { + trace: TraceGroup +} + +type PositionedSpan = { + span: TraceSpan + depth: number +} + +function formatDuration(durationMs: number | undefined): string { + if (durationMs === undefined) return '—' + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms` +} + +function getSpanPosition( + span: TraceSpan, + trace: TraceGroup +): { left: string; width: string } | undefined { + if ( + trace.startTimestamp === undefined || + trace.durationMs === undefined || + trace.durationMs <= 0 || + span.startTimestamp === undefined + ) { + return undefined + } + + const elapsedMs = (span.startTimestamp - trace.startTimestamp) * 1_000 + const left = Math.max(0, Math.min(100, (elapsedMs / trace.durationMs) * 100)) + const width = Math.max( + 1, + Math.min(100 - left, ((span.durationMs ?? 0) / trace.durationMs) * 100) + ) + + return { left: `${left}%`, width: `${width}%` } +} + +function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { + return spans.flatMap((span) => [ + { span, depth }, + ...flattenSpans(span.children, depth + 1), + ]) +} + +function isFailingSpan(span: TraceSpan): boolean { + const { level, statusCode } = span.item.metadata ?? {} + return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) +} + +function SpanRow({ + positionedSpan, + trace, + index, +}: { + positionedSpan: PositionedSpan + trace: TraceGroup + index: number +}) { + const { span, depth } = positionedSpan + const position = getSpanPosition(span, trace) + const failed = isFailingSpan(span) + + return ( +
    +
    + {depth > 0 ? ( +
    +
    + + ) +} + +/** A compact, read-only timeline for the spans contained in one local trace. */ +export function TraceWaterfall({ trace }: TraceWaterfallProps) { + if (trace.spans.length === 0) { + return ( +
    + This trace has no timestamped spans to display yet. +
    + ) + } + + const spans = flattenSpans(trace.roots) + + return ( +
    +
    +

    Trace

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + {trace.spans.length} span{trace.spans.length === 1 ? '' : 's'} + {trace.errorCount > 0 ? {trace.errorCount} error{trace.errorCount === 1 ? '' : 's'} : null} + {trace.logCount > 0 ? {trace.logCount} log{trace.logCount === 1 ? '' : 's'} : null} + Trace ID {trace.id.slice(0, 8)} +
    +
    +
    +
    +
    + Span +
    +
    + Timeline + +
    +
    + {spans.map((positionedSpan, index) => ( + + ))} +
    +
    + ) +} diff --git a/apps/local/src/components/ui/badge.tsx b/apps/local/src/components/ui/badge.tsx index 1d6abd39af..c97f945bae 100644 --- a/apps/local/src/components/ui/badge.tsx +++ b/apps/local/src/components/ui/badge.tsx @@ -10,6 +10,7 @@ const badgeVariants = cva( default: 'border-transparent bg-secondary text-secondary-foreground', success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', warning: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300', + destructive: 'border-destructive/20 bg-destructive/10 text-destructive', }, }, defaultVariants: { diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts index 4533e49876..c2b369bef7 100644 --- a/apps/local/src/lib/spotlight.test.ts +++ b/apps/local/src/lib/spotlight.test.ts @@ -43,6 +43,88 @@ describe("getStreamUrlFromHash", () => { }); describe("decodeEnvelope", () => { + test("unpacks batched span and log items from the Local receiver", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"version":2,"items":[{"trace_id":"trace-123","span_id":"span-456","parent_span_id":"root-span","name":"agent.run","start_timestamp":1700000000,"end_timestamp":1700000000.004,"attributes":{"sentry.op":{"value":"gen_ai.invoke_agent","type":"string"}}}]}],[{"type":"log"},{"version":2,"items":[{"trace_id":"trace-123","timestamp":1700000000.005,"level":"error","body":"Fixture request failed"}]}]]]', + "event-1" + ) + ).toMatchObject([ + { + id: "event-1:0", + type: "span", + timestamp: 1700000000.004, + metadata: { + title: "agent.run", + traceId: "trace-123", + spanId: "span-456", + operation: "gen_ai.invoke_agent", + durationMs: 4, + }, + }, + { + id: "event-1:1", + type: "log", + timestamp: 1700000000.005, + metadata: { + title: "Fixture request failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("describes standalone spans and logs with their own payload fields", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"trace_id":"trace-123","span_id":"span-456","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000,"timestamp":1700000000.004}],[{"type":"log"},{"trace_id":"trace-123","level":"error","body":"Inventory lookup failed","timestamp":1700000000.005}]]]', + "event-1" + ) + ).toMatchObject([ + { + type: "span", + metadata: { + title: "SELECT * FROM orders", + traceId: "trace-123", + spanId: "span-456", + operation: "db.query", + durationMs: 4, + }, + }, + { + type: "log", + metadata: { + title: "Inventory lookup failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("keeps the decoded payload available for trace views", () => { + const [item] = decodeEnvelope( + '[{},[[{"type":"transaction"},{"transaction":"GET /orders/42","contexts":{"trace":{"trace_id":"trace-123","span_id":"root-span"}},"spans":[{"span_id":"db-span","parent_span_id":"root-span","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000.001,"timestamp":1700000000.003}]}]]]', + "event-1" + ); + + expect((item as { payload?: unknown }).payload).toEqual({ + transaction: "GET /orders/42", + contexts: { trace: { trace_id: "trace-123", span_id: "root-span" } }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1700000000.001, + timestamp: 1700000000.003, + }, + ], + }); + }); + test("turns envelope items into displayable feed entries", () => { expect( decodeEnvelope( @@ -66,6 +148,24 @@ describe("decodeEnvelope", () => { origin: "auto.http.otel.http", }, text: '{\n "transaction": "GET /api/users/42",\n "start_timestamp": 1700000000,\n "timestamp": 1700000000.00668,\n "contexts": {\n "trace": {\n "trace_id": "trace-123",\n "span_id": "span-456",\n "op": "http.server",\n "data": {\n "http.method": "GET",\n "http.url": "http://127.0.0.1:3030/api/users/42",\n "http.response.status_code": 200,\n "sentry.origin": "auto.http.otel.http"\n }\n }\n }\n}', + payload: { + transaction: "GET /api/users/42", + start_timestamp: 1700000000, + timestamp: 1700000000.00668, + contexts: { + trace: { + trace_id: "trace-123", + span_id: "span-456", + op: "http.server", + data: { + "http.method": "GET", + "http.url": "http://127.0.0.1:3030/api/users/42", + "http.response.status_code": 200, + "sentry.origin": "auto.http.otel.http", + }, + }, + }, + }, }, ]); }); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts index 6e19fb1d1e..4f0aac7346 100644 --- a/apps/local/src/lib/spotlight.ts +++ b/apps/local/src/lib/spotlight.ts @@ -7,6 +7,7 @@ export type LocalFeedItem = { type: string timestamp?: number | string text: string + payload: unknown metadata?: EventMetadata } @@ -35,6 +36,11 @@ function getNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined } +function getAttributeString(attributes: Record, key: string): string | undefined { + const attribute = attributes[key] + return isRecord(attribute) ? getString(attribute.value) : getString(attribute) +} + function getRoute(url: string | undefined): string | undefined { if (!url) { return undefined @@ -50,24 +56,31 @@ function getRoute(url: string | undefined): string | undefined { function getEventMetadata(payload: unknown, type: string): EventMetadata { const event = isRecord(payload) ? payload : {} const transaction = getString(event.transaction) + const description = getString(event.description) + const message = getString(event.message) ?? getString(event.body) ?? getString(event.name) const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/) const contexts = isRecord(event.contexts) ? event.contexts : {} - const trace = isRecord(contexts.trace) ? contexts.trace : {} - const data = isRecord(trace.data) ? trace.data : {} + const trace = isRecord(contexts.trace) ? contexts.trace : event + const data = isRecord(trace.data) + ? trace.data + : isRecord(event.data) + ? event.data + : {} + const attributes = isRecord(event.attributes) ? event.attributes : {} const request = isRecord(event.request) ? event.request : {} const method = getString(request.method) ?? getString(data["http.method"]) ?? transactionMatch?.[1] const url = getString(request.url) ?? getString(data["http.url"]) const route = getRoute(url) ?? transactionMatch?.[2] const startTimestamp = getNumber(event.start_timestamp) - const timestamp = getNumber(event.timestamp) + const timestamp = getNumber(event.timestamp) ?? getNumber(event.end_timestamp) const durationMs = startTimestamp !== undefined && timestamp !== undefined ? Math.round((timestamp - startTimestamp) * 100_000) / 100 : undefined return { - title: transaction ?? route ?? type, + title: transaction ?? description ?? message ?? route ?? type, level: getString(event.level), method, route, @@ -76,9 +89,31 @@ function getEventMetadata(payload: unknown, type: string): EventMetadata { durationMs, traceId: getString(trace.trace_id) ?? getString(event.trace_id), spanId: getString(trace.span_id) ?? getString(event.span_id), - operation: getString(trace.op) ?? getString(event.transaction_op), - origin: getString(data["sentry.origin"]), + operation: + getString(trace.op) ?? + getString(event.op) ?? + getString(event.transaction_op) ?? + getAttributeString(attributes, "sentry.op"), + origin: + getString(data["sentry.origin"]) ?? + getString(event.origin) ?? + getAttributeString(attributes, "sentry.origin"), + } +} + +function getBatchedPayloadItems(payload: unknown, type: string): unknown[] { + if ((type !== "span" && type !== "log") || !isRecord(payload) || !Array.isArray(payload.items)) { + return [payload] + } + return payload.items.filter(isRecord) +} + +function getItemTimestamp(payload: unknown): number | string | undefined { + if (!isRecord(payload)) { + return undefined } + const timestamp = payload.timestamp ?? payload.end_timestamp + return typeof timestamp === "number" || typeof timestamp === "string" ? timestamp : undefined } function isLoopbackHost(hostname: string): boolean { @@ -147,21 +182,19 @@ export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { return [] } const header = item[0] - const payload = item[1] - const timestamp = isRecord(payload) ? payload.timestamp : undefined const type = typeof header.type === "string" ? header.type : "unknown" - return [ - { - id: `${eventId}:${index}`, - type, - timestamp: - typeof timestamp === "number" || typeof timestamp === "string" - ? timestamp - : undefined, - text: JSON.stringify(payload, null, 2) ?? String(payload), - metadata: getEventMetadata(payload, type), - }, - ] + const payloads = getBatchedPayloadItems(item[1], type) + return payloads.map((payload, payloadIndex) => ({ + id: + payloads.length === 1 + ? `${eventId}:${index}` + : `${eventId}:${index}:${payloadIndex}`, + type, + timestamp: getItemTimestamp(payload), + text: JSON.stringify(payload, null, 2) ?? String(payload), + payload, + metadata: getEventMetadata(payload, type), + })) }) } diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts new file mode 100644 index 0000000000..b48a63a5c9 --- /dev/null +++ b/apps/local/src/lib/trace-model.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "vitest" +import type { LocalFeedItem } from "./spotlight.js" +import { buildTraceGroups } from "./trace-model.js" + +function item(overrides: Partial): LocalFeedItem { + return { + id: "item-1", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /orders/42" }, + ...overrides, + } +} + +describe("buildTraceGroups", () => { + test("builds a timestamped span tree and correlates trace errors and logs", () => { + const traces = buildTraceGroups([ + item({ + id: "transaction", + timestamp: 1_700_000_000.012, + metadata: { + title: "GET /orders/42", + traceId: "trace-42", + spanId: "root-span", + operation: "http.server", + durationMs: 12, + }, + payload: { + transaction: "GET /orders/42", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.012, + contexts: { + trace: { trace_id: "trace-42", span_id: "root-span", op: "http.server" }, + }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_700_000_000.002, + timestamp: 1_700_000_000.005, + }, + { + span_id: "client-span", + parent_span_id: "root-span", + op: "http.client", + description: "GET https://inventory.test/items/42", + start_timestamp: 1_700_000_000.006, + timestamp: 1_700_000_000.01, + }, + ], + }, + }), + item({ + id: "log", + type: "log", + metadata: { title: "Inventory lookup finished", traceId: "trace-42", level: "info" }, + }), + item({ + id: "error", + type: "event", + metadata: { title: "Order failed", traceId: "trace-42", level: "error" }, + }), + ]) + + expect(traces).toHaveLength(1) + expect(traces[0]).toMatchObject({ + id: "trace-42", + title: "GET /orders/42", + itemCount: 3, + logCount: 1, + errorCount: 1, + startTimestamp: 1_700_000_000, + endTimestamp: 1_700_000_000.012, + durationMs: 12, + }) + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + operation: "http.server", + children: [ + { id: "db-span", operation: "db.query", durationMs: 3 }, + { id: "client-span", operation: "http.client", durationMs: 4 }, + ], + }, + ]) + }) + + test("keeps a span visible when its declared parent is absent", () => { + const traces = buildTraceGroups([ + item({ + metadata: { title: "orphaned work", traceId: "trace-orphan", spanId: "orphan-span" }, + payload: { + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.001, + contexts: { + trace: { + trace_id: "trace-orphan", + span_id: "orphan-span", + parent_span_id: "missing-parent", + op: "db.query", + }, + }, + }, + }), + ]) + + expect(traces[0]?.roots).toMatchObject([ + { id: "orphan-span", parentId: "missing-parent", operation: "db.query" }, + ]) + }) + + test("nests a standalone span using its top-level parent span id", () => { + const traces = buildTraceGroups([ + item({ + id: "root", + metadata: { + title: "POST /agent/run", + traceId: "trace-agent", + spanId: "root-span", + operation: "http.server", + }, + payload: { + transaction: "POST /agent/run", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.01, + contexts: { + trace: { trace_id: "trace-agent", span_id: "root-span", op: "http.server" }, + }, + }, + }), + item({ + id: "child", + type: "span", + metadata: { + title: "agent.run", + traceId: "trace-agent", + spanId: "child-span", + operation: "gen_ai.invoke_agent", + }, + payload: { + trace_id: "trace-agent", + span_id: "child-span", + parent_span_id: "root-span", + start_timestamp: 1_700_000_000.002, + end_timestamp: 1_700_000_000.006, + }, + }), + ]) + + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + children: [ + { id: "child-span", operation: "gen_ai.invoke_agent", durationMs: 4 }, + ], + }, + ]) + }) +}) diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts new file mode 100644 index 0000000000..999db4e65a --- /dev/null +++ b/apps/local/src/lib/trace-model.ts @@ -0,0 +1,235 @@ +import type { LocalFeedItem } from "./spotlight.js" + +export type TraceSpan = { + id: string + parentId?: string + operation?: string + description: string + startTimestamp?: number + endTimestamp?: number + durationMs?: number + item: LocalFeedItem + children: TraceSpan[] +} + +export type TraceGroup = { + id: string + title: string + items: LocalFeedItem[] + roots: TraceSpan[] + spans: TraceSpan[] + logCount: number + errorCount: number + itemCount: number + startTimestamp?: number + endTimestamp?: number + durationMs?: number +} + +type MutableTraceGroup = Omit & { + spanMap: Map +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +function getTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function getTraceContext(payload: unknown): Record { + if (!isRecord(payload)) { + return {} + } + const contexts = isRecord(payload.contexts) ? payload.contexts : {} + return isRecord(contexts.trace) ? contexts.trace : {} +} + +function getPayloadSpan(payload: unknown, item: LocalFeedItem): TraceSpan | undefined { + const event = isRecord(payload) ? payload : {} + const trace = getTraceContext(payload) + const id = getString(trace.span_id) ?? item.metadata?.spanId + if (!id) { + return undefined + } + const startTimestamp = getTimestamp(event.start_timestamp) + const endTimestamp = + getTimestamp(event.timestamp) ?? + getTimestamp(event.end_timestamp) ?? + getTimestamp(item.timestamp) + const operation = getString(trace.op) ?? item.metadata?.operation + const description = + getString(event.transaction) ?? + item.metadata?.route ?? + item.metadata?.title ?? + operation ?? + item.type + + return { + id, + parentId: getString(trace.parent_span_id) ?? getString(event.parent_span_id), + operation, + description, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + } +} + +function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] { + const event = isRecord(payload) ? payload : {} + if (!Array.isArray(event.spans)) { + return [] + } + + return event.spans.flatMap((value) => { + if (!isRecord(value)) { + return [] + } + const id = getString(value.span_id) + if (!id) { + return [] + } + const startTimestamp = getTimestamp(value.start_timestamp) + const endTimestamp = getTimestamp(value.timestamp) + const operation = getString(value.op) + return [ + { + id, + parentId: getString(value.parent_span_id), + operation, + description: getString(value.description) ?? operation ?? id, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + }, + ] + }) +} + +function getDurationMs(startTimestamp: number | undefined, endTimestamp: number | undefined): number | undefined { + if (startTimestamp === undefined || endTimestamp === undefined || endTimestamp < startTimestamp) { + return undefined + } + return Math.round((endTimestamp - startTimestamp) * 100_000) / 100 +} + +function isError(item: LocalFeedItem): boolean { + const { level, statusCode } = item.metadata ?? {} + return level === "error" || level === "fatal" || (statusCode !== undefined && statusCode >= 500) +} + +function sortByTimestamp(items: T[]): T[] { + return items.sort((left, right) => (left.startTimestamp ?? Infinity) - (right.startTimestamp ?? Infinity)) +} + +function updateTraceTiming(trace: MutableTraceGroup): void { + const timestamps = trace.items + .flatMap((item) => [getTimestamp(item.timestamp)]) + .concat( + [...trace.spanMap.values()].flatMap((span) => [span.startTimestamp, span.endTimestamp]) + ) + .filter((timestamp): timestamp is number => timestamp !== undefined) + + if (timestamps.length === 0) { + return + } + + trace.startTimestamp = Math.min(...timestamps) + trace.endTimestamp = Math.max(...timestamps) + trace.durationMs = getDurationMs(trace.startTimestamp, trace.endTimestamp) +} + +/** Build a small, read-only trace model once for every view in Local. */ +export function buildTraceGroups(items: readonly LocalFeedItem[]): TraceGroup[] { + const traces = new Map() + + for (const item of items) { + const traceId = item.metadata?.traceId + if (!traceId) { + continue + } + + const trace: MutableTraceGroup = traces.get(traceId) ?? { + id: traceId, + title: item.metadata?.title ?? item.type, + items: [], + logCount: 0, + errorCount: 0, + spanMap: new Map(), + } + if (!traces.has(traceId)) { + traces.set(traceId, trace) + } + + trace.items.push(item) + if (item.type === "log") { + trace.logCount += 1 + } + if (isError(item)) { + trace.errorCount += 1 + } + + if (item.type === "transaction" || item.type === "span") { + const rootSpan = getPayloadSpan(item.payload, item) + if (rootSpan) { + trace.spanMap.set(rootSpan.id, rootSpan) + } + } + for (const childSpan of getChildSpans(item.payload, item)) { + trace.spanMap.set(childSpan.id, childSpan) + } + updateTraceTiming(trace) + } + + return [...traces.values()] + .map((trace) => { + const spans = sortByTimestamp([...trace.spanMap.values()]) + const roots: TraceSpan[] = [] + for (const span of spans) { + span.children = [] + } + for (const span of spans) { + const parent = span.parentId ? trace.spanMap.get(span.parentId) : undefined + if (parent && parent !== span) { + parent.children.push(span) + } else { + roots.push(span) + } + } + for (const span of spans) { + sortByTimestamp(span.children) + } + + return { + id: trace.id, + title: trace.title, + items: trace.items, + roots: sortByTimestamp(roots), + spans, + logCount: trace.logCount, + errorCount: trace.errorCount, + itemCount: trace.items.length, + startTimestamp: trace.startTimestamp, + endTimestamp: trace.endTimestamp, + durationMs: trace.durationMs, + } + }) + .sort((left, right) => (right.endTimestamp ?? 0) - (left.endTimestamp ?? 0)) +} From 34b3141ee76527c362e0e391ffb22ec760f51e99 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 08:54:41 +0530 Subject: [PATCH 11/20] style(local): Refine trace timing ruler --- apps/local/src/App.integration.test.tsx | 5 +++- apps/local/src/components/trace-waterfall.tsx | 23 ++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 74034dd52d..91173ccece 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -413,10 +413,13 @@ describe('local receiver to viewer integration', () => { expect(screen.getByRole('region', { name: 'Trace waterfall' })).not.toBeNull() expect(screen.getByRole('columnheader', { name: 'Span' })).not.toBeNull() - expect(screen.getByRole('columnheader', { name: 'Timeline' })).not.toBeNull() + expect( + screen.getByRole('columnheader', { name: 'Timeline from 0ms to 12ms' }) + ).not.toBeNull() expect(screen.getByLabelText('Trace summary').textContent).toContain('3 spans') expect(screen.getByText('db.query')).not.toBeNull() expect(screen.getByText('http.client')).not.toBeNull() + expect(screen.getByLabelText('SELECT * FROM orders duration 4.00ms')).not.toBeNull() expect(screen.getByTestId('waterfall-bar-db-span')).not.toBeNull() } finally { cleanup() diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx index 062c04aba5..8b6f55cfca 100644 --- a/apps/local/src/components/trace-waterfall.tsx +++ b/apps/local/src/components/trace-waterfall.tsx @@ -89,7 +89,11 @@ function SpanRow({
    -
    +
    @@ -148,9 +152,16 @@ export function TraceWaterfall({ trace }: TraceWaterfallProps) {
    Span
    -
    - Timeline - +
    + Timeline +
    {spans.map((positionedSpan, index) => ( From 0e2d11bef1e9d0e41fd2441d12845007ee4622dc Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 09:01:30 +0530 Subject: [PATCH 12/20] feat(local): Improve trace inspection controls --- apps/local/src/App.integration.test.tsx | 5 +- apps/local/src/App.tsx | 22 +-------- apps/local/src/components/json-view.tsx | 45 ++++++++++++++--- apps/local/src/components/trace-waterfall.tsx | 49 ++++++++++++------- apps/local/src/components/ui/card.tsx | 2 +- apps/local/src/index.css | 2 +- apps/local/src/lib/clipboard.ts | 21 ++++++++ 7 files changed, 97 insertions(+), 49 deletions(-) create mode 100644 apps/local/src/lib/clipboard.ts diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 91173ccece..7029faab79 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -147,6 +147,8 @@ describe('local receiver to viewer integration', () => { fireEvent.click(screen.getByRole('tab', { name: 'JSON' })) const code = screen.getByTestId('highlighted-json') expect(code.textContent).toContain('GET /live-2') + fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' })) + expect(screen.getByRole('status', { name: 'JSON copied' })).not.toBeNull() await waitFor(() => { expect(screen.getByTestId('highlighted-json').querySelector('.shiki')).not.toBeNull() }) @@ -416,10 +418,11 @@ describe('local receiver to viewer integration', () => { expect( screen.getByRole('columnheader', { name: 'Timeline from 0ms to 12ms' }) ).not.toBeNull() + expect(screen.getByRole('columnheader', { name: 'Duration' })).not.toBeNull() expect(screen.getByLabelText('Trace summary').textContent).toContain('3 spans') expect(screen.getByText('db.query')).not.toBeNull() expect(screen.getByText('http.client')).not.toBeNull() - expect(screen.getByLabelText('SELECT * FROM orders duration 4.00ms')).not.toBeNull() + expect(screen.getByRole('cell', { name: 'Duration 4.00ms' })).not.toBeNull() expect(screen.getByTestId('waterfall-bar-db-span')).not.toBeNull() } finally { cleanup() diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 738406c9d5..3c218e38fe 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -15,6 +15,7 @@ import { getConnectionPresentation, type ConnectionState, } from '@/lib/presentation.ts' +import { copyText } from '@/lib/clipboard.ts' import { appendBounded, decodeEnvelope, @@ -189,27 +190,6 @@ type DetailGroup = { fields: DetailField[] } -function copyText(value: string): void { - const fallbackCopy = () => { - const input = document.createElement('textarea') - input.value = value - input.setAttribute('readonly', '') - input.style.position = 'fixed' - input.style.opacity = '0' - document.body.appendChild(input) - input.select() - document.execCommand?.('copy') - input.remove() - } - - if (navigator.clipboard?.writeText) { - void navigator.clipboard.writeText(value).catch(fallbackCopy) - return - } - - fallbackCopy() -} - function EventDetail({ item, trace, relatedItems }: EventDetailProps) { const [tab, setTab] = useState('overview') const [copiedField, setCopiedField] = useState() diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx index e7922b5310..7eaa226065 100644 --- a/apps/local/src/components/json-view.tsx +++ b/apps/local/src/components/json-view.tsx @@ -3,7 +3,9 @@ import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' import json from '@shikijs/langs/json' import githubDark from '@shikijs/themes/github-dark' import githubLight from '@shikijs/themes/github-light' +import { Check, Copy } from 'lucide-react' import { useEffect, useState } from 'react' +import { copyText } from '@/lib/clipboard.ts' type JsonViewProps = { code: string @@ -18,9 +20,11 @@ const highlighter = createHighlighterCore({ /** Render JSON with Shiki only after its containing event has been expanded. */ export function JsonView({ code }: JsonViewProps) { const [html, setHtml] = useState() + const [copied, setCopied] = useState(false) useEffect(() => { let disposed = false + setCopied(false) void highlighter .then((instance) => @@ -47,19 +51,44 @@ export function JsonView({ code }: JsonViewProps) { } }, [code]) + const copyJson = () => { + copyText(code) + setCopied(true) + } + + const copyButton = ( + + ) + if (!html) { return ( -
    -        {code}
    -      
    +
    +
    {copyButton}
    + {copied ? JSON copied : null} +
    +          {code}
    +        
    +
    ) } return ( -
    +
    +
    {copyButton}
    + {copied ? JSON copied : null} +
    +
    ) } diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx index 8b6f55cfca..4c58e42c97 100644 --- a/apps/local/src/components/trace-waterfall.tsx +++ b/apps/local/src/components/trace-waterfall.tsx @@ -7,6 +7,7 @@ type TraceWaterfallProps = { type PositionedSpan = { span: TraceSpan depth: number + isLastChild: boolean } function formatDuration(durationMs: number | undefined): string { @@ -38,8 +39,8 @@ function getSpanPosition( } function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { - return spans.flatMap((span) => [ - { span, depth }, + return spans.flatMap((span, index) => [ + { span, depth, isLastChild: index === spans.length - 1 }, ...flattenSpans(span.children, depth + 1), ]) } @@ -58,22 +59,29 @@ function SpanRow({ trace: TraceGroup index: number }) { - const { span, depth } = positionedSpan + const { span, depth, isLastChild } = positionedSpan const position = getSpanPosition(span, trace) const failed = isFailingSpan(span) return (
    {depth > 0 ? ( -
    -
    +
    Span
    Timeline
    +
    + Duration +
    {spans.map((positionedSpan, index) => ( ) { return (
    ) diff --git a/apps/local/src/index.css b/apps/local/src/index.css index eccada4c90..355a4442e1 100644 --- a/apps/local/src/index.css +++ b/apps/local/src/index.css @@ -19,7 +19,7 @@ --border: oklch(0.9 0.012 285); --input: oklch(0.9 0.012 285); --ring: oklch(0.56 0.25 292); - --radius: 0.7rem; + --radius: 0.5rem; } .dark { diff --git a/apps/local/src/lib/clipboard.ts b/apps/local/src/lib/clipboard.ts new file mode 100644 index 0000000000..137e9f0d5f --- /dev/null +++ b/apps/local/src/lib/clipboard.ts @@ -0,0 +1,21 @@ +/** Copy text while retaining a small fallback for browsers without Clipboard API access. */ +export function copyText(value: string): void { + const fallbackCopy = () => { + const input = document.createElement('textarea') + input.value = value + input.setAttribute('readonly', '') + input.style.position = 'fixed' + input.style.opacity = '0' + document.body.appendChild(input) + input.select() + document.execCommand?.('copy') + input.remove() + } + + if (navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(value).catch(fallbackCopy) + return + } + + fallbackCopy() +} From 1a0240127fd75c60bf1dd526d7d1272f6f09d329 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 09:06:33 +0530 Subject: [PATCH 13/20] fix(local): Reset JSON copy state on selection --- apps/local/src/App.integration.test.tsx | 3 +++ apps/local/src/components/json-view.tsx | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 7029faab79..3c8c3b3771 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -152,6 +152,9 @@ describe('local receiver to viewer integration', () => { await waitFor(() => { expect(screen.getByTestId('highlighted-json').querySelector('.shiki')).not.toBeNull() }) + fireEvent.click(events[0]!) + fireEvent.click(screen.getByRole('tab', { name: 'JSON' })) + expect(screen.getByRole('button', { name: 'Copy JSON' }).textContent).toContain('Copy JSON') } finally { cleanup() await stopReceiver(server) diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx index 7eaa226065..4ddfe2a6f8 100644 --- a/apps/local/src/components/json-view.tsx +++ b/apps/local/src/components/json-view.tsx @@ -24,7 +24,6 @@ export function JsonView({ code }: JsonViewProps) { useEffect(() => { let disposed = false - setCopied(false) void highlighter .then((instance) => From 9c627eb1f6137aaf79e107c27a46ddf88f6b99ab Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 10:57:03 +0530 Subject: [PATCH 14/20] docs(local): Define viewer connection landing --- ...26-09-09-local-viewer-connection-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md diff --git a/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md b/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md new file mode 100644 index 0000000000..e3cb58933c --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md @@ -0,0 +1,86 @@ +# Local Viewer Connection Landing + +## Goal + +Make a bare `local.sentry.dev` visit useful without weakening the existing +CLI-paired local receiver boundary. The common case should connect to a +running default receiver without a URL fragment; custom local and remote +streams remain an explicit user choice. + +## Connection policy + +The viewer resolves one endpoint in this order: + +1. A valid CLI-provided `#stream` fragment. +2. A saved loopback endpoint from local storage. +3. The default receiver, `http://localhost:8969/stream`. + +The UI removes the fragment from the address bar after reading it. A +successfully connected loopback endpoint is saved in local storage so a later +bare visit can reconnect. The default is saved only after it opens +successfully. + +Users can submit an endpoint through the connection landing page. Loopback +`http` or `https` endpoints with a `/stream` path are accepted and persisted. +Remote endpoints must use `https`, have no embedded user credentials, and are +kept only in session storage. Remote streams connect directly from the browser; +`local.sentry.dev` never proxies them or receives their endpoint. + +Remote servers are responsible for allowing the viewer origin with CORS. This +MVP does not support custom authorization headers because native `EventSource` +does not provide them. A user may paste an HTTPS stream URL with its own query +parameters when their receiver uses a URL-scoped token. + +## User experience + +When no receiver has opened yet, render one centered connection landing panel +instead of separate setup and empty-feed surfaces. + +- Start by showing that the default receiver is being checked. +- When it opens, move directly to the normal event workspace and its + `Waiting for events` state. +- When it cannot open, reveal a prefilled endpoint field, concise error text, + a Connect button, and a copyable `sentry local serve --open` command. +- An Advanced disclosure explains custom local versus remote endpoints and the + remote HTTPS requirement. +- A connected, empty workspace still offers a small Change receiver action, + rather than forcing users to edit the browser URL. + +The landing form has a visible label, validation feedback, keyboard submission, +and equivalent light and dark presentation. + +## Components and boundaries + +`apps/local/src/lib/spotlight.ts` owns endpoint parsing and classification. It +returns a normalized endpoint and persistence policy (`loopback` or `remote`) +without knowing about React or storage. + +`App.tsx` owns resolution order, storage reads/writes, EventSource lifecycle, +and the state transition between connecting, connected, and failed states. +The connection landing component is presentational: it receives the input, +status, validation message, and callbacks. It does not create EventSources or +access storage directly. + +The local CLI and receiver remain unchanged. Their existing `--open` fragment +continues to be the authoritative pairing path, and receiver CORS remains +limited to its existing read-only `/stream` policy for `local.sentry.dev`. + +## Failure handling + +Malformed URLs, non-loopback HTTP remote endpoints, credential-bearing URLs, +and paths other than `/stream` are rejected before a connection attempt. A +failed EventSource displays actionable status while retaining the entered URL. +Submitting another endpoint closes the prior EventSource before opening the +next one. Clearing the feed does not discard a selected receiver. + +## Verification + +- Pure tests cover source priority, loopback/remote classification, malformed + endpoint rejection, persistence policy, and query-token preservation. +- Integration tests cover default connection, fallback form, manual local + reconnection after reload, remote session-only behavior, error recovery, and + clean EventSource replacement. +- Run the local app test suite, lint, and production build, plus the focused + CLI local receiver tests. +- Manually verify the no-receiver, default-receiver, custom-receiver, and + failed-receiver views in both light and dark themes. From b851362b8cfde0dc208893197463f7a499aefde9 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 11:08:30 +0530 Subject: [PATCH 15/20] feat(local): Classify custom stream endpoints --- apps/local/src/lib/spotlight.test.ts | 43 ++++++++++++++++++++ apps/local/src/lib/spotlight.ts | 61 ++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts index c2b369bef7..2a1b448115 100644 --- a/apps/local/src/lib/spotlight.test.ts +++ b/apps/local/src/lib/spotlight.test.ts @@ -1,13 +1,42 @@ import { describe, expect, test } from "vitest"; import { appendBounded, + DEFAULT_STREAM_URL, decodeEnvelope, + parseStreamEndpoint, getPreferredStreamUrl, + resolveInitialStreamUrl, getStreamUrlFromHash, getStreamUrlFromStorage, } from "./spotlight.js"; describe("getStreamUrlFromHash", () => { + test("classifies the default stream as loopback", () => { + expect(DEFAULT_STREAM_URL).toBe("http://localhost:8969/stream"); + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: "loopback", + }); + }); + + test("accepts an HTTPS remote stream with a query token", () => { + expect( + parseStreamEndpoint("https://receiver.example/stream?token=abc") + ).toEqual({ + url: "https://receiver.example/stream?token=abc", + kind: "remote", + }); + }); + + test.each([ + "http://receiver.example/stream", + "https://user:pass@receiver.example/stream", + "https://receiver.example/other", + "https://receiver.example/stream#fragment", + ])("rejects unsafe custom endpoint %s", (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined(); + }); + test("accepts a loopback stream URL", () => { expect( getStreamUrlFromHash( @@ -40,6 +69,20 @@ describe("getStreamUrlFromHash", () => { ) ).toBe("http://localhost:9000/stream"); }); + + test("uses CLI pairing, saved endpoints, then the default in priority order", () => { + expect( + resolveInitialStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + null, + null + ) + ).toBe("http://localhost:9000/stream"); + expect( + resolveInitialStreamUrl("", "http://127.0.0.1:8970/stream", null) + ).toBe("http://127.0.0.1:8970/stream"); + expect(resolveInitialStreamUrl("", null, null)).toBe(DEFAULT_STREAM_URL); + }); }); describe("decodeEnvelope", () => { diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts index 4f0aac7346..7d9b1df147 100644 --- a/apps/local/src/lib/spotlight.ts +++ b/apps/local/src/lib/spotlight.ts @@ -1,7 +1,16 @@ export const SENTRY_ENVELOPE_EVENT = "application/x-sentry-envelope" export const STREAM_STORAGE_KEY = "sentry.local.stream-url" +export const REMOTE_STREAM_STORAGE_KEY = "sentry.local.remote-stream-url" +export const DEFAULT_STREAM_URL = "http://localhost:8969/stream" const MAX_FEED_ITEMS = 500 +export type StreamEndpointKind = "loopback" | "remote" + +export type StreamEndpoint = { + url: string + kind: StreamEndpointKind +} + export type LocalFeedItem = { id: string type: string @@ -121,39 +130,63 @@ function isLoopbackHost(hostname: string): boolean { return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" } -function normalizeStreamUrl(stream: string | null): string | undefined { +/** Normalize an explicit connection target and classify its persistence policy. */ +export function parseStreamEndpoint(stream: string | null): StreamEndpoint | undefined { if (!stream) { return undefined } try { const url = new URL(stream) if ( - url.protocol !== "http:" || - !isLoopbackHost(url.hostname) || url.pathname !== "/stream" || url.username || url.password || - url.search || url.hash ) { return undefined } - return url.toString() + if (isLoopbackHost(url.hostname)) { + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.search) { + return undefined + } + return { url: url.toString(), kind: "loopback" } + } + if (url.protocol === "https:") { + return { url: url.toString(), kind: "remote" } + } + return undefined } catch { return undefined } } +function normalizeLoopbackStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream) + return endpoint?.kind === "loopback" ? endpoint.url : undefined +} + +function normalizeRemoteStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream) + return endpoint?.kind === "remote" ? endpoint.url : undefined +} + /** Read the CLI-provided stream endpoint without allowing arbitrary targets. */ export function getStreamUrlFromHash(hash: string): string | undefined { - return normalizeStreamUrl( + return normalizeLoopbackStreamUrl( new URLSearchParams(hash.replace(/^#/, "")).get("stream") ) } /** Validate a stream endpoint saved by an earlier CLI-launched tab. */ export function getStreamUrlFromStorage(stream: string | null): string | undefined { - return normalizeStreamUrl(stream) + return normalizeLoopbackStreamUrl(stream) +} + +/** Validate an explicit remote stream saved for the current browser session. */ +export function getRemoteStreamUrlFromStorage( + stream: string | null +): string | undefined { + return normalizeRemoteStreamUrl(stream) } /** Prefer a newly supplied CLI endpoint over a previously saved one. */ @@ -164,6 +197,20 @@ export function getPreferredStreamUrl( return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream) } +/** Resolve the bare viewer's receiver in the same safe order as the UI. */ +export function resolveInitialStreamUrl( + hash: string, + savedLoopbackStream: string | null, + savedRemoteStream: string | null +): string { + return ( + getStreamUrlFromHash(hash) ?? + getStreamUrlFromStorage(savedLoopbackStream) ?? + getRemoteStreamUrlFromStorage(savedRemoteStream) ?? + DEFAULT_STREAM_URL + ) +} + /** Decode the event payload emitted by the local receiver's SSE endpoint. */ export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { let parsed: unknown From ca1302890db8722c71b73a6866d016bf98471dc7 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 11:20:01 +0530 Subject: [PATCH 16/20] feat(local): Add viewer receiver connection --- apps/local/src/App.integration.test.tsx | 77 ++++++++++++ apps/local/src/App.tsx | 117 ++++++++++++------ .../src/components/connection-landing.tsx | 94 ++++++++++++++ apps/local/src/lib/presentation.test.ts | 7 ++ apps/local/src/lib/presentation.ts | 4 +- 5 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 apps/local/src/components/connection-landing.tsx diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 3c8c3b3771..5687d4f2dc 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ThemeProvider } from '@/components/theme-provider.tsx' import App from './App.tsx' +import { + REMOTE_STREAM_STORAGE_KEY, + STREAM_STORAGE_KEY, +} from './lib/spotlight.ts' import { buildApp, tryListen, @@ -87,9 +91,31 @@ function renderViewer(port: number) { ) } +function renderBareViewer() { + window.history.replaceState(null, '', '/') + return render( + + + + ) +} + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null + onerror: ((event: Event) => void) | null = null + + constructor(_url: string) { + super() + queueMicrotask(() => this.onopen?.(new Event('open'))) + } + + close() {} +} + describe('local receiver to viewer integration', () => { beforeEach(() => { window.localStorage.clear() + window.sessionStorage.clear() vi.stubGlobal('EventSource', NodeEventSource) vi.stubGlobal('matchMedia', () => ({ matches: false, @@ -161,6 +187,57 @@ describe('local receiver to viewer integration', () => { } }) + test('offers a useful connection landing for a bare viewer visit', () => { + renderBareViewer() + + expect(screen.getByText('Looking for Sentry Local')).not.toBeNull() + const endpoint = screen.getByLabelText('Receiver endpoint') as HTMLInputElement + expect(endpoint.value).toBe('http://localhost:8969/stream') + expect(screen.getByRole('button', { name: 'Connect' })).not.toBeNull() + expect(screen.getByText('Advanced connection')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Copy local serve command' })).not.toBeNull() + }) + + test('connects a bare viewer to a custom loopback receiver and saves it after opening', async () => { + const { server, port } = await startReceiver() + + try { + renderBareViewer() + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: `http://127.0.0.1:${port}/stream` }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await screen.findByText('Connected to local receiver') + expect(window.localStorage.getItem('sentry.local.stream-url')).toBe( + `http://127.0.0.1:${port}/stream` + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('keeps a successfully opened remote receiver only for this browser session', async () => { + vi.stubGlobal('EventSource', OpeningEventSource) + renderBareViewer() + await screen.findByText('Connected to local receiver') + window.localStorage.clear() + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'https://receiver.example/stream?token=abc' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => { + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + 'https://receiver.example/stream?token=abc' + ) + }) + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull() + }) + test('replays an envelope buffered before the viewer opens', async () => { const { server, port } = await startReceiver() diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 3c218e38fe..ff5f7506e8 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,16 +1,10 @@ import { Check, Copy, Search, Terminal } from 'lucide-react' import { Fragment, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' import { JsonView } from '@/components/json-view.tsx' +import { ConnectionLanding } from '@/components/connection-landing.tsx' import { ReceiverControls } from '@/components/receiver-controls.tsx' import { TraceWaterfall } from '@/components/trace-waterfall.tsx' import { Badge } from '@/components/ui/badge.tsx' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card.tsx' import { getConnectionPresentation, type ConnectionState, @@ -18,9 +12,11 @@ import { import { copyText } from '@/lib/clipboard.ts' import { appendBounded, + DEFAULT_STREAM_URL, decodeEnvelope, - getPreferredStreamUrl, - getStreamUrlFromHash, + parseStreamEndpoint, + REMOTE_STREAM_STORAGE_KEY, + resolveInitialStreamUrl, SENTRY_ENVELOPE_EVENT, STREAM_STORAGE_KEY, type EventMetadata, @@ -125,6 +121,14 @@ function getSavedStream(): string | null { } } +function getSavedRemoteStream(): string | null { + try { + return window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY) + } catch { + return null + } +} + function saveStreamUrl(streamUrl: string): void { try { window.localStorage.setItem(STREAM_STORAGE_KEY, streamUrl) @@ -133,6 +137,14 @@ function saveStreamUrl(streamUrl: string): void { } } +function saveRemoteStreamUrl(streamUrl: string): void { + try { + window.sessionStorage.setItem(REMOTE_STREAM_STORAGE_KEY, streamUrl) + } catch { + // Browser policies can disable storage; the active tab still works. + } +} + function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { const metadata = getMetadata(item) const duration = formatDuration(metadata.durationMs) @@ -377,12 +389,13 @@ function EventDetail({ item, trace, relatedItems }: EventDetailProps) { } export default function App() { - const [streamUrl] = useState(() => - getPreferredStreamUrl(window.location.hash, getSavedStream()) - ) - const [connection, setConnection] = useState( - streamUrl ? 'connecting' : 'missing' + const [streamUrl, setStreamUrl] = useState(() => + resolveInitialStreamUrl(window.location.hash, getSavedStream(), getSavedRemoteStream()) ) + const [connection, setConnection] = useState('connecting') + const [draftEndpoint, setDraftEndpoint] = useState(streamUrl) + const [isEditingReceiver, setIsEditingReceiver] = useState(false) + const [connectionError, setConnectionError] = useState() const [items, setItems] = useState([]) const [selectedItemId, setSelectedItemId] = useState() const [lastViewedItemId, setLastViewedItemId] = useState() @@ -446,21 +459,27 @@ export default function App() { setSearchQuery('') } - useEffect(() => { - const fragmentStreamUrl = getStreamUrlFromHash(window.location.hash) - if (fragmentStreamUrl) { - saveStreamUrl(fragmentStreamUrl) + const connectToDraft = () => { + const endpoint = parseStreamEndpoint(draftEndpoint) + if (!endpoint) { + setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') + setConnection('failed') + return } + setDraftEndpoint(endpoint.url) + setStreamUrl(endpoint.url) + setConnection('connecting') + setConnectionError(undefined) + setIsEditingReceiver(false) + } + + useEffect(() => { if (window.location.hash) { window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`) } }, []) useEffect(() => { - if (!streamUrl) { - return - } - const source = new EventSource(streamUrl) const onEnvelope = (event: Event) => { try { @@ -476,12 +495,23 @@ export default function App() { source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) source.onopen = () => { + const endpoint = parseStreamEndpoint(streamUrl) + if (endpoint?.kind === 'loopback') { + saveStreamUrl(endpoint.url) + } else if (endpoint?.kind === 'remote') { + saveRemoteStreamUrl(endpoint.url) + } setConnection('connected') + setConnectionError(undefined) setMessage(undefined) } source.onerror = () => { - setConnection('reconnecting') - setMessage('Could not reach the local receiver. Retrying…') + setConnection('failed') + setConnectionError( + streamUrl === DEFAULT_STREAM_URL + ? 'Could not connect to the default receiver. Start it with the command below or enter another endpoint.' + : 'Could not connect to this receiver. Check the endpoint and try again.' + ) } return () => { @@ -532,32 +562,37 @@ export default function App() {
    - {connection === 'missing' ? ( - - - Connect a local receiver - - Start the receiver from your project, then this page will receive its stream. - - - - - sentry local serve --open - - - + {connectionError && connection === 'failed' && items.length > 0 ? ( +
    + {connectionError} +
    ) : null} - {message ? ( -
    +
    {message}
    ) : null} - {items.length === 0 ? ( + {items.length === 0 && (connection === 'connecting' || connection === 'failed' || isEditingReceiver) ? ( + copyText('sentry local serve --open')} + /> + ) : items.length === 0 ? (
    ) : (
    diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx new file mode 100644 index 0000000000..1ffc889f54 --- /dev/null +++ b/apps/local/src/components/connection-landing.tsx @@ -0,0 +1,94 @@ +import { Check, ChevronDown, Copy, Radio } from 'lucide-react' +import { type FormEvent, useState } from 'react' + +export type ConnectionLandingProps = { + endpoint: string + error?: string + phase: 'probing' | 'failed' | 'editing' + onConnect: () => void + onCopyCommand: () => void + onEndpointChange: (value: string) => void +} + +/** A compact, explicit receiver picker for bare Local viewer visits. */ +export function ConnectionLanding({ + endpoint, + error, + phase, + onConnect, + onCopyCommand, + onEndpointChange, +}: ConnectionLandingProps) { + const [copied, setCopied] = useState(false) + const isProbing = phase === 'probing' + + const submit = (event: FormEvent) => { + event.preventDefault() + onConnect() + } + + return ( +
    +
    +
    +
    + + {error ?

    {error}

    : null} + +
    + +
    + onEndpointChange(event.target.value)} + className="h-9 min-w-0 flex-1 border border-border bg-background px-3 font-mono text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
    +
    + +
    + + +

    + Loopback streams are remembered. Remote streams must use HTTPS, need CORS access for this viewer, and are kept only for this browser session. +

    +
    + +
    + sentry local serve --open + +
    +
    +
    + ) +} diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts index caeddc7514..57f0597a27 100644 --- a/apps/local/src/lib/presentation.test.ts +++ b/apps/local/src/lib/presentation.test.ts @@ -15,4 +15,11 @@ describe('getConnectionPresentation', () => { tone: 'neutral', }) }) + + test('makes a failed receiver visible in the status control', () => { + expect(getConnectionPresentation('failed')).toEqual({ + label: 'Receiver unavailable', + tone: 'warning', + }) + }) }) diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts index 956a8d89e4..66360c2dd2 100644 --- a/apps/local/src/lib/presentation.ts +++ b/apps/local/src/lib/presentation.ts @@ -1,4 +1,4 @@ -export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'missing' +export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'missing' | 'failed' export type ConnectionPresentation = { label: string @@ -17,5 +17,7 @@ export function getConnectionPresentation( return { label: 'Reconnecting to local receiver', tone: 'warning' } case 'missing': return { label: 'Waiting for a local stream', tone: 'neutral' } + case 'failed': + return { label: 'Receiver unavailable', tone: 'warning' } } } From 178d5c333ad4cbf6e2f306e68255f5712ef28f9a Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 11:20:01 +0530 Subject: [PATCH 17/20] docs(local): Add viewer connection plan --- ...6-09-09-local-viewer-connection-landing.md | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md diff --git a/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md b/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md new file mode 100644 index 0000000000..f27592ccc3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md @@ -0,0 +1,273 @@ +# Local Viewer Connection Landing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking. + +**Goal:** Connect a bare Local viewer to its default receiver and provide an explicit, safe form for custom local and HTTPS remote streams. + +**Architecture:** \`lib/spotlight.ts\` owns normalized endpoint parsing and persistence classification. A presentational connection landing owns form rendering only. \`App.tsx\` resolves source priority, owns storage and the replaceable EventSource, and switches between the landing and the read-only workspace. + +**Tech Stack:** React 19, TypeScript, Vite, Tailwind CSS, Vitest, Testing Library, native EventSource. + +**Spec:** \`docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md\` + +## Global Constraints + +- Preserve CLI \`#stream\` pairing and remove a supplied fragment from the address bar after reading it. +- The default endpoint is exactly \`http://localhost:8969/stream\`. +- Persist only successfully opened loopback endpoints in local storage and remote endpoints in session storage. +- Accept loopback HTTP/HTTPS or remote HTTPS URLs ending in \`/stream\`, reject credentials and fragments, and preserve explicit remote query parameters. +- Remote streams connect directly from the browser; do not change receiver CORS, add server proxies, mutation APIs, or custom EventSource headers. +- Preserve the bounded 500-item feed and all existing workspace behavior. + +--- + +## File Structure + +- \`apps/local/src/lib/spotlight.ts\`: stream parsing, classification, priority, and storage-key constants. +- \`apps/local/src/lib/spotlight.test.ts\`: pure parsing and priority assertions. +- \`apps/local/src/components/connection-landing.tsx\`: accessible form and default-probe/failure presentation. +- \`apps/local/src/App.tsx\`: endpoint state, storage, EventSource lifecycle, and visibility transitions. +- \`apps/local/src/App.integration.test.tsx\`: default probe, form, persistence, recovery, and workspace regression coverage. + +### Task 1: Add the stream endpoint model + +**Files:** +- Modify: \`apps/local/src/lib/spotlight.ts\` +- Modify: \`apps/local/src/lib/spotlight.test.ts\` + +**Interfaces:** +- Produces: \`DEFAULT_STREAM_URL\`, \`REMOTE_STREAM_STORAGE_KEY\`, \`StreamEndpointKind\`, \`StreamEndpoint\`, \`parseStreamEndpoint(value)\`, and \`resolveInitialStreamUrl(hash, savedLoopback, savedRemote)\`. +- Consumes: Existing loopback validation and hash parsing. + +- [x] **Step 1: Write failing pure tests** + +~~~ts +test('classifies the default stream as loopback', () => { + expect(DEFAULT_STREAM_URL).toBe('http://localhost:8969/stream') + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: 'loopback', + }) +}) + +test('accepts HTTPS remote streams with a query token', () => { + expect(parseStreamEndpoint('https://receiver.example/stream?token=abc')).toEqual({ + url: 'https://receiver.example/stream?token=abc', + kind: 'remote', + }) +}) + +test.each([ + 'http://receiver.example/stream', + 'https://user:pass@receiver.example/stream', + 'https://receiver.example/other', + 'https://receiver.example/stream#fragment', +])('rejects unsafe endpoint %s', (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined() +}) +~~~ + +- [x] **Step 2: Run the test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/lib/spotlight.test.ts\` + +Expected: FAIL because the endpoint model exports do not exist. + +- [x] **Step 3: Add normalized parsing and priority** + +~~~ts +export const DEFAULT_STREAM_URL = 'http://localhost:8969/stream' +export const REMOTE_STREAM_STORAGE_KEY = 'sentry.local.remote-stream-url' +export type StreamEndpointKind = 'loopback' | 'remote' +export type StreamEndpoint = { url: string; kind: StreamEndpointKind } + +export function parseStreamEndpoint(value: string | null): StreamEndpoint | undefined { + if (!value) return undefined + try { + const url = new URL(value) + if (url.pathname !== '/stream' || url.username || url.password || url.hash) return undefined + if (isLoopbackHost(url.hostname) && ['http:', 'https:'].includes(url.protocol)) { + return { url: url.toString(), kind: 'loopback' } + } + return url.protocol === 'https:' ? { url: url.toString(), kind: 'remote' } : undefined + } catch { + return undefined + } +} +~~~ + +\`resolveInitialStreamUrl\` must prefer a valid fragment, then saved loopback, then saved remote, then the default. Existing hash and local-storage helpers remain URL-string compatibility wrappers. + +- [x] **Step 4: Add priority assertions and run the pure suite** + +~~~ts +expect(resolveInitialStreamUrl('#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream', null, null)) + .toBe('http://localhost:9000/stream') +expect(resolveInitialStreamUrl('', 'http://127.0.0.1:8970/stream', null)) + .toBe('http://127.0.0.1:8970/stream') +expect(resolveInitialStreamUrl('', null, null)).toBe(DEFAULT_STREAM_URL) +~~~ + +Run: \`pnpm --filter local exec vitest run src/lib/spotlight.test.ts\` + +Expected: PASS, including envelope decoding and bounded-feed coverage. + +- [x] **Step 5: Commit** + +~~~bash +git add apps/local/src/lib/spotlight.ts apps/local/src/lib/spotlight.test.ts +git commit -m "feat(local): Classify custom stream endpoints" +~~~ + +### Task 2: Create the connection landing + +**Files:** +- Create: \`apps/local/src/components/connection-landing.tsx\` +- Modify: \`apps/local/src/App.integration.test.tsx\` + +**Interfaces:** +- Produces: \`ConnectionLanding(props)\`. +- Consumes: \`phase: 'probing' | 'failed' | 'editing'\`, \`endpoint\`, optional \`error\`, and callbacks to edit/submit the endpoint and copy the CLI command. + +- [x] **Step 1: Write failing form interaction tests** + +~~~tsx +renderViewerWithoutSavedStream() +await screen.findByText('Looking for Sentry Local') +await screen.findByRole('alert', { name: 'Could not connect to the default receiver' }) + +const input = screen.getByLabelText('Receiver endpoint') +expect(input.getAttribute('value')).toBe('http://localhost:8969/stream') +fireEvent.change(input, { target: { value: 'http://127.0.0.1:9000/stream' } }) +fireEvent.click(screen.getByRole('button', { name: 'Connect' })) +~~~ + +Also assert one primary heading, a command-copy button, an Advanced disclosure, and readable validation for plain-HTTP remote input. + +- [x] **Step 2: Run the integration test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: FAIL because the landing, receiver field, and Connect action do not exist. + +- [x] **Step 3: Add the presentational component** + +~~~tsx +export type ConnectionLandingProps = { + phase: 'probing' | 'failed' | 'editing' + endpoint: string + error?: string + onEndpointChange: (value: string) => void + onConnect: () => void + onCopyCommand: () => void +} + +export function ConnectionLanding(props: ConnectionLandingProps) { + return ( +
    +

    Connect a receiver

    + + props.onEndpointChange(event.target.value)} + /> + +
    + ) +} +~~~ + +Use one centered compact panel for probing, failure, and editing. Submit on Enter. The Advanced disclosure explains remote HTTPS and CORS requirements. + +- [x] **Step 4: Rerun the integration test and commit** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: PASS with selected-event, search, filtering, clear-feed, JSON, and waterfall regressions. + +~~~bash +git add apps/local/src/components/connection-landing.tsx apps/local/src/App.integration.test.tsx +git commit -m "feat(local): Add receiver connection landing" +~~~ + +### Task 3: Integrate storage and replaceable EventSource lifecycle + +**Files:** +- Modify: \`apps/local/src/App.tsx\` +- Modify: \`apps/local/src/App.integration.test.tsx\` + +**Interfaces:** +- Consumes: Task 1 endpoint model and Task 2 landing callbacks. +- Produces: EventSource replacement without reload and correct local/session persistence after \`open\`. + +- [x] **Step 1: Write failing lifecycle tests** + +~~~tsx +test('saves a successful custom loopback endpoint for a future bare visit', async () => { + const { port } = await startReceiver() + renderViewerWithoutSavedStream() + submitEndpoint(\`http://127.0.0.1:\${port}/stream\`) + await screen.findByText('Connected to local receiver') + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBe(\`http://127.0.0.1:\${port}/stream\`) +}) + +test('keeps an explicit remote endpoint in session storage only', () => { + renderViewerWithoutSavedStream() + submitEndpoint('https://receiver.example/stream?token=abc') + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + 'https://receiver.example/stream?token=abc' + ) +}) +~~~ + +Add a failed-default-to-custom-recovery test and a connected-empty-state Change receiver test. + +- [x] **Step 2: Run the integration test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: FAIL because \`streamUrl\` is immutable and remote persistence does not exist. + +- [x] **Step 3: Make the endpoint state replaceable in \`App.tsx\`** + +~~~tsx +const [streamUrl, setStreamUrl] = useState(() => resolveInitialStreamUrl( + window.location.hash, getSavedLoopbackStream(), getSavedRemoteStream() +)) +const [connectionPhase, setConnectionPhase] = useState<'probing' | 'failed' | 'connected'>('probing') +const [draftEndpoint, setDraftEndpoint] = useState(streamUrl) + +function connectToDraft() { + const endpoint = parseStreamEndpoint(draftEndpoint) + if (!endpoint) return setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') + setStreamUrl(endpoint.url) + setConnectionPhase('probing') +} +~~~ + +On EventSource \`open\`, persist by endpoint kind. On \`error\`, retain the input and show the form without clearing feed items. Cleanup closes the old source. Keep fragment removal in its initial effect. Offer Change receiver from the connected empty workspace. + +- [x] **Step 4: Run all app quality gates** + +Run: \`pnpm --filter local exec vitest run && pnpm --filter local run lint && pnpm --filter local run build\` + +Expected: PASS with no lint warnings and a successful Vite build. + +- [x] **Step 5: Run CLI regression tests and manually verify both themes** + +Run: \`pnpm --filter sentry exec vitest run test/commands/local/server.test.ts test/commands/local/run.test.ts test/commands/local/ui.test.ts\` + +Expected: PASS; CLI fragments, hosted read-only CORS, IPv6, and NDJSON behavior remain unchanged. + +Run: \`pnpm --filter local dev\` + +Verify default success, default failure form, custom loopback reload persistence, remote session-only selection, invalid remote feedback, command copy, and landing/workspace readability in light and dark mode. + +- [x] **Step 6: Commit** + +~~~bash +git add apps/local/src/App.tsx apps/local/src/App.integration.test.tsx +git commit -m "feat(local): Connect viewers without CLI pairing" +~~~ From 23a00d099141d5f29ee0c840a1e493644fdc5106 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 11:21:54 +0530 Subject: [PATCH 18/20] fix(local): Stop retries after invalid receiver URL --- apps/local/src/App.integration.test.tsx | 39 ++++++++++++++++++++++++- apps/local/src/App.tsx | 20 ++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 5687d4f2dc..95e55a9bc6 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -1,7 +1,7 @@ import { createSpotlightBuffer } from '@spotlightjs/spotlight/sdk' import { EventSource as NodeEventSource } from 'eventsource' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ThemeProvider } from '@/components/theme-provider.tsx' import App from './App.tsx' import { @@ -112,6 +112,19 @@ class OpeningEventSource extends EventTarget { close() {} } +class ControllableEventSource extends EventTarget { + static instances: ControllableEventSource[] = [] + onopen: ((event: Event) => void) | null = null + onerror: ((event: Event) => void) | null = null + + constructor(_url: string) { + super() + ControllableEventSource.instances.push(this) + } + + close() {} +} + describe('local receiver to viewer integration', () => { beforeEach(() => { window.localStorage.clear() @@ -238,6 +251,30 @@ describe('local receiver to viewer integration', () => { expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull() }) + test('keeps endpoint validation visible after a failed receiver has been stopped', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onerror?.(new Event('error'))) + await screen.findByRole('alert', { name: 'Receiver connection error' }) + + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'http://receiver.example/stream' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(screen.getByRole('alert').textContent).toContain( + 'Enter a loopback stream or an HTTPS remote stream' + ) + + await act(async () => source?.onerror?.(new Event('error'))) + expect(screen.getByRole('alert').textContent).toContain( + 'Enter a loopback stream or an HTTPS remote stream' + ) + }) + test('replays an envelope buffered before the viewer opens', async () => { const { server, port } = await startReceiver() diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index ff5f7506e8..2ee54d8d7b 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -395,6 +395,7 @@ export default function App() { const [connection, setConnection] = useState('connecting') const [draftEndpoint, setDraftEndpoint] = useState(streamUrl) const [isEditingReceiver, setIsEditingReceiver] = useState(false) + const [isConnectionEnabled, setIsConnectionEnabled] = useState(true) const [connectionError, setConnectionError] = useState() const [items, setItems] = useState([]) const [selectedItemId, setSelectedItemId] = useState() @@ -462,12 +463,14 @@ export default function App() { const connectToDraft = () => { const endpoint = parseStreamEndpoint(draftEndpoint) if (!endpoint) { + setIsConnectionEnabled(false) setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') setConnection('failed') return } setDraftEndpoint(endpoint.url) setStreamUrl(endpoint.url) + setIsConnectionEnabled(true) setConnection('connecting') setConnectionError(undefined) setIsEditingReceiver(false) @@ -480,8 +483,16 @@ export default function App() { }, []) useEffect(() => { + if (!isConnectionEnabled) { + return + } + + let isCurrent = true const source = new EventSource(streamUrl) const onEnvelope = (event: Event) => { + if (!isCurrent) { + return + } try { const messageEvent = event as MessageEvent const eventId = messageEvent.lastEventId || `event-${fallbackEventId.current++}` @@ -495,6 +506,9 @@ export default function App() { source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) source.onopen = () => { + if (!isCurrent) { + return + } const endpoint = parseStreamEndpoint(streamUrl) if (endpoint?.kind === 'loopback') { saveStreamUrl(endpoint.url) @@ -506,6 +520,9 @@ export default function App() { setMessage(undefined) } source.onerror = () => { + if (!isCurrent) { + return + } setConnection('failed') setConnectionError( streamUrl === DEFAULT_STREAM_URL @@ -515,10 +532,11 @@ export default function App() { } return () => { + isCurrent = false source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) source.close() } - }, [streamUrl]) + }, [isConnectionEnabled, streamUrl]) return (
    From a8a19bb01e22a4691f534344a85e26bb57b32255 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 9 Sep 2026 11:28:11 +0530 Subject: [PATCH 19/20] fix(local): Keep receiver editor open for new events --- apps/local/src/App.integration.test.tsx | 25 +++++++++++++++++++++++++ apps/local/src/App.tsx | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 95e55a9bc6..3bd09f1f01 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -275,6 +275,31 @@ describe('local receiver to viewer integration', () => { ) }) + test('keeps receiver selection open when an event arrives during editing', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onopen?.(new Event('open'))) + await screen.findByText('Connected to local receiver') + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: 'transaction' }, { transaction: 'GET /arrived-while-editing' }]], + ]), + }) + ) + }) + + expect(screen.getByLabelText('Receiver endpoint')).not.toBeNull() + }) + test('replays an envelope buffered before the viewer opens', async () => { const { server, port } = await startReceiver() diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 2ee54d8d7b..939339d6cc 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -591,7 +591,7 @@ export default function App() {
    ) : null} - {items.length === 0 && (connection === 'connecting' || connection === 'failed' || isEditingReceiver) ? ( + {isEditingReceiver || (items.length === 0 && (connection === 'connecting' || connection === 'failed')) ? ( Date: Wed, 9 Sep 2026 11:29:24 +0530 Subject: [PATCH 20/20] fix(local): Preserve healthy receiver on validation failure --- apps/local/src/App.integration.test.tsx | 24 +++++++++++++++++++++++- apps/local/src/App.tsx | 6 ++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 3bd09f1f01..34a2272e9f 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -116,13 +116,16 @@ class ControllableEventSource extends EventTarget { static instances: ControllableEventSource[] = [] onopen: ((event: Event) => void) | null = null onerror: ((event: Event) => void) | null = null + closed = false constructor(_url: string) { super() ControllableEventSource.instances.push(this) } - close() {} + close() { + this.closed = true + } } describe('local receiver to viewer integration', () => { @@ -300,6 +303,25 @@ describe('local receiver to viewer integration', () => { expect(screen.getByLabelText('Receiver endpoint')).not.toBeNull() }) + test('keeps a healthy receiver open after an invalid replacement endpoint', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onopen?.(new Event('open'))) + await screen.findByText('Connected to local receiver') + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'http://receiver.example/stream' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + expect(source?.closed).toBe(false) + expect(screen.getByRole('status', { name: 'Connected to local receiver' })).not.toBeNull() + }) + test('replays an envelope buffered before the viewer opens', async () => { const { server, port } = await startReceiver() diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 939339d6cc..70afb56699 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -463,9 +463,11 @@ export default function App() { const connectToDraft = () => { const endpoint = parseStreamEndpoint(draftEndpoint) if (!endpoint) { - setIsConnectionEnabled(false) setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') - setConnection('failed') + if (connection !== 'connected') { + setIsConnectionEnabled(false) + setConnection('failed') + } return } setDraftEndpoint(endpoint.url)