Refactor into grpc events

This commit is contained in:
Gregory Schier
2024-02-22 00:49:22 -08:00
parent 6f389b0010
commit 766da4327c
31 changed files with 851 additions and 595 deletions

View File

@@ -6,7 +6,7 @@ interface Props {
export function EmptyStateText({ children }: Props) {
return (
<div className="rounded-lg border border-dashed border-highlight h-full text-gray-400 flex items-center justify-center">
<div className="rounded-lg border border-dashed border-highlight h-full py-2 text-gray-400 flex items-center justify-center">
{children}
</div>
);

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcMessagesQueryKey } from '../hooks/useGrpcMessages';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
@@ -53,8 +53,8 @@ export function GlobalHooks() {
? httpResponsesQueryKey(payload)
: payload.model === 'grpc_connection'
? grpcConnectionsQueryKey(payload)
: payload.model === 'grpc_message'
? grpcMessagesQueryKey(payload)
: payload.model === 'grpc_event'
? grpcEventsQueryKey(payload)
: payload.model === 'grpc_request'
? grpcRequestsQueryKey(payload)
: payload.model === 'workspace'
@@ -107,8 +107,8 @@ export function GlobalHooks() {
queryClient.setQueryData(grpcRequestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_connection') {
queryClient.setQueryData(grpcConnectionsQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_message') {
queryClient.setQueryData(grpcMessagesQueryKey(payload), removeById(payload));
} else if (payload.model === 'grpc_event') {
queryClient.setQueryData(grpcEventsQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
} else if (payload.model === 'cookie_jar') {

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useGrpc } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import { useGrpcEvents } from '../hooks/useGrpcEvents';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList';
@@ -21,7 +21,7 @@ export function GrpcConnectionLayout({ style }: Props) {
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const connections = useGrpcConnections(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const messages = useGrpcEvents(activeConnection?.id ?? null);
const grpc = useGrpc(activeRequest, activeConnection);
const services = grpc.reflect.data ?? null;

View File

@@ -1,15 +1,17 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useMemo, useState } from 'react';
import { format, addMilliseconds } from 'date-fns';
import type { CSSProperties, ReactNode } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import type { GrpcRequest } from '../lib/models';
import { useGrpcEvents } from '../hooks/useGrpcEvents';
import type { GrpcEvent, GrpcRequest } from '../lib/models';
import { Icon } from './core/Icon';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
interface Props {
@@ -25,34 +27,80 @@ interface Props {
| 'no-method';
}
const CONNECTION_RESPONSE_EVENT_ID = 'connection_response';
export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) {
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const [activeEventId, setActiveEventId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const ogEvents = useGrpcEvents(activeConnection?.id ?? null);
const activeMessage = useMemo(
() => messages.find((m) => m.id === activeMessageId) ?? null,
[activeMessageId, messages],
const events = useMemo(() => {
const createdAt =
activeConnection != null &&
addMilliseconds(activeConnection.createdAt, activeConnection.elapsed)
.toISOString()
.replace('Z', '');
if (activeConnection == null || activeConnection.elapsed === 0) {
return ogEvents;
} else if (activeConnection.error != null) {
return [
...ogEvents,
{
id: CONNECTION_RESPONSE_EVENT_ID,
eventType: 'error',
content: activeConnection.error,
metadata: activeConnection.trailers,
createdAt,
updatedAt: createdAt,
} as GrpcEvent,
];
} else {
return [
...ogEvents,
{
id: CONNECTION_RESPONSE_EVENT_ID,
eventType: activeConnection.status === 0 ? 'connection_response' : 'error',
content: `Connection ${GRPC_CODES[activeConnection.status] ?? 'closed'}`,
metadata: activeConnection.trailers,
createdAt,
updatedAt: createdAt,
} as GrpcEvent,
];
}
}, [activeConnection, ogEvents]);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
[activeEventId, events],
);
// Set active message to the first message received if unary
useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
return;
}
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events.length]);
return (
<SplitLayout
layout="vertical"
style={style}
name={methodType === 'unary' ? 'grpc_messages_unary' : 'grpc_messages_streaming'}
defaultRatio={methodType === 'unary' ? 0.75 : 0.3}
name="grpc_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() => (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono" alignItems="center">
<HStack alignItems="center" space={2}>
<span>{messages.filter((m) => !m.isInfo).length} messages</span>
{activeConnection?.elapsed === 0 && (
<Icon icon="refresh" size="sm" spin className="text-gray-600" />
)}
</HStack>
{activeConnection && (
firstSlot={() =>
activeConnection && (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono" alignItems="center">
<HStack alignItems="center" space={2}>
<span>{events.length} messages</span>
{activeConnection.elapsed === 0 && (
<Icon icon="refresh" size="sm" spin className="text-gray-600" />
)}
</HStack>
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
@@ -60,63 +108,59 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
// todo
}}
/>
)}
</HStack>
<div className="overflow-y-auto h-full">
{...messages.map((m) => (
<HStack
role="button"
key={m.id}
space={2}
onClick={() => {
if (m.id === activeMessageId) setActiveMessageId(null);
else setActiveMessageId(m.id);
}}
alignItems="center"
className={classNames(
'px-2 py-1 font-mono cursor-default group',
m === activeMessage && '!bg-highlight',
)}
>
<Icon
className={
m.isInfo ? 'text-gray-600' : m.isServer ? 'text-blue-600' : 'text-green-600'
}
icon={m.isInfo ? 'info' : m.isServer ? 'arrowBigDownDash' : 'arrowBigUpDash'}
/>
<div
className={classNames(
'w-full truncate text-gray-800 text-2xs group-hover:text-gray-900',
m.id === activeMessageId && 'text-gray-900',
)}
</HStack>
<div className="overflow-y-auto h-full">
{...events.map((m) => (
<MessageRow
key={m.id}
isActive={m.id === activeEventId}
eventType={m.eventType}
timestamp={m.createdAt}
onClick={() => {
if (m.id === activeEventId) setActiveEventId(null);
else setActiveEventId(m.id);
}}
>
{m.message}
</div>
<div
className={classNames(
'text-gray-600 text-2xs group-hover:text-gray-700',
m.id === activeMessageId && 'text-gray-700',
)}
>
{format(m.createdAt, 'HH:mm:ss')}
</div>
</HStack>
))}
{m.content}
</MessageRow>
))}
</div>
</div>
</div>
)}
)
}
secondSlot={
activeMessage &&
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">
{activeMessage.isInfo ? (
<span>{activeMessage.message}</span>
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
</>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeMessage?.message ?? '{}')} />
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="mb-2 select-text cursor-text font-semibold">
{activeEvent.content}
</div>
{Object.keys(activeEvent.metadata).length === 0 ? (
<EmptyStateText>
No {activeEvent.eventType === 'connection_response' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(activeEvent.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key} value={value} />
))}
</KeyValueRows>
)}
</div>
)}
</div>
</div>
@@ -125,3 +169,89 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
/>
);
}
function MessageRow({
onClick,
isActive,
eventType,
children,
timestamp,
}: {
onClick?: () => void;
isActive?: boolean;
eventType: GrpcEvent['eventType'];
children: ReactNode;
timestamp: string;
}) {
return (
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1 py-1 font-mono cursor-default group focus:outline-none',
isActive && '!bg-highlight text-gray-900',
'text-gray-800 hover:text-gray-900',
)}
>
<Icon
className={
eventType === 'server_message'
? 'text-blue-600'
: eventType === 'client_message'
? 'text-violet-600'
: eventType === 'error'
? 'text-orange-600'
: eventType === 'connection_response'
? 'text-green-600'
: 'text-gray-700'
}
title={
eventType === 'server_message'
? 'Server message'
: eventType === 'client_message'
? 'Client message'
: eventType === 'error'
? 'Error'
: eventType === 'connection_response'
? 'Connection response'
: undefined
}
icon={
eventType === 'server_message'
? 'arrowBigDownDash'
: eventType === 'client_message'
? 'arrowBigUpDash'
: eventType === 'error'
? 'alert'
: eventType === 'connection_response'
? 'check'
: 'info'
}
/>
<div className={classNames('w-full truncate text-2xs')}>{children}</div>
<div className={classNames('opacity-50 text-2xs')}>
{format(timestamp + 'Z', 'HH:mm:ss.SSS')}
</div>
</button>
);
}
const GRPC_CODES: Record<number, string> = {
0: 'Ok',
1: 'Cancelled',
2: 'Unknown',
3: 'Invalid argument',
4: 'Deadline exceeded',
5: 'Not found',
6: 'Already exists',
7: 'Permission denied',
8: 'Resource exhausted',
9: 'Failed precondition',
10: 'Aborted',
11: 'Out of range',
12: 'Unimplemented',
13: 'Internal',
14: 'Unavailable',
15: 'Data loss',
16: 'Unauthenticated',
};

