mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-28 12:11:53 +01:00
Refactor: Consolidate event viewers into unified EventViewer component
Migrate EventStreamViewer, HttpResponseTimeline, GrpcResponsePane, and WebsocketResponsePane to use a shared EventViewer component with generic event type support, render props for rows and details, and keyboard navigation (↑/↓/j/k/Escape). This reduces duplication and provides a consistent event viewing experience across all response types.
This commit is contained in:
@@ -3,18 +3,18 @@ import type {
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { AutoScroller } from './core/AutoScroller';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { EventViewer } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||
import { Icon, type IconProps } from './core/Icon';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
import { Separator } from './core/Separator';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
@@ -25,113 +25,73 @@ export function HttpResponseTimeline({ 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 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 (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="http_response_events"
|
||||
<EventViewer
|
||||
events={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}
|
||||
minHeightPx={10}
|
||||
firstSlot={() => (
|
||||
<AutoScroller
|
||||
data={events}
|
||||
render={(event, i) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
isActive={i === activeEventIndex}
|
||||
onClick={() => {
|
||||
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||
else setActiveEventIndex(i);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
renderRow={({ event, isActive, onClick }) => {
|
||||
const display = getEventDisplay(event.event);
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
||||
content={display.summary}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
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 {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
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 timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
||||
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
|
||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||
return (
|
||||
@@ -139,6 +99,8 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
<DetailHeader
|
||||
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
||||
timestamp={timestamp}
|
||||
showRaw={showRaw}
|
||||
setShowRaw={setShowRaw}
|
||||
/>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
@@ -152,7 +114,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
if (e.type === 'send_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Request" timestamp={timestamp} />
|
||||
<DetailHeader title="Request" timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Method">
|
||||
<HttpMethodTagRaw forceColor method={e.method} />
|
||||
@@ -167,7 +129,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
if (e.type === 'receive_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Response" timestamp={timestamp} />
|
||||
<DetailHeader title="Response" timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
@@ -182,7 +144,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
if (e.type === 'redirect') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Redirect" timestamp={timestamp} />
|
||||
<DetailHeader title="Redirect" timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
@@ -200,7 +162,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
if (e.type === 'setting') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
||||
<DetailHeader title="Apply Setting" timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
@@ -214,7 +176,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
@@ -224,21 +186,62 @@ function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
</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 = {
|
||||
icon: IconProps['icon'];
|
||||
color: IconProps['color'];
|
||||
|
||||
Reference in New Issue
Block a user