diff --git a/src/components/auto-refresh-select.tsx b/src/components/auto-refresh-select.tsx index fa8e79b..b6d41ce 100644 --- a/src/components/auto-refresh-select.tsx +++ b/src/components/auto-refresh-select.tsx @@ -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 { diff --git a/src/components/inspect/drawer.tsx b/src/components/inspect/drawer.tsx index 290e14f..bd75181 100644 --- a/src/components/inspect/drawer.tsx +++ b/src/components/inspect/drawer.tsx @@ -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' @@ -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({ @@ -53,6 +58,10 @@ export function InspectDrawer({ expandSession, expandTrace, inspectKey, + autoRefresh, + onAutoRefreshChange, + onRefresh, + refreshing, }: InspectDrawerProps) { const [selectedId, setSelectedId] = useState(null) const [drawerView, setDrawerView] = useState('spans') @@ -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 ? : null diff --git a/src/components/inspect/tree.tsx b/src/components/inspect/tree.tsx index 9eb7d93..aaa3ee2 100644 --- a/src/components/inspect/tree.tsx +++ b/src/components/inspect/tree.tsx @@ -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' diff --git a/src/components/inspect/use-raw-roots.test.ts b/src/components/inspect/use-raw-roots.test.ts index b545887..1af6f53 100644 --- a/src/components/inspect/use-raw-roots.test.ts +++ b/src/components/inspect/use-raw-roots.test.ts @@ -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', () => { @@ -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) - }) -}) diff --git a/src/components/inspect/use-raw-roots.tsx b/src/components/inspect/use-raw-roots.tsx index 45fd3fb..50f633d 100644 --- a/src/components/inspect/use-raw-roots.tsx +++ b/src/components/inspect/use-raw-roots.tsx @@ -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 { @@ -25,15 +25,9 @@ export function ensureRootIn(prev: Set, id: string): Set { return next } -export function toggleAllIn(prev: Set, topLevelIds: readonly string[]): Set { - 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>(() => new Set()) + const [rawAllOn, setRawAllOn] = useState(false) const topLevelIds = useMemo(() => view.spans.filter((s) => !s.parentId).map((s) => s.id), [view.spans]) @@ -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 | 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, } } diff --git a/src/components/inspect/view-bar.tsx b/src/components/inspect/view-bar.tsx index e334d1e..2019b70 100644 --- a/src/components/inspect/view-bar.tsx +++ b/src/components/inspect/view-bar.tsx @@ -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' @@ -55,11 +59,18 @@ export function InspectViewBar({ {showRawAll && ( - + - {rawAllOn ? 'Hide raw spans (all traces)' : 'Show raw spans (all traces)'} + + {rawAllOn ? 'Close raw spans on every trace' : 'Open raw spans on every trace'} + )} {showRawAll && (hasRefreshGroup || extras != null) && } @@ -69,6 +80,7 @@ export function InspectViewBar({ onChange={onAutoRefreshChange} onRefresh={onRefresh} loading={refreshing} + options={INSPECT_AUTO_REFRESH_OPTIONS} /> )} {extras} diff --git a/src/hooks/use-auto-refresh.ts b/src/hooks/use-auto-refresh.ts index 9f9cdb4..f3910bc 100644 --- a/src/hooks/use-auto-refresh.ts +++ b/src/hooks/use-auto-refresh.ts @@ -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') diff --git a/src/routes/sessions/$sessionId.tsx b/src/routes/sessions/$sessionId.tsx index 75c8aed..7669f46 100644 --- a/src/routes/sessions/$sessionId.tsx +++ b/src/routes/sessions/$sessionId.tsx @@ -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' @@ -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], diff --git a/src/routes/sessions/-components/sessions-drawer-host.tsx b/src/routes/sessions/-components/sessions-drawer-host.tsx index 39f4f91..ed21b82 100644 --- a/src/routes/sessions/-components/sessions-drawer-host.tsx +++ b/src/routes/sessions/-components/sessions-drawer-host.tsx @@ -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' @@ -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(null) @@ -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} /> ) } diff --git a/src/routes/traces/$traceId.tsx b/src/routes/traces/$traceId.tsx index d5ca4b2..c6de913 100644 --- a/src/routes/traces/$traceId.tsx +++ b/src/routes/traces/$traceId.tsx @@ -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' @@ -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' @@ -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 @@ -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 ? : undefined} />
diff --git a/src/routes/traces/-components/trace-drawer-host.tsx b/src/routes/traces/-components/trace-drawer-host.tsx index 1520724..3fb8138 100644 --- a/src/routes/traces/-components/trace-drawer-host.tsx +++ b/src/routes/traces/-components/trace-drawer-host.tsx @@ -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 { traceSpansQuery } from '../-data' @@ -21,10 +23,12 @@ interface TraceInspectDrawerHostProps { export function TraceInspectDrawerHost({ previewTraceId, onClose }: TraceInspectDrawerHostProps) { const open = previewTraceId !== null const queryId = previewTraceId ?? TRACE_DRAWER_PLACEHOLDER + const [autoRefresh, setAutoRefresh] = useInspectAutoRefresh() - const { data, isLoading } = useQuery({ + const { data, isLoading, isFetching, refetch } = useQuery({ ...traceSpansQuery(queryId), enabled: open, + refetchInterval: open ? AUTO_REFRESH_MS[autoRefresh] : false, }) const [retainedPreview, setRetainedPreview] = useState(null) @@ -58,6 +62,12 @@ export function TraceInspectDrawerHost({ previewTraceId, onClose }: TraceInspect service={service} hasError={hasError} expandTrace={displayPreview ? { traceId: displayPreview.traceId } : undefined} + autoRefresh={autoRefresh} + onAutoRefreshChange={setAutoRefresh} + onRefresh={() => { + void refetch() + }} + refreshing={isFetching} /> ) }