View File

@@ -218,7 +218,7 @@ export function GrpcConnectionSetupPane({
className="border border-highlight"
size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction={isStreaming ? undefined : 'http_request.send'}
hotkeyAction="grpc_request.send"
onClick={handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={
@@ -240,24 +240,24 @@ export function GrpcConnectionSetupPane({
disabled={!isStreaming}
/>
)}
{methodType === 'client_streaming' && isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={onCommit}
icon="check"
/>
)}
{(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
<>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={onCommit}
icon="check"
/>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
</>
)}
</HStack>
</div>

View File

@@ -1,10 +1,8 @@
import { shell } from '@tauri-apps/api';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import type { HttpResponse } from '../lib/models';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { HStack } from './core/Stacks';
interface Props {
response: HttpResponse;
@@ -13,16 +11,16 @@ interface Props {
export function ResponseHeaders({ response }: Props) {
return (
<div className="overflow-auto h-full pb-4">
<dl className="text-xs w-full font-mono flex flex-col">
<KeyValueRows>
{response.headers.map((h, i) => (
<Row key={i} label={h.name} value={h.value} labelClassName="!text-violet-600" />
<KeyValueRow key={i} label={h.name} value={h.value} labelClassName="!text-violet-600" />
))}
</dl>
</KeyValueRows>
<Separator className="my-4">Other Info</Separator>
<dl className="text-xs w-full font-mono divide-highlightSecondary">
<Row label="Version" value={response.version} />
<Row label="Remote Address" value={response.remoteAddr} />
<Row
<KeyValueRows>
<KeyValueRow label="Version" value={response.version} />
<KeyValueRow label="Remote Address" value={response.remoteAddr} />
<KeyValueRow
label={
<div className="flex items-center">
URL
@@ -41,26 +39,7 @@ export function ResponseHeaders({ response }: Props) {
</div>
}
/>
</dl>
</KeyValueRows>
</div>
);
}
function Row({
label,
value,
labelClassName,
}: {
label: ReactNode;
value: ReactNode;
labelClassName?: string;
}) {
return (
<HStack space={3} className="py-0.5">
<dd className={classNames(labelClassName, 'w-1/3 text-gray-700 select-text cursor-text')}>
{label}
</dd>
<dt className="w-2/3 select-text cursor-text break-all">{value}</dt>
</HStack>
);
}

View File

@@ -48,7 +48,7 @@ export const SidebarActions = memo(function SidebarActions() {
},
{
key: 'create-grpc-request',
label: 'GRPC Call',
label: 'gRPC Call',
onSelect: () => createGrpcRequest.mutate({}),
},
{

View File

@@ -69,7 +69,7 @@ export const UrlBar = memo(function UrlBar({
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="!h-auto my-0.5 mr-0.5"
className="!h-auto my-0.5 ml-0.5"
/>
)
}

View File

@@ -15,6 +15,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList';
import { HStack } from './core/Stacks';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay';
@@ -160,7 +161,19 @@ export default function Workspace() {
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{activeRequest == null ? (
<HotKeyList hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']} />
<HotKeyList
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button size="sm" color="gray">
Import
</Button>
<Button size="sm" color="gray">
New Request
</Button>
</HStack>
}
/>
) : activeRequest.model === 'grpc_request' ? (
<GrpcConnectionLayout style={body} />
) : (

View File

@@ -6,9 +6,10 @@ import { HStack, VStack } from './Stacks';
interface Props {
hotkeys: HotkeyAction[];
bottomSlot?: React.ReactNode;
}
export const HotKeyList = ({ hotkeys }: Props) => {
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
return (
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
<VStack space={2}>
@@ -18,6 +19,7 @@ export const HotKeyList = ({ hotkeys }: Props) => {
<HotKey className="ml-auto" action={hotkey} />
</HStack>
))}
{bottomSlot}
</VStack>
</div>
);

View File

@@ -4,8 +4,11 @@ import type { HTMLAttributes } from 'react';
import { memo } from 'react';
const icons = {
alert: lucide.AlertTriangleIcon,
archive: lucide.ArchiveIcon,
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
arrowBigLeftDash: lucide.ArrowBigLeftDashIcon,
arrowBigRightDash: lucide.ArrowBigRightDashIcon,
arrowBigUpDash: lucide.ArrowBigUpDashIcon,
arrowDown: lucide.ArrowDownIcon,
arrowDownToDot: lucide.ArrowDownToDotIcon,
@@ -60,12 +63,14 @@ export interface IconProps {
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
spin?: boolean;
title?: string;
}
export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: IconProps) {
export const Icon = memo(function Icon({ icon, spin, size = 'md', className, title }: IconProps) {
const Component = icons[icon] ?? icons.question;
return (
<Component
title={title}
className={classNames(
className,
'text-inherit flex-shrink-0',

View File

@@ -0,0 +1,24 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { HStack } from './Stacks';
interface Props {
label: ReactNode;
value: ReactNode;
labelClassName?: string;
}
export function KeyValueRows({ children }: { children: ReactNode }) {
return <dl className="text-xs w-full font-mono divide-highlightSecondary">{children}</dl>;
}
export function KeyValueRow({ label, value, labelClassName }: Props) {
return (
<HStack space={3} className="py-0.5">
<dd className={classNames(labelClassName, 'w-1/3 text-gray-700 select-text cursor-text')}>
{label}
</dd>
<dt className="w-2/3 select-text cursor-text break-all">{value}</dt>
</HStack>
);
}