Http response events (#326)

This commit is contained in:
Gregory Schier
2025-12-21 14:34:37 -08:00
committed by GitHub
parent 7e0aa919fb
commit 089c7e8dce
18 changed files with 779 additions and 74 deletions

View File

@@ -4,6 +4,7 @@ import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType';
@@ -23,6 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
import { ResponseEvents } from './ResponseEvents';
import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
@@ -46,6 +48,7 @@ interface Props {
const TAB_BODY = 'body';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const TAB_EVENTS = 'events';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
@@ -57,12 +60,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: 'Preview Mode',
hidden: (activeResponse?.contentLength || 0) === 0,
options: {
value: viewMode,
onChange: setViewMode,
@@ -82,6 +86,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
/>
),
},
{
value: TAB_EVENTS,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
},
{
value: TAB_INFO,
label: 'Info',
@@ -93,7 +102,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
setViewMode,
viewMode,
activeResponse?.requestHeaders.length,
activeResponse?.contentLength,
responseEvents.data?.length,
],
);
const activeTab = activeTabs?.[activeRequestId];
@@ -224,6 +233,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
</TabContent>
<TabContent value={TAB_EVENTS}>
<ResponseEvents response={activeResponse} />
</TabContent>
</Tabs>
</div>
</div>

View File

@@ -0,0 +1,341 @@
import type {
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { Fragment, type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
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';
interface Props {
response: HttpResponse;
}
export function ResponseEvents({ response }: Props) {
return (
<Fragment key={response.id}>
<ActualResponseEvents response={response} />
</Fragment>
);
}
function ActualResponseEvents({ response }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
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"
defaultRatio={0.5}
minHeightPx={20}
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);
}}
/>
)}
/>
)}
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 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 text-xs">{summary}</div>
<div className="text-xs 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 }) {
const { label } = getEventDisplay(event.event);
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
const e = event.event;
// Headers - show name and value with Editor for JSON
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<div className="flex flex-col gap-2 h-full">
<DetailHeader
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
timestamp={timestamp}
/>
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Request URL - show method and path separately
if (e.type === 'send_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Request" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Response" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Redirect" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Settings - show as key/value
if (e.type === 'setting') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Apply Setting" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
return (
<div className="flex flex-col gap-2">
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
<div className="font-mono text-sm">{formatBytes(e.bytes)}</div>
</div>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return (
<div className="flex flex-col gap-1">
<DetailHeader title={label} timestamp={timestamp} />
<div className="font-mono text-sm">{summary}</div>
</div>
);
}
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
</div>
);
}
type EventDisplay = {
icon: IconProps['icon'];
color: IconProps['color'];
label: string;
summary: ReactNode;
};
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case 'start_request':
return {
icon: 'info',
color: 'secondary',
label: 'Start',
summary: 'Request started',
};
case 'end_request':
return {
icon: 'info',
color: 'secondary',
label: 'End',
summary: 'Request complete',
};
case 'setting':
return {
icon: 'settings',
color: 'secondary',
label: 'Setting',
summary: `${event.name} = ${event.value}`,
};
case 'info':
return {
icon: 'info',
color: 'secondary',
label: 'Info',
summary: event.message,
};
case 'redirect':
return {
icon: 'arrow_big_right_dash',
color: 'warning',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
};
case 'send_url':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Request',
summary: `${event.method} ${event.path}`,
};
case 'receive_url':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Response',
summary: `${event.version} ${event.status}`,
};
case 'header_up':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'header_down':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'chunk_sent':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${event.bytes} bytes sent`,
};
case 'chunk_received':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${event.bytes} bytes received`,
};
default:
return {
icon: 'info',
color: 'secondary',
label: 'Unknown',
summary: 'Unknown event',
};
}
}

View File

@@ -41,10 +41,12 @@ export function HttpMethodTagRaw({
className,
method,
short,
forceColor,
}: {
method: string;
className?: string;
short?: boolean;
forceColor?: boolean;
}) {
let label = method.toUpperCase();
if (short) {
@@ -54,7 +56,8 @@ export function HttpMethodTagRaw({
const m = method.toUpperCase();
const colored = useAtomValue(settingsAtom).coloredMethods;
const settings = useAtomValue(settingsAtom);
const colored = forceColor || settings.coloredMethods;
return (
<span

View File

@@ -1,4 +1,5 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { HttpResponseState } from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props {
@@ -8,25 +9,40 @@ interface Props {
short?: boolean;
}
export function HttpStatusTag({ response, className, showReason, short }: Props) {
const { status, state } = response;
export function HttpStatusTag({ response, ...props }: Props) {
const { status, state, statusReason } = response;
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
}
export function HttpStatusTagRaw({
status,
state,
className,
showReason,
statusReason,
short,
}: Omit<Props, 'response'> & {
status: number | string;
state?: HttpResponseState;
statusReason?: string | null;
}) {
let colorClass: string;
let label = `${status}`;
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
if (state === 'initialized') {
label = short ? 'CONN' : 'CONNECTING';
colorClass = 'text-text-subtle';
} else if (status < 100) {
} else if (statusN < 100) {
label = short ? 'ERR' : 'ERROR';
colorClass = 'text-danger';
} else if (status < 200) {
} else if (statusN < 200) {
colorClass = 'text-info';
} else if (status < 300) {
} else if (statusN < 300) {
colorClass = 'text-success';
} else if (status < 400) {
} else if (statusN < 400) {
colorClass = 'text-primary';
} else if (status < 500) {
} else if (statusN < 500) {
colorClass = 'text-warning';
} else {
colorClass = 'text-danger';
@@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
return (
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
{label} {showReason && statusReason}
</span>
);
}