Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/auto-refresh-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export const LIST_AUTO_REFRESH_OPTIONS = [
'15m',
] as const satisfies readonly AutoRefreshInterval[]

export const INSPECT_AUTO_REFRESH_OPTIONS = [
'off',
'5s',
'30s',
'1m',
'5m',
] as const satisfies readonly AutoRefreshInterval[]

export const DEFAULT_AUTO_REFRESH_INTERVAL: AutoRefreshInterval = '30s'

interface AutoRefreshSelectProps {
Expand Down
13 changes: 13 additions & 0 deletions src/components/inspect/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IconMaximize, IconShare2, IconX } from '@tabler/icons-react'
import { Link } from '@tanstack/react-router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import type { AutoRefreshInterval } from '#/components/auto-refresh-select'
import { ContextWindow } from '#/components/context-window'
import { ConversationView } from '#/components/conversation-view'
import { CopyButton } from '#/components/copy-button'
Expand Down Expand Up @@ -40,6 +41,10 @@ interface InspectDrawerProps {
expandTrace?: { traceId: string }
/** Stable id for the inspected entity — resets picker state when it changes while `open`. */
inspectKey?: string | null
autoRefresh?: AutoRefreshInterval
onAutoRefreshChange?: (value: AutoRefreshInterval) => void
onRefresh?: () => void
refreshing?: boolean
}

