mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:14:03 +01:00
Server sent event response viewer (#126)
This commit is contained in:
@@ -188,7 +188,7 @@ function EventRow({
|
||||
className={classNames(
|
||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
||||
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
|
||||
isActive && '!bg-surface-highlight !text',
|
||||
isActive && '!bg-surface-highlight !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ResponseHeaders } from './ResponseHeaders';
|
||||
import { ResponseInfo } from './ResponseInfo';
|
||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
||||
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { PdfViewer } from './responseViewers/PdfViewer';
|
||||
@@ -163,15 +164,17 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
<div className="pb-2 h-full">
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
</div>
|
||||
) : contentType?.startsWith('image') ? (
|
||||
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : contentType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
|
||||
) : contentType?.startsWith('audio') ? (
|
||||
) : contentType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
|
||||
) : contentType?.startsWith('video') ? (
|
||||
) : contentType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
|
||||
) : contentType?.match(/pdf/) ? (
|
||||
) : contentType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
|
||||
) : contentType?.match(/csv|tab-separated/) ? (
|
||||
) : contentType?.match(/csv|tab-separated/i) ? (
|
||||
<CsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
// ) : viewMode === 'pretty' && contentType?.includes('json') ? (
|
||||
|
||||
@@ -6,7 +6,6 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
'select-text cursor-text',
|
||||
'font-mono text-shrink bg-surface-highlight border border-border-subtle',
|
||||
'px-1.5 py-0.5 rounded text shadow-inner break-words',
|
||||
)}
|
||||
|
||||
205
src-web/components/responseViewers/EventStreamViewer.tsx
Normal file
205
src-web/components/responseViewers/EventStreamViewer.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import type { ServerSentEvent } from '@yaakapp-internal/sse';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Fragment, useMemo, useRef, useState } from 'react';
|
||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||
import { isJSON } from '../../lib/contentType';
|
||||
import { tryFormatJson } from '../../lib/formatters';
|
||||
import { Button } from '../core/Button';
|
||||
import { Editor } from '../core/Editor';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { SplitLayout } from '../core/SplitLayout';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function EventStreamViewer({ response }: Props) {
|
||||
return (
|
||||
<Fragment
|
||||
key={response.id} // force a refresh when the response changes
|
||||
>
|
||||
<ActualEventStreamViewer response={response} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ActualEventStreamViewer({ response }: Props) {
|
||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
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 (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="grpc_events"
|
||||
defaultRatio={0.4}
|
||||
minHeightPx={20}
|
||||
firstSlot={() => (
|
||||
<EventStreamEventsVirtual
|
||||
events={events.data ?? []}
|
||||
activeEventIndex={activeEventIndex}
|
||||
setActiveEventIndex={setActiveEventIndex}
|
||||
/>
|
||||
)}
|
||||
secondSlot={
|
||||
activeEvent
|
||||
? () => (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<div className="pb-3 px-2">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="pl-2 overflow-y-auto">
|
||||
<div className="mb-2 select-text cursor-text font-semibold">Message Received</div>
|
||||
{!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>
|
||||
) : (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={activeEvent.id ?? activeEvent.data}
|
||||
defaultValue={tryFormatJson(activeEvent.data)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventStreamEventsVirtual({
|
||||
events,
|
||||
activeEventIndex,
|
||||
setActiveEventIndex,
|
||||
}: {
|
||||
events: ServerSentEvent[];
|
||||
activeEventIndex: number | null;
|
||||
setActiveEventIndex: (eventId: number | null) => void;
|
||||
}) {
|
||||
// The scrollable element for your list
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The virtualizer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: events.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // react-virtual requires a height, so we'll give it one
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="overflow-y-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const event = events[virtualItem.index]!;
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<EventStreamEvent
|
||||
event={event}
|
||||
isActive={virtualItem.index === activeEventIndex}
|
||||
onClick={() => {
|
||||
if (virtualItem.index === activeEventIndex) setActiveEventIndex(null);
|
||||
else setActiveEventIndex(virtualItem.index);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventStreamEvent({
|
||||
onClick,
|
||||
isActive,
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
event: ServerSentEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
|
||||
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
|
||||
isActive && '!bg-surface-highlight !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
|
||||
<HStack space={1.5} className="text-sm">
|
||||
{event.eventType && (
|
||||
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
|
||||
{event.eventType}
|
||||
</InlineCode>
|
||||
)}
|
||||
{event.id && (
|
||||
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
|
||||
{event.id}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user