mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-10 06:42:52 +02:00
Consolidate event viewer interfaces (#355)
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
activeGrpcConnectionAtom,
|
activeGrpcConnectionAtom,
|
||||||
activeGrpcConnections,
|
activeGrpcConnections,
|
||||||
@@ -12,17 +10,15 @@ import {
|
|||||||
} from '../hooks/usePinnedGrpcConnection';
|
} from '../hooks/usePinnedGrpcConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { copyToClipboard } from '../lib/copy';
|
import { copyToClipboard } from '../lib/copy';
|
||||||
import { AutoScroller } from './core/AutoScroller';
|
|
||||||
import { Banner } from './core/Banner';
|
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { EventViewer } from './core/EventViewer';
|
||||||
|
import { EventViewerRow } from './core/EventViewerRow';
|
||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
import { Separator } from './core/Separator';
|
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
@@ -42,7 +38,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||||
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const connections = useAtomValue(activeGrpcConnections);
|
const connections = useAtomValue(activeGrpcConnections);
|
||||||
@@ -51,8 +47,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
const activeEvent = useMemo(
|
||||||
() => events.find((m) => m.id === activeEventId) ?? null,
|
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
||||||
[activeEventId, events],
|
[activeEventIndex, events],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the active message to the first message received if unary
|
// Set the active message to the first message received if unary
|
||||||
@@ -61,223 +57,198 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
|
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
|
||||||
|
if (firstServerMessageIndex !== -1) {
|
||||||
|
setActiveEventIndex(firstServerMessageIndex);
|
||||||
|
}
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
|
if (activeConnection == null) {
|
||||||
|
return (
|
||||||
|
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
||||||
|
<HStack space={2}>
|
||||||
|
<span className="whitespace-nowrap">{events.length} Messages</span>
|
||||||
|
{activeConnection.state !== 'closed' && (
|
||||||
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<RecentGrpcConnectionsDropdown
|
||||||
|
connections={connections}
|
||||||
|
activeConnection={activeConnection}
|
||||||
|
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<div style={style} className="h-full">
|
||||||
layout="vertical"
|
<ErrorBoundary name="GRPC Events">
|
||||||
style={style}
|
<EventViewer
|
||||||
name="grpc_events"
|
events={events}
|
||||||
defaultRatio={0.4}
|
getEventKey={(event) => event.id}
|
||||||
minHeightPx={20}
|
error={activeConnection.error}
|
||||||
firstSlot={() =>
|
header={header}
|
||||||
activeConnection == null ? (
|
splitLayoutName="grpc_events"
|
||||||
<HotkeyList
|
defaultRatio={0.4}
|
||||||
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
renderRow={({ event, isActive, onClick }) => (
|
||||||
/>
|
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||||
) : (
|
)}
|
||||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
renderDetail={({ event }) => (
|
||||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
<GrpcEventDetail
|
||||||
<HStack space={2}>
|
event={event}
|
||||||
<span className="whitespace-nowrap">{events.length} Messages</span>
|
showLarge={showLarge}
|
||||||
{activeConnection.state !== 'closed' && (
|
showingLarge={showingLarge}
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
setShowLarge={setShowLarge}
|
||||||
)}
|
setShowingLarge={setShowingLarge}
|
||||||
</HStack>
|
/>
|
||||||
<div className="ml-auto">
|
)}
|
||||||
<RecentGrpcConnectionsDropdown
|
/>
|
||||||
connections={connections}
|
</ErrorBoundary>
|
||||||
activeConnection={activeConnection}
|
</div>
|
||||||
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
);
|
||||||
/>
|
}
|
||||||
</div>
|
|
||||||
</HStack>
|
function GrpcEventRow({
|
||||||
<ErrorBoundary name="GRPC Events">
|
event,
|
||||||
<AutoScroller
|
isActive,
|
||||||
data={events}
|
onClick,
|
||||||
header={
|
}: {
|
||||||
activeConnection.error && (
|
event: GrpcEvent;
|
||||||
<Banner color="danger" className="m-3">
|
isActive: boolean;
|
||||||
{activeConnection.error}
|
onClick: () => void;
|
||||||
</Banner>
|
}) {
|
||||||
)
|
const { eventType, status, content, error } = event;
|
||||||
}
|
const display = getEventDisplay(eventType, status);
|
||||||
render={(event) => (
|
|
||||||
<EventRow
|
return (
|
||||||
key={event.id}
|
<EventViewerRow
|
||||||
event={event}
|
isActive={isActive}
|
||||||
isActive={event.id === activeEventId}
|
onClick={onClick}
|
||||||
onClick={() => {
|
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
||||||
if (event.id === activeEventId) setActiveEventId(null);
|
content={
|
||||||
else setActiveEventId(event.id);
|
<span className="text-xs">
|
||||||
}}
|
{content.slice(0, 1000)}
|
||||||
/>
|
{error && <span className="text-warning"> ({error})</span>}
|
||||||
)}
|
</span>
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
activeEvent != null && activeConnection != null
|
|
||||||
? () => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
|
|
||||||
{activeEvent.eventType === 'client_message' ||
|
|
||||||
activeEvent.eventType === 'server_message' ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
|
||||||
<div className="font-semibold">
|
|
||||||
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
title="Copy message"
|
|
||||||
icon="copy"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => copyToClipboard(activeEvent.content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<Editor
|
|
||||||
language="json"
|
|
||||||
defaultValue={activeEvent.content ?? ''}
|
|
||||||
wrapLines={false}
|
|
||||||
readOnly={true}
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div>
|
|
||||||
<div className="select-text cursor-text font-semibold">
|
|
||||||
{activeEvent.content}
|
|
||||||
</div>
|
|
||||||
{activeEvent.error && (
|
|
||||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
|
||||||
{activeEvent.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="py-2 h-full">
|
|
||||||
{Object.keys(activeEvent.metadata).length === 0 ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
No{' '}
|
|
||||||
{activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{Object.entries(activeEvent.metadata).map(([key, value]) => (
|
|
||||||
<KeyValueRow key={key} label={key}>
|
|
||||||
{value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
timestamp={event.createdAt}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
function GrpcEventDetail({
|
||||||
onClick,
|
|
||||||
isActive,
|
|
||||||
event,
|
event,
|
||||||
|
showLarge,
|
||||||
|
showingLarge,
|
||||||
|
setShowLarge,
|
||||||
|
setShowingLarge,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
|
||||||
isActive?: boolean;
|
|
||||||
event: GrpcEvent;
|
event: GrpcEvent;
|
||||||
|
showLarge: boolean;
|
||||||
|
showingLarge: boolean;
|
||||||
|
setShowLarge: (v: boolean) => void;
|
||||||
|
setShowingLarge: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { eventType, status, createdAt, content, error } = event;
|
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
return (
|
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||||
<div className="px-1" ref={ref}>
|
<div className="font-semibold">
|
||||||
<button
|
Message {event.eventType === 'client_message' ? 'Sent' : 'Received'}
|
||||||
type="button"
|
</div>
|
||||||
onClick={onClick}
|
<IconButton
|
||||||
className={classNames(
|
title="Copy message"
|
||||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
icon="copy"
|
||||||
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
size="xs"
|
||||||
isActive && '!bg-surface-active !text-text',
|
onClick={() => copyToClipboard(event.content)}
|
||||||
'text-text-subtle hover:text',
|
/>
|
||||||
|
</div>
|
||||||
|
{!showLarge && event.content.length > 1000 * 1000 ? (
|
||||||
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
|
Message previews larger than 1MB are hidden
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowingLarge(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowLarge(true);
|
||||||
|
setShowingLarge(false);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
isLoading={showingLarge}
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
language="json"
|
||||||
|
defaultValue={event.content ?? ''}
|
||||||
|
wrapLines={false}
|
||||||
|
readOnly={true}
|
||||||
|
stateKey={null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<Icon
|
);
|
||||||
color={
|
}
|
||||||
eventType === 'server_message'
|
|
||||||
? 'info'
|
// Error or connection_end - show metadata/trailers
|
||||||
: eventType === 'client_message'
|
return (
|
||||||
? 'primary'
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
: eventType === 'error' || (status != null && status > 0)
|
<div>
|
||||||
? 'danger'
|
<div className="select-text cursor-text font-semibold">{event.content}</div>
|
||||||
: eventType === 'connection_end'
|
{event.error && (
|
||||||
? 'success'
|
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
||||||
: undefined
|
{event.error}
|
||||||
}
|
</div>
|
||||||
title={
|
)}
|
||||||
eventType === 'server_message'
|
</div>
|
||||||
? 'Server message'
|
<div className="py-2 h-full">
|
||||||
: eventType === 'client_message'
|
{Object.keys(event.metadata).length === 0 ? (
|
||||||
? 'Client message'
|
<EmptyStateText>
|
||||||
: eventType === 'error' || (status != null && status > 0)
|
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
|
||||||
? 'Error'
|
</EmptyStateText>
|
||||||
: eventType === 'connection_end'
|
) : (
|
||||||
? 'Connection response'
|
<KeyValueRows>
|
||||||
: undefined
|
{Object.entries(event.metadata).map(([key, value]) => (
|
||||||
}
|
<KeyValueRow key={key} label={key}>
|
||||||
icon={
|
{value}
|
||||||
eventType === 'server_message'
|
</KeyValueRow>
|
||||||
? 'arrow_big_down_dash'
|
))}
|
||||||
: eventType === 'client_message'
|
</KeyValueRows>
|
||||||
? 'arrow_big_up_dash'
|
)}
|
||||||
: eventType === 'error' || (status != null && status > 0)
|
</div>
|
||||||
? 'alert_triangle'
|
|
||||||
: eventType === 'connection_end'
|
|
||||||
? 'check'
|
|
||||||
: 'info'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className={classNames('w-full truncate text-xs')}>
|
|
||||||
{content.slice(0, 1000)}
|
|
||||||
{error && <span className="text-warning"> ({error})</span>}
|
|
||||||
</div>
|
|
||||||
<div className={classNames('opacity-50 text-xs')}>
|
|
||||||
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEventDisplay(
|
||||||
|
eventType: GrpcEvent['eventType'],
|
||||||
|
status: GrpcEvent['status'],
|
||||||
|
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
|
||||||
|
if (eventType === 'server_message') {
|
||||||
|
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
|
||||||
|
}
|
||||||
|
if (eventType === 'client_message') {
|
||||||
|
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
|
||||||
|
}
|
||||||
|
if (eventType === 'error' || (status != null && status > 0)) {
|
||||||
|
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
|
||||||
|
}
|
||||||
|
if (eventType === 'connection_end') {
|
||||||
|
return { icon: 'check', color: 'success', title: 'Connection response' };
|
||||||
|
}
|
||||||
|
return { icon: 'info', color: undefined, title: 'Event' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import type {
|
|||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
HttpResponseEventData,
|
HttpResponseEventData,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { AutoScroller } from './core/AutoScroller';
|
import { Button } from './core/Button';
|
||||||
import { Banner } from './core/Banner';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { EventViewer } from './core/EventViewer';
|
||||||
|
import { EventViewerRow } from './core/EventViewerRow';
|
||||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
import { Separator } from './core/Separator';
|
import { HStack } from './core/Stacks';
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -25,113 +25,73 @@ export function HttpResponseTimeline({ response }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Inner({ response }: Props) {
|
function Inner({ response }: Props) {
|
||||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
|
||||||
() => (activeEventIndex == null ? null : events?.[activeEventIndex]),
|
|
||||||
[activeEventIndex, events],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Banner color="danger" className="m-3">
|
|
||||||
{String(error)}
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<EventViewer
|
||||||
layout="vertical"
|
events={events ?? []}
|
||||||
name="http_response_events"
|
getEventKey={(event) => event.id}
|
||||||
|
error={error ? String(error) : null}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMessage="Loading events..."
|
||||||
|
emptyMessage="No events recorded"
|
||||||
|
splitLayoutName="http_response_events"
|
||||||
defaultRatio={0.25}
|
defaultRatio={0.25}
|
||||||
minHeightPx={10}
|
renderRow={({ event, isActive, onClick }) => {
|
||||||
firstSlot={() => (
|
const display = getEventDisplay(event.event);
|
||||||
<AutoScroller
|
return (
|
||||||
data={events}
|
<EventViewerRow
|
||||||
render={(event, i) => (
|
isActive={isActive}
|
||||||
<EventRow
|
onClick={onClick}
|
||||||
key={event.id}
|
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
||||||
event={event}
|
content={display.summary}
|
||||||
isActive={i === activeEventIndex}
|
timestamp={event.createdAt}
|
||||||
onClick={() => {
|
/>
|
||||||
if (i === activeEventIndex) setActiveEventIndex(null);
|
);
|
||||||
else setActiveEventIndex(i);
|
}}
|
||||||
}}
|
renderDetail={({ event }) => (
|
||||||
/>
|
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
secondSlot={
|
|
||||||
activeEvent
|
|
||||||
? () => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="mx-2 overflow-y-auto">
|
|
||||||
<EventDetails event={activeEvent} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
|
||||||
onClick,
|
|
||||||
isActive,
|
|
||||||
event,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
isActive: boolean;
|
|
||||||
event: HttpResponseEvent;
|
|
||||||
}) {
|
|
||||||
const display = getEventDisplay(event.event);
|
|
||||||
const { icon, color, summary } = display;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={classNames(
|
|
||||||
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
|
||||||
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
|
||||||
isActive && '!bg-surface-active !text-text',
|
|
||||||
'text-text-subtle hover:text',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon color={color} icon={icon} size="sm" />
|
|
||||||
<div className="w-full truncate">{summary}</div>
|
|
||||||
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventDetails({ event }: { event: HttpResponseEvent }) {
|
function EventDetails({
|
||||||
|
event,
|
||||||
|
showRaw,
|
||||||
|
setShowRaw,
|
||||||
|
}: {
|
||||||
|
event: HttpResponseEvent;
|
||||||
|
showRaw: boolean;
|
||||||
|
setShowRaw: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const { label } = getEventDisplay(event.event);
|
const { label } = getEventDisplay(event.event);
|
||||||
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
||||||
const e = event.event;
|
const e = event.event;
|
||||||
|
|
||||||
|
// Raw view - show plaintext representation
|
||||||
|
if (showRaw) {
|
||||||
|
const rawText = formatEventRaw(event.event);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
<DetailHeader
|
||||||
|
title={label}
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
|
<Editor language="text" defaultValue={rawText} readOnly stateKey={null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Headers - show name and value with Editor for JSON
|
// Headers - show name and value with Editor for JSON
|
||||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||||
return (
|
return (
|
||||||
@@ -139,6 +99,8 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
<DetailHeader
|
<DetailHeader
|
||||||
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
/>
|
/>
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||||
@@ -152,7 +114,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
if (e.type === 'send_url') {
|
if (e.type === 'send_url') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<DetailHeader title="Request" timestamp={timestamp} />
|
<DetailHeader
|
||||||
|
title="Request"
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Method">
|
<KeyValueRow label="Method">
|
||||||
<HttpMethodTagRaw forceColor method={e.method} />
|
<HttpMethodTagRaw forceColor method={e.method} />
|
||||||
@@ -167,7 +134,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
if (e.type === 'receive_url') {
|
if (e.type === 'receive_url') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<DetailHeader title="Response" timestamp={timestamp} />
|
<DetailHeader
|
||||||
|
title="Response"
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
@@ -182,7 +154,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
if (e.type === 'redirect') {
|
if (e.type === 'redirect') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<DetailHeader title="Redirect" timestamp={timestamp} />
|
<DetailHeader
|
||||||
|
title="Redirect"
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
<HttpStatusTagRaw status={e.status} />
|
<HttpStatusTagRaw status={e.status} />
|
||||||
@@ -200,7 +177,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
if (e.type === 'setting') {
|
if (e.type === 'setting') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
<DetailHeader
|
||||||
|
title="Apply Setting"
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
@@ -214,7 +196,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
|
<DetailHeader
|
||||||
|
title={`Data ${direction}`}
|
||||||
|
timestamp={timestamp}
|
||||||
|
showRaw={showRaw}
|
||||||
|
setShowRaw={setShowRaw}
|
||||||
|
/>
|
||||||
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -224,21 +211,62 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
|||||||
const { summary } = getEventDisplay(event.event);
|
const { summary } = getEventDisplay(event.event);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DetailHeader title={label} timestamp={timestamp} />
|
<DetailHeader title={label} timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||||
<div className="font-mono text-editor">{summary}</div>
|
<div className="font-mono text-editor">{summary}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
|
function DetailHeader({
|
||||||
|
title,
|
||||||
|
timestamp,
|
||||||
|
showRaw,
|
||||||
|
setShowRaw,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
timestamp: string;
|
||||||
|
showRaw: boolean;
|
||||||
|
setShowRaw: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
|
<HStack space={2} className="items-center">
|
||||||
|
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
|
||||||
|
<Button variant="border" size="xs" onClick={() => setShowRaw(!showRaw)}>
|
||||||
|
{showRaw ? 'Formatted' : 'Raw'}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
|
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format event as raw plaintext for debugging */
|
||||||
|
function formatEventRaw(event: HttpResponseEventData): string {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'send_url':
|
||||||
|
return `> ${event.method} ${event.path}`;
|
||||||
|
case 'receive_url':
|
||||||
|
return `< ${event.version} ${event.status}`;
|
||||||
|
case 'header_up':
|
||||||
|
return `> ${event.name}: ${event.value}`;
|
||||||
|
case 'header_down':
|
||||||
|
return `< ${event.name}: ${event.value}`;
|
||||||
|
case 'redirect':
|
||||||
|
return `< ${event.status} Redirect: ${event.url}`;
|
||||||
|
case 'setting':
|
||||||
|
return `[setting] ${event.name} = ${event.value}`;
|
||||||
|
case 'info':
|
||||||
|
return `[info] ${event.message}`;
|
||||||
|
case 'chunk_sent':
|
||||||
|
return `> [${formatBytes(event.bytes)} sent]`;
|
||||||
|
case 'chunk_received':
|
||||||
|
return `< [${formatBytes(event.bytes)} received]`;
|
||||||
|
default:
|
||||||
|
return '[unknown event]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type EventDisplay = {
|
type EventDisplay = {
|
||||||
icon: IconProps['icon'];
|
icon: IconProps['icon'];
|
||||||
color: IconProps['color'];
|
color: IconProps['color'];
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { hexy } from 'hexy';
|
import { hexy } from 'hexy';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useFormatText } from '../hooks/useFormatText';
|
import { useFormatText } from '../hooks/useFormatText';
|
||||||
import {
|
import {
|
||||||
activeWebsocketConnectionAtom,
|
activeWebsocketConnectionAtom,
|
||||||
@@ -14,16 +12,14 @@ import {
|
|||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { languageFromContentType } from '../lib/contentType';
|
import { languageFromContentType } from '../lib/contentType';
|
||||||
import { copyToClipboard } from '../lib/copy';
|
import { copyToClipboard } from '../lib/copy';
|
||||||
import { AutoScroller } from './core/AutoScroller';
|
|
||||||
import { Banner } from './core/Banner';
|
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { EventViewer } from './core/EventViewer';
|
||||||
|
import { EventViewerRow } from './core/EventViewerRow';
|
||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
import { Separator } from './core/Separator';
|
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
@@ -35,227 +31,198 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsocketResponsePane({ activeRequest }: Props) {
|
export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||||
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
|
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
if (activeConnection == null) {
|
||||||
() => events.find((m) => m.id === activeEventId) ?? null,
|
return (
|
||||||
[activeEventId, events],
|
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
|
||||||
|
<HStack space={2}>
|
||||||
|
{activeConnection.state !== 'closed' && (
|
||||||
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
|
)}
|
||||||
|
<WebsocketStatusTag connection={activeConnection} />
|
||||||
|
<span>•</span>
|
||||||
|
<span>{events.length} Messages</span>
|
||||||
|
</HStack>
|
||||||
|
<HStack space={0.5} className="ml-auto">
|
||||||
|
<RecentWebsocketConnectionsDropdown
|
||||||
|
connections={connections}
|
||||||
|
activeConnection={activeConnection}
|
||||||
|
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
|
|
||||||
|
|
||||||
const message = useMemo(() => {
|
|
||||||
if (hexDump) {
|
|
||||||
return activeEvent?.message ? hexy(activeEvent?.message) : '';
|
|
||||||
}
|
|
||||||
return activeEvent?.message
|
|
||||||
? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
|
|
||||||
: '';
|
|
||||||
}, [activeEvent?.message, hexDump]);
|
|
||||||
|
|
||||||
const language = languageFromContentType(null, message);
|
|
||||||
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<ErrorBoundary name="Websocket Events">
|
||||||
layout="vertical"
|
<EventViewer
|
||||||
name="grpc_events"
|
events={events}
|
||||||
defaultRatio={0.4}
|
getEventKey={(event) => event.id}
|
||||||
minHeightPx={20}
|
error={activeConnection.error}
|
||||||
firstSlot={() =>
|
header={header}
|
||||||
activeConnection == null ? (
|
splitLayoutName="websocket_events"
|
||||||
<HotkeyList
|
defaultRatio={0.4}
|
||||||
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
renderRow={({ event, isActive, onClick }) => (
|
||||||
|
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||||
|
)}
|
||||||
|
renderDetail={({ event, index }) => (
|
||||||
|
<WebsocketEventDetail
|
||||||
|
event={event}
|
||||||
|
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
|
||||||
|
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
|
||||||
|
showLarge={showLarge}
|
||||||
|
showingLarge={showingLarge}
|
||||||
|
setShowLarge={setShowLarge}
|
||||||
|
setShowingLarge={setShowingLarge}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
|
/>
|
||||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
|
</ErrorBoundary>
|
||||||
<HStack space={2}>
|
|
||||||
{activeConnection.state !== 'closed' && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
<WebsocketStatusTag connection={activeConnection} />
|
|
||||||
<span>•</span>
|
|
||||||
<span>{events.length} Messages</span>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={0.5} className="ml-auto">
|
|
||||||
<RecentWebsocketConnectionsDropdown
|
|
||||||
connections={connections}
|
|
||||||
activeConnection={activeConnection}
|
|
||||||
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<ErrorBoundary name="Websocket Events">
|
|
||||||
<AutoScroller
|
|
||||||
data={events}
|
|
||||||
header={
|
|
||||||
activeConnection.error && (
|
|
||||||
<Banner color="danger" className="m-3">
|
|
||||||
{activeConnection.error}
|
|
||||||
</Banner>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
render={(event) => (
|
|
||||||
<EventRow
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
isActive={event.id === activeEventId}
|
|
||||||
onClick={() => {
|
|
||||||
if (event.id === activeEventId) setActiveEventId(null);
|
|
||||||
else setActiveEventId(event.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
activeEvent != null && activeConnection != null
|
|
||||||
? () => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
|
||||||
<div className="font-semibold">
|
|
||||||
{activeEvent.messageType === 'close'
|
|
||||||
? 'Connection Closed'
|
|
||||||
: activeEvent.messageType === 'open'
|
|
||||||
? 'Connection open'
|
|
||||||
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
|
|
||||||
</div>
|
|
||||||
{message !== '' && (
|
|
||||||
<HStack space={1}>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
if (activeEventId == null) return;
|
|
||||||
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hexDump ? 'Show Message' : 'Show Hexdump'}
|
|
||||||
</Button>
|
|
||||||
<IconButton
|
|
||||||
title="Copy message"
|
|
||||||
icon="copy"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => copyToClipboard(formattedMessage ?? '')}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : activeEvent.message.length === 0 ? (
|
|
||||||
<EmptyStateText>No Content</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<Editor
|
|
||||||
language={language}
|
|
||||||
defaultValue={formattedMessage ?? ''}
|
|
||||||
wrapLines={false}
|
|
||||||
readOnly={true}
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
function WebsocketEventRow({
|
||||||
onClick,
|
|
||||||
isActive,
|
|
||||||
event,
|
event,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
|
||||||
isActive?: boolean;
|
|
||||||
event: WebsocketEvent;
|
event: WebsocketEvent;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { createdAt, message: messageBytes, isServer, messageType } = event;
|
const { message: messageBytes, isServer, messageType } = event;
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const message = messageBytes
|
const message = messageBytes
|
||||||
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const iconColor =
|
||||||
|
messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary';
|
||||||
|
|
||||||
|
const icon =
|
||||||
|
messageType === 'close' || messageType === 'open'
|
||||||
|
? 'info'
|
||||||
|
: isServer
|
||||||
|
? 'arrow_big_down_dash'
|
||||||
|
: 'arrow_big_up_dash';
|
||||||
|
|
||||||
|
const content =
|
||||||
|
messageType === 'close' ? (
|
||||||
|
'Disconnected from server'
|
||||||
|
) : messageType === 'open' ? (
|
||||||
|
'Connected to server'
|
||||||
|
) : message === '' ? (
|
||||||
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{message.slice(0, 1000)}</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-1" ref={ref}>
|
<EventViewerRow
|
||||||
<button
|
isActive={isActive}
|
||||||
type="button"
|
onClick={onClick}
|
||||||
onClick={onClick}
|
icon={<Icon color={iconColor} icon={icon} />}
|
||||||
className={classNames(
|
content={content}
|
||||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
timestamp={event.createdAt}
|
||||||
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
/>
|
||||||
isActive && '!bg-surface-active !text-text',
|
);
|
||||||
'text-text-subtle hover:text',
|
}
|
||||||
|
|
||||||
|
function WebsocketEventDetail({
|
||||||
|
event,
|
||||||
|
hexDump,
|
||||||
|
setHexDump,
|
||||||
|
showLarge,
|
||||||
|
showingLarge,
|
||||||
|
setShowLarge,
|
||||||
|
setShowingLarge,
|
||||||
|
}: {
|
||||||
|
event: WebsocketEvent;
|
||||||
|
hexDump: boolean;
|
||||||
|
setHexDump: (v: boolean) => void;
|
||||||
|
showLarge: boolean;
|
||||||
|
showingLarge: boolean;
|
||||||
|
setShowLarge: (v: boolean) => void;
|
||||||
|
setShowingLarge: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const message = useMemo(() => {
|
||||||
|
if (hexDump) {
|
||||||
|
return event.message ? hexy(event.message) : '';
|
||||||
|
}
|
||||||
|
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : '';
|
||||||
|
}, [event.message, hexDump]);
|
||||||
|
|
||||||
|
const language = languageFromContentType(null, message);
|
||||||
|
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||||
|
|
||||||
|
const title =
|
||||||
|
event.messageType === 'close'
|
||||||
|
? 'Connection Closed'
|
||||||
|
: event.messageType === 'open'
|
||||||
|
? 'Connection Open'
|
||||||
|
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||||
|
<div className="font-semibold">{title}</div>
|
||||||
|
{message !== '' && (
|
||||||
|
<HStack space={1}>
|
||||||
|
<Button variant="border" size="xs" onClick={() => setHexDump(!hexDump)}>
|
||||||
|
{hexDump ? 'Show Message' : 'Show Hexdump'}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
title="Copy message"
|
||||||
|
icon="copy"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => copyToClipboard(formattedMessage ?? '')}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<Icon
|
{!showLarge && event.message.length > 1000 * 1000 ? (
|
||||||
color={
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
messageType === 'close' || messageType === 'open'
|
Message previews larger than 1MB are hidden
|
||||||
? 'secondary'
|
<div>
|
||||||
: isServer
|
<Button
|
||||||
? 'info'
|
onClick={() => {
|
||||||
: 'primary'
|
setShowingLarge(true);
|
||||||
}
|
setTimeout(() => {
|
||||||
icon={
|
setShowLarge(true);
|
||||||
messageType === 'close' || messageType === 'open'
|
setShowingLarge(false);
|
||||||
? 'info'
|
}, 500);
|
||||||
: isServer
|
}}
|
||||||
? 'arrow_big_down_dash'
|
isLoading={showingLarge}
|
||||||
: 'arrow_big_up_dash'
|
color="secondary"
|
||||||
}
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : event.message.length === 0 ? (
|
||||||
|
<EmptyStateText>No Content</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
language={language}
|
||||||
|
defaultValue={formattedMessage ?? ''}
|
||||||
|
wrapLines={false}
|
||||||
|
readOnly={true}
|
||||||
|
stateKey={null}
|
||||||
/>
|
/>
|
||||||
<div className={classNames('w-full truncate text-xs')}>
|
)}
|
||||||
{messageType === 'close' ? (
|
|
||||||
'Disconnected from server'
|
|
||||||
) : messageType === 'open' ? (
|
|
||||||
'Connected to server'
|
|
||||||
) : message === '' ? (
|
|
||||||
<em className="italic text-text-subtlest">No content</em>
|
|
||||||
) : (
|
|
||||||
message.slice(0, 1000)
|
|
||||||
)}
|
|
||||||
{/*{error && <span className="text-warning"> ({error})</span>}*/}
|
|
||||||
</div>
|
|
||||||
<div className={classNames('opacity-50 text-xs')}>
|
|
||||||
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||||
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
@@ -7,9 +7,19 @@ interface Props<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
|
/** Make container focusable for keyboard navigation */
|
||||||
|
focusable?: boolean;
|
||||||
|
/** Callback to expose the virtualizer for keyboard navigation */
|
||||||
|
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
export function AutoScroller<T>({
|
||||||
|
data,
|
||||||
|
render,
|
||||||
|
header,
|
||||||
|
focusable = false,
|
||||||
|
onVirtualizerReady,
|
||||||
|
}: Props<T>) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
||||||
|
|
||||||
@@ -20,6 +30,11 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
|||||||
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose virtualizer to parent for keyboard navigation
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onVirtualizerReady?.(rowVirtualizer);
|
||||||
|
}, [rowVirtualizer, onVirtualizerReady]);
|
||||||
|
|
||||||
// Scroll to new items
|
// Scroll to new items
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: UIEvent<HTMLDivElement>) => {
|
(e: UIEvent<HTMLDivElement>) => {
|
||||||
@@ -63,7 +78,12 @@ export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{header ?? <span aria-hidden />}
|
{header ?? <span aria-hidden />}
|
||||||
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full w-full overflow-y-auto"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
tabIndex={focusable ? 0 : undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
|
||||||
|
import { AutoScroller } from './AutoScroller';
|
||||||
|
import { Banner } from './Banner';
|
||||||
|
import { Separator } from './Separator';
|
||||||
|
import { SplitLayout } from './SplitLayout';
|
||||||
|
|
||||||
|
interface EventViewerProps<T> {
|
||||||
|
/** Array of events to display */
|
||||||
|
events: T[];
|
||||||
|
|
||||||
|
/** Get unique key for each event */
|
||||||
|
getEventKey: (event: T, index: number) => string;
|
||||||
|
|
||||||
|
/** Render the event row - receives event, index, isActive, and onClick */
|
||||||
|
renderRow: (props: {
|
||||||
|
event: T;
|
||||||
|
index: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => ReactNode;
|
||||||
|
|
||||||
|
/** Render the detail pane for the selected event */
|
||||||
|
renderDetail?: (props: { event: T; index: number }) => ReactNode;
|
||||||
|
|
||||||
|
/** Optional header above the event list (e.g., connection status) */
|
||||||
|
header?: ReactNode;
|
||||||
|
|
||||||
|
/** Error message to display as a banner */
|
||||||
|
error?: string | null;
|
||||||
|
|
||||||
|
/** Name for SplitLayout state persistence */
|
||||||
|
splitLayoutName: string;
|
||||||
|
|
||||||
|
/** Default ratio for the split (0.0 - 1.0) */
|
||||||
|
defaultRatio?: number;
|
||||||
|
|
||||||
|
/** Enable keyboard navigation (arrow keys) */
|
||||||
|
enableKeyboardNav?: boolean;
|
||||||
|
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/** Message to show while loading */
|
||||||
|
loadingMessage?: string;
|
||||||
|
|
||||||
|
/** Message to show when no events */
|
||||||
|
emptyMessage?: string;
|
||||||
|
|
||||||
|
/** Callback when active index changes (for controlled state in parent) */
|
||||||
|
onActiveIndexChange?: (index: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventViewer<T>({
|
||||||
|
events,
|
||||||
|
getEventKey,
|
||||||
|
renderRow,
|
||||||
|
renderDetail,
|
||||||
|
header,
|
||||||
|
error,
|
||||||
|
splitLayoutName,
|
||||||
|
defaultRatio = 0.4,
|
||||||
|
enableKeyboardNav = true,
|
||||||
|
isLoading = false,
|
||||||
|
loadingMessage = 'Loading events...',
|
||||||
|
emptyMessage = 'No events recorded',
|
||||||
|
onActiveIndexChange,
|
||||||
|
}: EventViewerProps<T>) {
|
||||||
|
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Wrap setActiveIndex to notify parent
|
||||||
|
const setActiveIndex = useCallback(
|
||||||
|
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
|
||||||
|
setActiveIndexInternal((prev) => {
|
||||||
|
const newIndex =
|
||||||
|
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
|
||||||
|
onActiveIndexChange?.(newIndex);
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onActiveIndexChange],
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
|
||||||
|
|
||||||
|
const activeEvent = useMemo(
|
||||||
|
() => (activeIndex != null ? events[activeIndex] : null),
|
||||||
|
[activeIndex, events],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the event list container is focused
|
||||||
|
const isContainerFocused = useCallback(() => {
|
||||||
|
return containerRef.current?.contains(document.activeElement) ?? false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEventViewerKeyboard({
|
||||||
|
totalCount: events.length,
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
virtualizer: virtualizerRef.current,
|
||||||
|
isContainerFocused,
|
||||||
|
enabled: enableKeyboardNav,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle virtualizer ready callback
|
||||||
|
const handleVirtualizerReady = useCallback(
|
||||||
|
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
|
||||||
|
virtualizerRef.current = virtualizer;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle selection on click
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setActiveIndex((prev) => (prev === index ? null : index));
|
||||||
|
},
|
||||||
|
[setActiveIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0 && !error) {
|
||||||
|
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="h-full">
|
||||||
|
<SplitLayout
|
||||||
|
layout="vertical"
|
||||||
|
name={splitLayoutName}
|
||||||
|
defaultRatio={defaultRatio}
|
||||||
|
minHeightPx={10}
|
||||||
|
firstSlot={({ style }) => (
|
||||||
|
<div style={style} className="w-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
{header}
|
||||||
|
<AutoScroller
|
||||||
|
data={events}
|
||||||
|
focusable={enableKeyboardNav}
|
||||||
|
onVirtualizerReady={handleVirtualizerReady}
|
||||||
|
header={
|
||||||
|
error && (
|
||||||
|
<Banner color="danger" className="m-3">
|
||||||
|
{error}
|
||||||
|
</Banner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render={(event, index) => (
|
||||||
|
<div key={getEventKey(event, index)}>
|
||||||
|
{renderRow({
|
||||||
|
event,
|
||||||
|
index,
|
||||||
|
isActive: index === activeIndex,
|
||||||
|
onClick: () => handleRowClick(index),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
secondSlot={
|
||||||
|
activeEvent != null && renderDetail
|
||||||
|
? ({ style }) => (
|
||||||
|
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 overflow-y-auto">
|
||||||
|
{renderDetail({ event: activeEvent, index: activeIndex ?? 0 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface EventViewerRowProps {
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: ReactNode;
|
||||||
|
content: ReactNode;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventViewerRow({
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
}: EventViewerRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
||||||
|
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
|
isActive && '!bg-surface-active !text-text',
|
||||||
|
'text-text-subtle hover:text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<div className="w-full truncate">{content}</div>
|
||||||
|
<div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,15 +5,13 @@ import { Fragment, useMemo, useState } from 'react';
|
|||||||
import { useFormatText } from '../../hooks/useFormatText';
|
import { useFormatText } from '../../hooks/useFormatText';
|
||||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||||
import { isJSON } from '../../lib/contentType';
|
import { isJSON } from '../../lib/contentType';
|
||||||
import { AutoScroller } from '../core/AutoScroller';
|
|
||||||
import { Banner } from '../core/Banner';
|
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import type { EditorProps } from '../core/Editor/Editor';
|
import type { EditorProps } from '../core/Editor/Editor';
|
||||||
import { Editor } from '../core/Editor/LazyEditor';
|
import { Editor } from '../core/Editor/LazyEditor';
|
||||||
|
import { EventViewer } from '../core/EventViewer';
|
||||||
|
import { EventViewerRow } from '../core/EventViewerRow';
|
||||||
import { Icon } from '../core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from '../core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
import { Separator } from '../core/Separator';
|
|
||||||
import { SplitLayout } from '../core/SplitLayout';
|
|
||||||
import { HStack, VStack } from '../core/Stacks';
|
import { HStack, VStack } from '../core/Stacks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,134 +31,103 @@ export function EventStreamViewer({ response }: Props) {
|
|||||||
function ActualEventStreamViewer({ response }: Props) {
|
function ActualEventStreamViewer({ response }: Props) {
|
||||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
|
||||||
const events = useResponseBodyEventSource(response);
|
const events = useResponseBodyEventSource(response);
|
||||||
const activeEvent = useMemo(
|
|
||||||
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
|
|
||||||
[activeEventIndex, events],
|
|
||||||
);
|
|
||||||
|
|
||||||
const language = useMemo<'text' | 'json'>(() => {
|
|
||||||
if (!activeEvent?.data) return 'text';
|
|
||||||
return isJSON(activeEvent?.data) ? 'json' : 'text';
|
|
||||||
}, [activeEvent?.data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<EventViewer
|
||||||
layout="vertical"
|
events={events.data ?? []}
|
||||||
name="grpc_events"
|
getEventKey={(_, index) => String(index)}
|
||||||
|
error={events.error ? String(events.error) : null}
|
||||||
|
splitLayoutName="sse_events"
|
||||||
defaultRatio={0.4}
|
defaultRatio={0.4}
|
||||||
minHeightPx={20}
|
renderRow={({ event, index, isActive, onClick }) => (
|
||||||
firstSlot={() => (
|
<EventViewerRow
|
||||||
<AutoScroller
|
isActive={isActive}
|
||||||
data={events.data ?? []}
|
onClick={onClick}
|
||||||
header={
|
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
|
||||||
events.error && (
|
content={
|
||||||
<Banner color="danger" className="m-3">
|
<HStack space={2} className="items-center">
|
||||||
{String(events.error)}
|
<EventLabels event={event} index={index} isActive={isActive} />
|
||||||
</Banner>
|
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
|
||||||
)
|
</HStack>
|
||||||
}
|
}
|
||||||
render={(event, i) => (
|
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
|
||||||
<EventRow
|
/>
|
||||||
event={event}
|
)}
|
||||||
isActive={i === activeEventIndex}
|
renderDetail={({ event, index }) => (
|
||||||
index={i}
|
<EventDetail
|
||||||
onClick={() => {
|
event={event}
|
||||||
if (i === activeEventIndex) setActiveEventIndex(null);
|
index={index}
|
||||||
else setActiveEventIndex(i);
|
showLarge={showLarge}
|
||||||
}}
|
showingLarge={showingLarge}
|
||||||
/>
|
setShowLarge={setShowLarge}
|
||||||
)}
|
setShowingLarge={setShowingLarge}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
secondSlot={
|
|
||||||
activeEvent
|
|
||||||
? () => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col pl-2">
|
|
||||||
<HStack space={1.5} className="mb-2 font-semibold">
|
|
||||||
<EventLabels
|
|
||||||
className="text-sm"
|
|
||||||
event={activeEvent}
|
|
||||||
index={activeEventIndex ?? 0}
|
|
||||||
/>
|
|
||||||
Message Received
|
|
||||||
</HStack>
|
|
||||||
{!showLarge && activeEvent.data.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<FormattedEditor language={language} text={activeEvent.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EventDetail({
|
||||||
|
event,
|
||||||
|
index,
|
||||||
|
showLarge,
|
||||||
|
showingLarge,
|
||||||
|
setShowLarge,
|
||||||
|
setShowingLarge,
|
||||||
|
}: {
|
||||||
|
event: ServerSentEvent;
|
||||||
|
index: number;
|
||||||
|
showLarge: boolean;
|
||||||
|
showingLarge: boolean;
|
||||||
|
setShowLarge: (v: boolean) => void;
|
||||||
|
setShowingLarge: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const language = useMemo<'text' | 'json'>(() => {
|
||||||
|
if (!event?.data) return 'text';
|
||||||
|
return isJSON(event?.data) ? 'json' : 'text';
|
||||||
|
}, [event?.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<HStack space={1.5} className="mb-2 font-semibold">
|
||||||
|
<EventLabels className="text-sm" event={event} index={index} />
|
||||||
|
Message Received
|
||||||
|
</HStack>
|
||||||
|
{!showLarge && event.data.length > 1000 * 1000 ? (
|
||||||
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
|
Message previews larger than 1MB are hidden
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowingLarge(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowLarge(true);
|
||||||
|
setShowingLarge(false);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
isLoading={showingLarge}
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<FormattedEditor language={language} text={event.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
|
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
|
||||||
const formatted = useFormatText({ text, language, pretty: true });
|
const formatted = useFormatText({ text, language, pretty: true });
|
||||||
if (formatted == null) return null;
|
if (formatted == null) return null;
|
||||||
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
|
||||||
onClick,
|
|
||||||
isActive,
|
|
||||||
event,
|
|
||||||
className,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
isActive: boolean;
|
|
||||||
event: ServerSentEvent;
|
|
||||||
className?: string;
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
|
|
||||||
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
|
|
||||||
isActive && '!bg-surface-active !text-text',
|
|
||||||
'text-text-subtle hover:text',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
|
|
||||||
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
|
|
||||||
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventLabels({
|
function EventLabels({
|
||||||
className,
|
className,
|
||||||
event,
|
event,
|
||||||
@@ -169,7 +136,7 @@ function EventLabels({
|
|||||||
}: {
|
}: {
|
||||||
event: ServerSentEvent;
|
event: ServerSentEvent;
|
||||||
index: number;
|
index: number;
|
||||||
className: string;
|
className?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useKey } from 'react-use';
|
||||||
|
|
||||||
|
interface UseEventViewerKeyboardProps {
|
||||||
|
totalCount: number;
|
||||||
|
activeIndex: number | null;
|
||||||
|
setActiveIndex: (index: number | null) => void;
|
||||||
|
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
|
||||||
|
isContainerFocused: () => boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventViewerKeyboard({
|
||||||
|
totalCount,
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
virtualizer,
|
||||||
|
isContainerFocused,
|
||||||
|
enabled = true,
|
||||||
|
}: UseEventViewerKeyboardProps) {
|
||||||
|
const selectPrev = useCallback(() => {
|
||||||
|
if (totalCount === 0) return;
|
||||||
|
|
||||||
|
const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);
|
||||||
|
setActiveIndex(newIndex);
|
||||||
|
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
|
||||||
|
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
|
||||||
|
|
||||||
|
const selectNext = useCallback(() => {
|
||||||
|
if (totalCount === 0) return;
|
||||||
|
|
||||||
|
const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);
|
||||||
|
setActiveIndex(newIndex);
|
||||||
|
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
|
||||||
|
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
|
||||||
|
|
||||||
|
useKey(
|
||||||
|
(e) => e.key === 'ArrowUp' || e.key === 'k',
|
||||||
|
(e) => {
|
||||||
|
if (!enabled || !isContainerFocused()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectPrev();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
[enabled, isContainerFocused, selectPrev],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKey(
|
||||||
|
(e) => e.key === 'ArrowDown' || e.key === 'j',
|
||||||
|
(e) => {
|
||||||
|
if (!enabled || !isContainerFocused()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectNext();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
[enabled, isContainerFocused, selectNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKey(
|
||||||
|
(e) => e.key === 'Escape',
|
||||||
|
(e) => {
|
||||||
|
if (!enabled || !isContainerFocused()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(null);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
[enabled, isContainerFocused, setActiveIndex],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user