export function InspectDrawer({
Expand All @@ -53,6 +58,10 @@ export function InspectDrawer({
expandSession,
expandTrace,
inspectKey,
autoRefresh,
onAutoRefreshChange,
onRefresh,
refreshing,
}: InspectDrawerProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [drawerView, setDrawerView] = useState<DrawerView>('spans')
Expand Down Expand Up @@ -223,6 +232,10 @@ export function InspectDrawer({
onViewChange={setDrawerView}
rawAllOn={raw.rawAllOn}
onToggleRawAll={raw.toggleAll}
autoRefresh={autoRefresh}
onAutoRefreshChange={onAutoRefreshChange}
onRefresh={onRefresh}
refreshing={refreshing}
hiddenTabs={hiddenTabs}
extras={
contentReady && drawerView === 'conversation' && spans.length > 0 ? <ContextWindow view={view} /> : null
Expand Down
2 changes: 1 addition & 1 deletion src/components/inspect/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ function SpanTreeRowImpl({
aria-pressed={rawOn}
aria-label={rawOn ? 'Hide raw spans for this trace' : 'Show raw spans for this trace'}
className={cn(
'mr-2 inline-flex size-6 shrink-0 self-center items-center justify-center rounded-md transition-opacity',
'mr-3 inline-flex size-6 shrink-0 self-center items-center justify-center rounded-md transition-opacity',
'focus:outline-hidden focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/80',
rawOn
? 'bg-muted text-foreground opacity-100'
Expand Down
24 changes: 1 addition & 23 deletions src/components/inspect/use-raw-roots.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ensureRootIn, toggleAllIn, toggleRootIn } from './use-raw-roots'
import { ensureRootIn, toggleRootIn } from './use-raw-roots'

describe('toggleRootIn', () => {
it('adds an absent id', () => {
Expand Down Expand Up @@ -32,25 +32,3 @@ describe('ensureRootIn', () => {
expect([...next].sort()).toEqual(['a', 'b'])
})
})

describe('toggleAllIn', () => {
it('clears when any roots are on (full set → empty)', () => {
const next = toggleAllIn(new Set(['a', 'b']), ['a', 'b', 'c'])
expect(next.size).toBe(0)
})

it('clears when partially on, treating any-on as "on"', () => {
const next = toggleAllIn(new Set(['a']), ['a', 'b', 'c'])
expect(next.size).toBe(0)
})

it('sets to every top-level id when empty (empty → full set)', () => {
const next = toggleAllIn(new Set(), ['a', 'b', 'c'])
expect([...next].sort()).toEqual(['a', 'b', 'c'])
})

it('clears to empty even when topLevelIds is empty', () => {
const next = toggleAllIn(new Set(['a']), [])
expect(next.size).toBe(0)
})
})
32 changes: 22 additions & 10 deletions src/components/inspect/use-raw-roots.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { InspectorView } from '#/lib/inspector-view'

export interface RawRootsControl {
Expand All @@ -25,15 +25,9 @@ export function ensureRootIn(prev: Set<string>, id: string): Set<string> {
return next
}

export function toggleAllIn(prev: Set<string>, topLevelIds: readonly string[]): Set<string> {
return prev.size > 0 ? new Set() : new Set(topLevelIds)
}

// Centralized state for per-trace raw-spans. A root is "on" when its id is in
// the set. The toolbar's bulk control flips between empty (all off) and the
// full set of top-level span ids (all on); per-row controls flip one at a time.
export function useRawRoots(view: InspectorView): RawRootsControl {
const [rawRoots, setRawRoots] = useState<Set<string>>(() => new Set())
const [rawAllOn, setRawAllOn] = useState(false)

const topLevelIds = useMemo(() => view.spans.filter((s) => !s.parentId).map((s) => s.id), [view.spans])

Expand All @@ -46,14 +40,32 @@ export function useRawRoots(view: InspectorView): RawRootsControl {
}, [])

const toggleAll = useCallback(() => {
setRawRoots((prev) => toggleAllIn(prev, topLevelIds))
setRawAllOn((prev) => {
const next = !prev
setRawRoots(next ? new Set(topLevelIds) : new Set())
return next
})
}, [topLevelIds])

useEffect(() => {
if (!rawAllOn) return
setRawRoots((prev) => {
let next: Set<string> | null = null
for (const id of topLevelIds) {
if (!prev.has(id)) {
if (!next) next = new Set(prev)
next.add(id)
}
}
return next ?? prev
})
}, [rawAllOn, topLevelIds])

return {
rawRoots,
toggleRoot,
ensureRoot,
rawAllOn: rawRoots.size > 0,
rawAllOn,
toggleAll,
}
}
18 changes: 15 additions & 3 deletions src/components/inspect/view-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ChatBubbleLeftRightIcon, QueueListIcon } from '@heroicons/react/24/outline'
import { IconBraces } from '@tabler/icons-react'
import type { ReactNode } from 'react'
import { type AutoRefreshInterval, AutoRefreshSelect } from '#/components/auto-refresh-select'
import {
type AutoRefreshInterval,
AutoRefreshSelect,
INSPECT_AUTO_REFRESH_OPTIONS,
} from '#/components/auto-refresh-select'
import { IconTabs } from '#/components/icon-tabs'
import { Separator } from '#/components/ui/separator'
import { Toggle } from '#/components/ui/toggle'
Expand Down Expand Up @@ -55,11 +59,18 @@ export function InspectViewBar({
{showRawAll && (
<Tooltip>
<TooltipTrigger asChild>
<Toggle size="sm" pressed={rawAllOn} onPressedChange={onToggleRawAll} aria-label="Show raw spans">
<Toggle
size="sm"
pressed={rawAllOn}
onPressedChange={onToggleRawAll}
aria-label="Toggle raw spans on every trace"
>
<IconBraces />
</Toggle>
</TooltipTrigger>
<TooltipContent>{rawAllOn ? 'Hide raw spans (all traces)' : 'Show raw spans (all traces)'}</TooltipContent>
<TooltipContent>
{rawAllOn ? 'Close raw spans on every trace' : 'Open raw spans on every trace'}
</TooltipContent>
</Tooltip>
)}
{showRawAll && (hasRefreshGroup || extras != null) && <Separator orientation="vertical" className="mx-1 h-5" />}
Expand All @@ -69,6 +80,7 @@ export function InspectViewBar({
onChange={onAutoRefreshChange}
onRefresh={onRefresh}
loading={refreshing}
options={INSPECT_AUTO_REFRESH_OPTIONS}
/>
)}
{extras}
Expand Down
67 changes: 34 additions & 33 deletions src/hooks/use-auto-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,47 @@
import { useCallback, useSyncExternalStore } from 'react'
import {
type AutoRefreshInterval,
DEFAULT_AUTO_REFRESH_INTERVAL,
LIST_AUTO_REFRESH_OPTIONS,
} from '#/components/auto-refresh-select'
import { AUTO_REFRESH_MS, type AutoRefreshInterval } from '#/components/auto-refresh-select'

const STORAGE_KEY = 'sessions-auto-refresh'
const VALID_KEYS = Object.keys(AUTO_REFRESH_MS) as readonly AutoRefreshInterval[]

function isInterval(v: unknown): v is AutoRefreshInterval {
return typeof v === 'string' && (LIST_AUTO_REFRESH_OPTIONS as readonly string[]).includes(v)
return typeof v === 'string' && (VALID_KEYS as readonly string[]).includes(v)
}

const listeners = new Set<() => void>()
function createAutoRefreshHook(storageKey: string, defaultInterval: AutoRefreshInterval) {
const listeners = new Set<() => void>()

function subscribe(cb: () => void) {
listeners.add(cb)
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) cb()
function subscribe(cb: () => void) {
listeners.add(cb)
const onStorage = (event: StorageEvent) => {
if (event.key === storageKey) cb()
}
window.addEventListener('storage', onStorage)
return () => {
listeners.delete(cb)
window.removeEventListener('storage', onStorage)
}
}
window.addEventListener('storage', onStorage)
return () => {
listeners.delete(cb)
window.removeEventListener('storage', onStorage)

function notify() {
for (const listener of listeners) listener()
}
}

function notify() {
for (const listener of listeners) listener()
}
function read(): AutoRefreshInterval {
if (typeof window === 'undefined') return defaultInterval
const stored = window.localStorage.getItem(storageKey)
return isInterval(stored) ? stored : defaultInterval
}

function read(): AutoRefreshInterval {
if (typeof window === 'undefined') return DEFAULT_AUTO_REFRESH_INTERVAL
const stored = window.localStorage.getItem(STORAGE_KEY)
return isInterval(stored) ? stored : DEFAULT_AUTO_REFRESH_INTERVAL
return function useAutoRefreshScoped(): [AutoRefreshInterval, (next: AutoRefreshInterval) => void] {
const value = useSyncExternalStore(subscribe, read, () => defaultInterval)
const setValue = useCallback((next: AutoRefreshInterval) => {
if (typeof window === 'undefined') return
window.localStorage.setItem(storageKey, next)
notify()
}, [])
return [value, setValue]
}
}

export function useAutoRefresh(): [AutoRefreshInterval, (next: AutoRefreshInterval) => void] {
const value = useSyncExternalStore(subscribe, read, () => DEFAULT_AUTO_REFRESH_INTERVAL)
const setValue = useCallback((next: AutoRefreshInterval) => {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, next)
notify()
}, [])
return [value, setValue]
}
export const useAutoRefresh = createAutoRefreshHook('sessions-auto-refresh', '30s')
export const useInspectAutoRefresh = createAutoRefreshHook('inspect-auto-refresh', '5s')
4 changes: 2 additions & 2 deletions src/routes/sessions/$sessionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
BreadcrumbSeparator,
} from '#/components/ui/breadcrumb'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '#/components/ui/empty'
import { useAutoRefresh } from '#/hooks/use-auto-refresh'
import { useInspectAutoRefresh } from '#/hooks/use-auto-refresh'
import { buildInspectorView } from '#/lib/inspector-view'
import { categorizeFromSpans } from '#/lib/telemetry/trace-category'
import { parse, type TimeRange } from '#/lib/time-range'
Expand Down Expand Up @@ -68,7 +68,7 @@ function SessionDetail() {
const { sessionId } = Route.useParams()
const search = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const [autoRefresh, setAutoRefresh] = useAutoRefresh()
const [autoRefresh, setAutoRefresh] = useInspectAutoRefresh()
const { data, refetch, isFetching } = useQuery({
...sessionQuery(sessionId, search.range),
refetchInterval: AUTO_REFRESH_MS[autoRefresh],
Expand Down
12 changes: 11 additions & 1 deletion src/routes/sessions/-components/sessions-drawer-host.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { AUTO_REFRESH_MS } from '#/components/auto-refresh-select'
import { InspectDrawer } from '#/components/inspect/drawer'
import { useInspectAutoRefresh } from '#/hooks/use-auto-refresh'
import type { Span } from '#/lib/spans'
import { sessionQuery } from '../-data'

Expand All @@ -23,10 +25,12 @@ interface SessionsDrawerHostProps {
export function SessionsDrawerHost({ previewSessionId, onClose }: SessionsDrawerHostProps) {
const open = previewSessionId !== null
const queryId = previewSessionId ?? SESSION_DRAWER_PLACEHOLDER
const [autoRefresh, setAutoRefresh] = useInspectAutoRefresh()

const { data, isLoading } = useQuery({
const { data, isLoading, isFetching, refetch } = useQuery({
...sessionQuery(queryId, DRAWER_LOOKUP_RANGE),
enabled: open,
refetchInterval: open ? AUTO_REFRESH_MS[autoRefresh] : false,
})
const [retainedPreview, setRetainedPreview] = useState<RetainedSessionPreview | null>(null)

Expand Down Expand Up @@ -60,6 +64,12 @@ export function SessionsDrawerHost({ previewSessionId, onClose }: SessionsDrawer
service={service}
hasError={hasError}
expandSession={displayPreview ? { sessionId: displayPreview.sessionId, range: DRAWER_LOOKUP_RANGE } : undefined}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
onRefresh={() => {
void refetch()
}}
refreshing={isFetching}
/>
)
}
15 changes: 14 additions & 1 deletion src/routes/traces/$traceId.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AUTO_REFRESH_MS } from '#/components/auto-refresh-select'
import { ContextWindow } from '#/components/context-window'
import { ConversationView } from '#/components/conversation-view'
import { InspectLayout } from '#/components/inspect/overview'
Expand All @@ -17,6 +18,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '#/components/ui/breadcrumb'
import { useInspectAutoRefresh } from '#/hooks/use-auto-refresh'
import { buildInspectorView } from '#/lib/inspector-view'
import type { Span } from '#/lib/spans'
import { traceSpansQuery } from './-data'
Expand All @@ -28,7 +30,12 @@ export const Route = createFileRoute('/traces/$traceId')({

function TraceDetail() {
const { traceId } = Route.useParams()
const { data: loaderData } = useQuery(traceSpansQuery(traceId))
const [autoRefresh, setAutoRefresh] = useInspectAutoRefresh()
const {
data: loaderData,
refetch,
isFetching,
} = useQuery({ ...traceSpansQuery(traceId), refetchInterval: AUTO_REFRESH_MS[autoRefresh] })

const spans: Span[] = loaderData?.spans ?? []
const provider = loaderData?.provider
Expand Down Expand Up @@ -107,6 +114,12 @@ function TraceDetail() {
onViewChange={setView}
rawAllOn={raw.rawAllOn}
onToggleRawAll={raw.toggleAll}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
onRefresh={() => {
void refetch()
}}
refreshing={isFetching}
extras={view === 'conversation' && spans.length > 0 ? <ContextWindow view={inspectorView} /> : undefined}
/>
<div className="min-h-0 flex-1 overflow-hidden bg-background">
Expand Down
Loading