Better reflect failure UI

This commit is contained in:
Gregory Schier
2024-02-05 14:50:47 -08:00
parent 63a381c55a
commit 8309c19167
13 changed files with 584 additions and 395 deletions

View File

@@ -29,10 +29,11 @@ pub struct MethodDefinition {
pub server_streaming: bool,
}
pub async fn reflect(uri: &Uri) -> Vec<ServiceDefinition> {
let (pool, _) = fill_pool(uri).await;
pub async fn reflect(uri: &Uri) -> Result<Vec<ServiceDefinition>, String> {
let (pool, _) = fill_pool(uri).await?;
pool.services()
Ok(pool
.services()
.map(|s| {
let mut def = ServiceDefinition {
name: s.full_name().to_string(),
@@ -53,5 +54,5 @@ pub async fn reflect(uri: &Uri) -> Vec<ServiceDefinition> {
}
def
})
.collect::<Vec<_>>()
.collect::<Vec<_>>())
}

View File

@@ -173,7 +173,7 @@ impl GrpcManager {
message: &str,
) -> Result<Streaming<DynamicMessage>> {
self.connect(id, uri)
.await
.await?
.server_streaming(service, method, message)
.await
}
@@ -187,7 +187,7 @@ impl GrpcManager {
stream: ReceiverStream<String>,
) -> Result<DynamicMessage> {
self.connect(id, uri)
.await
.await?
.client_streaming(service, method, stream)
.await
}
@@ -201,15 +201,15 @@ impl GrpcManager {
stream: ReceiverStream<String>,
) -> Result<Streaming<DynamicMessage>> {
self.connect(id, uri)
.await
.await?
.streaming(service, method, stream)
.await
}
pub async fn connect(&mut self, id: &str, uri: Uri) -> GrpcConnection {
let (pool, conn) = fill_pool(&uri).await;
pub async fn connect(&mut self, id: &str, uri: Uri) -> Result<GrpcConnection> {
let (pool, conn) = fill_pool(&uri).await?;
let connection = GrpcConnection { pool, conn, uri };
self.connections.insert(id.to_string(), connection.clone());
connection
Ok(connection)
}
}

View File

@@ -5,6 +5,7 @@ use anyhow::anyhow;
use hyper::client::HttpConnector;
use hyper::Client;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use log::warn;
use prost::Message;
use prost_reflect::{DescriptorPool, MethodDescriptor};
use prost_types::FileDescriptorProto;
@@ -12,6 +13,7 @@ use tokio_stream::StreamExt;
use tonic::body::BoxBody;
use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri;
use tonic::Code::Unimplemented;
use tonic::Request;
use tonic_reflection::pb::server_reflection_client::ServerReflectionClient;
use tonic_reflection::pb::server_reflection_request::MessageRequest;
@@ -20,10 +22,13 @@ use tonic_reflection::pb::ServerReflectionRequest;
pub async fn fill_pool(
uri: &Uri,
) -> (
DescriptorPool,
Client<HttpsConnector<HttpConnector>, BoxBody>,
) {
) -> Result<
(
DescriptorPool,
Client<HttpsConnector<HttpConnector>, BoxBody>,
),
String,
> {
let mut pool = DescriptorPool::new();
let connector = HttpsConnectorBuilder::new().with_native_roots();
let connector = connector.https_or_http().enable_http2().wrap_connector({
@@ -37,34 +42,33 @@ pub async fn fill_pool(
.build(connector);
let mut client = ServerReflectionClient::with_origin(transport.clone(), uri.clone());
let services = list_services(&mut client).await;
for service in services {
for service in list_services(&mut client).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
continue;
}
file_descriptor_set_from_service_name(&service, &mut pool, &mut client).await;
}
(pool, transport)
Ok((pool, transport))
}
async fn list_services(
reflect_client: &mut ServerReflectionClient<Client<HttpsConnector<HttpConnector>, BoxBody>>,
) -> Vec<String> {
) -> Result<Vec<String>, String> {
let response =
send_reflection_request(reflect_client, MessageRequest::ListServices("".into())).await;
send_reflection_request(reflect_client, MessageRequest::ListServices("".into())).await?;
let list_services_response = match response {
MessageResponse::ListServicesResponse(resp) => resp,
_ => panic!("Expected a ListServicesResponse variant"),
};
list_services_response
Ok(list_services_response
.service
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.collect::<Vec<_>>())
}
async fn file_descriptor_set_from_service_name(
@@ -72,11 +76,21 @@ async fn file_descriptor_set_from_service_name(
pool: &mut DescriptorPool,
client: &mut ServerReflectionClient<Client<HttpsConnector<HttpConnector>, BoxBody>>,
) {
let response = send_reflection_request(
let response = match send_reflection_request(
client,
MessageRequest::FileContainingSymbol(service_name.into()),
)
.await;
.await
{
Ok(resp) => resp,
Err(e) => {
warn!(
"Error fetching file descriptor for service {}: {}",
service_name, e
);
return;
}
};
let file_descriptor_response = match response {
MessageResponse::FileDescriptorResponse(resp) => resp,
@@ -109,8 +123,14 @@ async fn file_descriptor_set_by_filename(
let response =
send_reflection_request(client, MessageRequest::FileByFilename(filename.into())).await;
let file_descriptor_response = match response {
MessageResponse::FileDescriptorResponse(resp) => resp,
_ => panic!("Expected a FileDescriptorResponse variant"),
Ok(MessageResponse::FileDescriptorResponse(resp)) => resp,
Ok(_) => {
panic!("Expected a FileDescriptorResponse variant")
}
Err(e) => {
warn!("Error fetching file descriptor for {}: {}", filename, e);
return;
}
};
for fd in file_descriptor_response.file_descriptor_proto {
@@ -123,7 +143,7 @@ async fn file_descriptor_set_by_filename(
async fn send_reflection_request(
client: &mut ServerReflectionClient<Client<HttpsConnector<HttpConnector>, BoxBody>>,
message: MessageRequest,
) -> MessageResponse {
) -> Result<MessageResponse, String> {
let reflection_request = ServerReflectionRequest {
host: "".into(), // Doesn't matter
message_request: Some(message),
@@ -134,14 +154,17 @@ async fn send_reflection_request(
client
.server_reflection_info(request)
.await
.expect("server reflection failed")
.map_err(|e| match e.code() {
Unimplemented => "Reflection not implemented for server".to_string(),
_ => e.to_string(),
})?
.into_inner()
.next()
.await
.expect("steamed response")
.expect("successful response")
.map_err(|e| e.to_string())?
.message_response
.expect("some MessageResponse")
.ok_or("No reflection response".to_string())
}
pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery {

View File

@@ -100,7 +100,7 @@ async fn cmd_grpc_reflect(
.await
.map_err(|e| e.to_string())?;
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
Ok(grpc::reflect(&uri).await)
grpc::reflect(&uri).await
}
#[tauri::command]
@@ -150,7 +150,7 @@ async fn cmd_grpc_call_unary(
.lock()
.await
.connect(&conn.clone().id, uri)
.await
.await?
.unary(
&req.service.unwrap_or_default(),
&req.method.unwrap_or_default(),

View File

@@ -86,10 +86,8 @@ export function GlobalHooks() {
queryClient.setQueryData<Model[]>(queryKey, (values = []) => {
const index = values.findIndex((v) => modelsEq(v, payload)) ?? -1;
if (index >= 0) {
console.log('UPDATED MODEL', payload);
return [...values.slice(0, index), payload, ...values.slice(index + 1)];
} else {
console.log('INSERTED MODEL', payload);
return pushToFront ? [payload, ...(values ?? [])] : [...(values ?? []), payload];
}
});

View File

@@ -1,27 +1,16 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties, FormEvent } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { RadioDropdown } from './core/RadioDropdown';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { GrpcEditor } from './GrpcEditor';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
import { UrlBar } from './UrlBar';
import { GrpcConnectionMessagesPane } from './GrpcConnectionMessagesPane';
import { GrpcConnectionSetupPane } from './GrpcConnectionSetupPane';
interface Props {
style: CSSProperties;
@@ -30,62 +19,19 @@ interface Props {
export function GrpcConnectionLayout({ style }: Props) {
const activeRequest = useActiveRequest('grpc_request');
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const alert = useAlert();
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const grpc = useGrpc(activeRequest, activeConnection);
const activeMethod = useMemo(() => {
if (grpc.services == null || activeRequest == null) return null;
const s = grpc.services.find((s) => s.name === activeRequest.service);
if (s == null) return null;
return s.methods.find((m) => m.name === activeRequest.method);
}, [activeRequest, grpc.services]);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (activeMethod == null || activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
if (activeMethod.clientStreaming && activeMethod.serverStreaming) {
await grpc.streaming.mutateAsync();
} else if (!activeMethod.clientStreaming && activeMethod.serverStreaming) {
await grpc.serverStreaming.mutateAsync();
} else if (activeMethod.clientStreaming && !activeMethod.serverStreaming) {
await grpc.clientStreaming.mutateAsync();
} else {
const msg = await grpc.unary.mutateAsync();
setActiveMessageId(msg.id);
}
},
[
activeMethod,
activeRequest,
alert,
grpc.streaming,
grpc.serverStreaming,
grpc.clientStreaming,
grpc.unary,
],
);
const services = grpc.reflect.data ?? null;
useEffect(() => {
if (grpc.services == null || activeRequest == null) return;
const s = grpc.services.find((s) => s.name === activeRequest.service);
if (services == null || activeRequest == null) return;
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) {
updateRequest.mutate({
service: grpc.services[0]?.name ?? null,
method: grpc.services[0]?.methods[0]?.name ?? null,
service: services[0]?.name ?? null,
method: services[0]?.methods[0]?.name ?? null,
});
return;
}
@@ -95,61 +41,30 @@ export function GrpcConnectionLayout({ style }: Props) {
updateRequest.mutate({ method: s.methods[0]?.name ?? null });
return;
}
}, [activeRequest, grpc.services, updateRequest]);
}, [activeRequest, services, updateRequest]);
const handleChangeService = useCallback(
async (v: string) => {
const [serviceName, methodName] = v.split('/', 2);
if (serviceName == null || methodName == null) throw new Error('Should never happen');
await updateRequest.mutateAsync({
service: serviceName,
method: methodName,
});
},
[updateRequest],
);
const activeMethod = useMemo(() => {
if (services == null || activeRequest == null) return null;
const handleChangeUrl = useCallback(
(url: string) => updateRequest.mutateAsync({ url }),
[updateRequest],
);
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) return null;
return s.methods.find((m) => m.name === activeRequest.method);
}, [activeRequest, services]);
const handleChangeMessage = useCallback(
(message: string) => updateRequest.mutateAsync({ message }),
[updateRequest],
);
const select = useMemo(() => {
const options =
grpc.services?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`;
return { value, options };
}, [activeRequest?.method, activeRequest?.service, grpc.services]);
const [paneSize, setPaneSize] = useState(99999);
const urlContainerEl = useRef<HTMLDivElement>(null);
useResizeObserver<HTMLDivElement>(urlContainerEl.current, (entry) => {
setPaneSize(entry.contentRect.width);
});
const activeMessage = useMemo(
() => messages.find((m) => m.id === activeMessageId) ?? null,
[activeMessageId, messages],
);
const messageType: 'unary' | 'server_streaming' | 'client_streaming' | 'streaming' =
useMemo(() => {
if (activeMethod == null) return 'unary'; // Good enough
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
if (activeMethod.clientStreaming) return 'client_streaming';
if (activeMethod.serverStreaming) return 'server_streaming';
return 'unary';
}, [activeMethod]);
const methodType:
| 'unary'
| 'server_streaming'
| 'client_streaming'
| 'streaming'
| 'no-schema'
| 'no-method' = useMemo(() => {
if (services == null) return 'no-schema';
if (activeMethod == null) return 'no-method';
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
if (activeMethod.clientStreaming) return 'client_streaming';
if (activeMethod.serverStreaming) return 'server_streaming';
return 'unary';
}, [activeMethod, services]);
if (activeRequest == null) {
return null;
@@ -160,110 +75,28 @@ export function GrpcConnectionLayout({ style }: Props) {
name="grpc_layout"
className="p-3 gap-1.5"
style={style}
firstSlot={() => (
<VStack space={2}>
<div
ref={urlContainerEl}
className={classNames(
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
paneSize < 400 && '!grid-cols-1',
)}
>
<UrlBar
url={activeRequest.url ?? ''}
method={null}
submitIcon={null}
forceUpdateKey={activeRequest?.id ?? ''}
placeholder="localhost:50051"
onSubmit={handleConnect}
isLoading={grpc.unary.isLoading}
onUrlChange={handleChangeUrl}
/>
<HStack space={1.5}>
<RadioDropdown
value={select.value}
items={select.options.map((o) => ({
label: o.label,
value: o.value,
type: 'default',
shortLabel: o.label,
}))}
onChange={handleChangeService}
>
<Button
size="sm"
className={classNames(
'border border-highlight font-mono text-xs text-gray-800',
paneSize < 400 && 'flex-1',
)}
rightSlot={<Icon className="text-gray-600" size="sm" icon="chevronDown" />}
>
{select.options.find((o) => o.value === select.value)?.label}
</Button>
</RadioDropdown>
{!grpc.isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title={messageType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction={grpc.isStreaming ? undefined : 'http_request.send'}
onClick={handleConnect}
icon={
grpc.isStreaming
? 'refresh'
: messageType === 'unary'
? 'sendHorizontal'
: 'arrowUpDown'
}
/>
)}
{grpc.isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={() => grpc.cancel.mutateAsync()}
icon="x"
disabled={!grpc.isStreaming}
/>
)}
{activeMethod?.clientStreaming &&
!activeMethod.serverStreaming &&
grpc.isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={() => grpc.commit.mutateAsync()}
icon="check"
/>
)}
{activeMethod?.clientStreaming && grpc.isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => grpc.send.mutateAsync({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
)}
</HStack>
</div>
<GrpcEditor
forceUpdateKey={activeRequest?.id ?? ''}
url={activeRequest.url ?? ''}
defaultValue={activeRequest.message}
onChange={handleChangeMessage}
service={activeRequest.service}
method={activeRequest.method}
className="bg-gray-50"
/>
</VStack>
firstSlot={({ style }) => (
<GrpcConnectionSetupPane
style={style}
activeRequest={activeRequest}
methodType={methodType}
onUnary={grpc.unary.mutate}
onServerStreaming={grpc.serverStreaming.mutate}
onClientStreaming={grpc.clientStreaming.mutate}
onStreaming={grpc.streaming.mutate}
onCommit={grpc.commit.mutate}
onCancel={grpc.cancel.mutate}
onSend={grpc.send.mutate}
onReflectRefetch={grpc.reflect.refetch}
services={services ?? null}
reflectionError={grpc.reflect.error as string | undefined}
reflectionLoading={grpc.reflect.isLoading}
/>
)}
secondSlot={() =>
secondSlot={({ style }) =>
!grpc.unary.isLoading && (
<div
style={style}
className={classNames(
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
@@ -275,102 +108,8 @@ export function GrpcConnectionLayout({ style }: Props) {
{grpc.unary.error}
</Banner>
) : messages.length >= 0 ? (
<SplitLayout
forceVertical
name={
!activeMethod?.clientStreaming && !activeMethod?.serverStreaming
? 'grpc_messages_unary'
: 'grpc_messages_streaming'
}
defaultRatio={
!activeMethod?.clientStreaming && !activeMethod?.serverStreaming ? 0.75 : 0.3
}
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>
{grpc.isStreaming && (
<Icon icon="refresh" size="sm" spin className="text-gray-600" />
)}
</HStack>
{activeConnection && (
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinned={() => {
// todo
}}
/>
)}
</HStack>
<div className="overflow-y-auto h-full">
{...messages.map((m) => (
<HStack
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',
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="w-full truncate text-gray-800 text-2xs">{m.message}</div>
<div className="text-gray-600 text-2xs">
{format(m.createdAt, 'HH:mm:ss')}
</div>
</HStack>
))}
</div>
</div>
)}
secondSlot={
!activeMessage
? null
: () => (
<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>
) : (
<JsonAttributeTree
attrValue={JSON.parse(activeMessage?.message ?? '{}')}
/>
)}
</div>
</div>
)
}
/>
<GrpcConnectionMessagesPane activeRequest={activeRequest} methodType={methodType} />
) : (
// ) : ? (
// <Editor
// readOnly
// className="bg-gray-50 dark:bg-gray-100"
// contentType="application/json"
// defaultValue={resp.message}
// forceUpdateKey={resp.id}
// />
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.toggle', 'urlBar.focus']} />
)}
</div>

View File

@@ -0,0 +1,109 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useMemo, useState } from 'react';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages';
import type { GrpcRequest } from '../lib/models';
import { Icon } from './core/Icon';
import { JsonAttributeTree } from './core/JsonAttributeTree';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
}
export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) {
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null);
const activeMessage = useMemo(
() => messages.find((m) => m.id === activeMessageId) ?? null,
[activeMessageId, messages],
);
return (
<SplitLayout
forceVertical
style={style}
name={methodType === 'unary' ? 'grpc_messages_unary' : 'grpc_messages_streaming'}
defaultRatio={methodType === 'unary' ? 0.75 : 0.3}
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 && (
<RecentConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinned={() => {
// todo
}}
/>
)}
</HStack>
<div className="overflow-y-auto h-full">
{...messages.map((m) => (
<HStack
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', 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="w-full truncate text-gray-800 text-2xs">{m.message}</div>
<div className="text-gray-600 text-2xs">{format(m.createdAt, 'HH:mm:ss')}</div>
</HStack>
))}
</div>
</div>
)}
secondSlot={
activeMessage &&
(() => (
<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>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeMessage?.message ?? '{}')} />
)}
</div>
</div>
))
}
/>
);
}

View File

@@ -0,0 +1,242 @@
import useResizeObserver from '@react-hook/resize-observer';
import classNames from 'classnames';
import type { CSSProperties, FormEvent } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import type { GrpcRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { RadioDropdown } from './core/RadioDropdown';
import { HStack, VStack } from './core/Stacks';
import { GrpcEditor } from './GrpcEditor';
import { UrlBar } from './UrlBar';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
reflectionError?: string;
reflectionLoading?: boolean;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
onUnary: () => void;
onCommit: () => void;
onCancel: () => void;
onSend: (v: { message: string }) => void;
onClientStreaming: () => void;
onServerStreaming: () => void;
onStreaming: () => void;
onReflectRefetch: () => void;
services: ReflectResponseService[] | null;
}
export function GrpcConnectionSetupPane({
style,
services,
methodType,
activeRequest,
reflectionError,
reflectionLoading,
onReflectRefetch,
onStreaming,
onClientStreaming,
onServerStreaming,
onCommit,
onCancel,
onSend,
onUnary,
}: Props) {
const connections = useGrpcConnections(activeRequest.id ?? null);
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null;
const isStreaming = activeConnection?.elapsed === 0;
const [paneSize, setPaneSize] = useState(99999);
const urlContainerEl = useRef<HTMLDivElement>(null);
useResizeObserver<HTMLDivElement>(urlContainerEl.current, (entry) => {
setPaneSize(entry.contentRect.width);
});
const handleChangeUrl = useCallback(
(url: string) => updateRequest.mutateAsync({ url }),
[updateRequest],
);
const handleChangeMessage = useCallback(
(message: string) => updateRequest.mutateAsync({ message }),
[updateRequest],
);
const select = useMemo(() => {
const options =
services?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`;
return { value, options };
}, [activeRequest?.method, activeRequest?.service, services]);
const handleChangeService = useCallback(
async (v: string) => {
const [serviceName, methodName] = v.split('/', 2);
if (serviceName == null || methodName == null) throw new Error('Should never happen');
await updateRequest.mutateAsync({
service: serviceName,
method: methodName,
});
},
[updateRequest],
);
const handleConnect = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: 'grpc-invalid-service-method',
title: 'Error',
body: 'Service or method not selected',
});
}
if (methodType === 'streaming') {
onStreaming();
} else if (methodType === 'server_streaming') {
onServerStreaming();
} else if (methodType === 'client_streaming') {
onClientStreaming();
} else {
onUnary();
}
},
[activeRequest, methodType, onStreaming, onServerStreaming, onClientStreaming, onUnary],
);
return (
<VStack space={2} style={style}>
<div
ref={urlContainerEl}
className={classNames(
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
paneSize < 400 && '!grid-cols-1',
)}
>
<UrlBar
url={activeRequest.url ?? ''}
method={null}
submitIcon={null}
forceUpdateKey={activeRequest?.id ?? ''}
placeholder="localhost:50051"
onSubmit={handleConnect}
onUrlChange={handleChangeUrl}
isLoading={false}
/>
<HStack space={1.5}>
<RadioDropdown
value={select.value}
onChange={handleChangeService}
items={select.options.map((o) => ({
label: o.label,
value: o.value,
type: 'default',
shortLabel: o.label,
}))}
extraItems={[
{
label: 'Custom',
type: 'default',
key: 'custom',
},
]}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon className="text-gray-600" size="sm" icon="chevronDown" />}
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-xs min-w-[5rem] !ring-0',
paneSize < 400 && 'flex-1',
)}
>
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
</Button>
</RadioDropdown>
{!isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction={isStreaming ? undefined : 'http_request.send'}
onClick={handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={
isStreaming
? 'refresh'
: methodType.includes('streaming')
? 'arrowUpDown'
: 'sendHorizontal'
}
/>
)}
{isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
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"
/>
)}
</HStack>
</div>
<GrpcEditor
forceUpdateKey={activeRequest?.id ?? ''}
url={activeRequest.url ?? ''}
defaultValue={activeRequest.message}
onChange={handleChangeMessage}
service={activeRequest.service}
services={services}
method={activeRequest.method}
className="bg-gray-50"
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
onReflect={onReflectRefetch}
/>
</VStack>
);
}

View File

@@ -1,15 +1,16 @@
import type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
import { FormattedError } from './core/FormattedError';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
type Props = Pick<
EditorProps,
@@ -18,17 +19,30 @@ type Props = Pick<
url: string;
service: string | null;
method: string | null;
services: ReflectResponseService[] | null;
reflectionError?: string;
reflectionLoading?: boolean;
onReflect: () => void;
};
export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorProps }: Props) {
export function GrpcEditor({
service,
method,
services,
defaultValue,
reflectionError,
reflectionLoading,
onReflect,
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const activeRequestId = useActiveRequestId();
const grpc = useGrpc(url, activeRequestId);
const alert = useAlert();
const dialog = useDialog();
useEffect(() => {
if (editorViewRef.current == null || grpc.services == null) return;
const s = grpc.services?.find((s) => s.name === service);
if (editorViewRef.current == null || services === null) return;
const s = services?.find((s) => s.name === service);
if (service != null && s == null) {
alert({
id: 'grpc-find-service-error',
@@ -79,7 +93,7 @@ export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorP
});
console.log('Failed to parse method schema', method, schema);
}
}, [alert, grpc.services, method, service]);
}, [alert, services, method, service]);
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
@@ -90,6 +104,46 @@ export function GrpcEditor({ url, service, method, defaultValue, ...extraEditorP
heightMode="auto"
placeholder="..."
ref={editorViewRef}
actions={
reflectionError || reflectionLoading
? [
<div key="introspection" className="!opacity-100">
<Button
key="introspection"
size="xs"
color={reflectionError ? 'danger' : 'gray'}
isLoading={reflectionLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
size: 'dynamic',
id: 'introspection-failed',
render: () => (
<>
<FormattedError>{reflectionError ?? 'unknown'}</FormattedError>
<HStack className="w-full my-4" space={2} justifyContent="end">
<Button color="gray">Select .proto</Button>
<Button
onClick={() => {
dialog.hide('introspection-failed');
onReflect();
}}
color="secondary"
>
Try Again
</Button>
</HStack>
</>
),
});
}}
>
{reflectionError ? 'Reflection Failed' : 'Reflecting'}
</Button>
</div>,
]
: []
}
{...extraEditorProps}
/>
</div>

View File

@@ -755,12 +755,14 @@ const SidebarItem = forwardRef(function SidebarItem(
)}
{latestGrpcConnection ? (
<div className="ml-auto">
{latestGrpcConnection.elapsed === 0 && <Icon spin size="sm" icon="update" />}
{latestGrpcConnection.elapsed === 0 && (
<Icon spin size="sm" icon="update" className="text-gray-400" />
)}
</div>
) : latestHttpResponse ? (
<div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? (
<Icon spin size="sm" icon="update" />
<Icon spin size="sm" icon="update" className="text-gray-400" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestHttpResponse} />
)}

View File

@@ -8,6 +8,7 @@ import { Icon } from './Icon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
innerClassName?: string;
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
@@ -27,10 +28,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
innerClassName,
children,
forDropdown,
color,
color = 'default',
type = 'button',
justify = 'center',
size = 'md',
variant = 'solid',
leftSlot,
rightSlot,
disabled,
@@ -53,24 +55,53 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
color === 'custom' && 'ring-blue-500/50',
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'primary' && 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
color === 'secondary' &&
'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
color === 'warning' &&
'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
variant === 'border' && 'border',
// Solids
variant === 'solid' && color === 'custom' && 'ring-blue-500/50',
variant === 'solid' &&
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
variant === 'solid' &&
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
variant === 'solid' &&
color === 'primary' &&
'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
variant === 'solid' &&
color === 'secondary' &&
'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
variant === 'solid' &&
color === 'warning' &&
'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
variant === 'solid' &&
color === 'danger' &&
'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
// Borders
variant === 'border' &&
color === 'default' &&
'border-highlight text-gray-700 enabled:hocus:border-focus enabled:hocus:text-gray-1000 ring-blue-500/50',
variant === 'border' &&
color === 'gray' &&
'border-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
variant === 'border' &&
color === 'primary' &&
'border-blue-500/70 text-blue-700 enabled:hocus:border-blue-500 ring-blue-500/50',
variant === 'border' &&
color === 'secondary' &&
'border-violet-500/70 text-violet-700 enabled:hocus:border-violet-500 ring-violet-500/50',
variant === 'border' &&
color === 'warning' &&
'border-orange-500/70 text-orange-700 enabled:hocus:border-orange-500 ring-orange-500/50',
variant === 'border' &&
color === 'danger' &&
'border-red-500/70 text-red-700 enabled:hocus:border-red-500 ring-red-500/50',
),
[className, disabled, color, justify, size],
[className, disabled, justify, size, variant, color],
);
const buttonRef = useRef<HTMLButtonElement>(null);

View File

@@ -1,7 +1,8 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: string;
children: ReactNode;
}
export function FormattedError({ children }: Props) {

View File

@@ -1,9 +1,10 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { emit } from '@tauri-apps/api/event';
import { useCallback } from 'react';
import type { GrpcConnection, GrpcMessage, GrpcRequest } from '../lib/models';
interface ReflectResponseService {
export interface ReflectResponseService {
name: string;
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
}
@@ -13,58 +14,46 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) {
const unary = useMutation<GrpcMessage, string>({
mutationKey: ['grpc_unary', conn?.id ?? 'n/a'],
mutationFn: async () => {
const message = (await invoke('cmd_grpc_call_unary', {
mutationFn: async () =>
(await invoke('cmd_grpc_call_unary', {
requestId,
})) as GrpcMessage;
return message;
},
})) as GrpcMessage,
});
const clientStreaming = useMutation<void, string>({
mutationKey: ['grpc_client_streaming', conn?.id ?? 'n/a'],
mutationFn: async () => {
await invoke('cmd_grpc_client_streaming', { requestId });
},
mutationFn: async () => await invoke('cmd_grpc_client_streaming', { requestId }),
});
const serverStreaming = useMutation<void, string>({
mutationKey: ['grpc_server_streaming', conn?.id ?? 'n/a'],
mutationFn: async () => {
await invoke('cmd_grpc_server_streaming', { requestId });
},
mutationFn: async () => await invoke('cmd_grpc_server_streaming', { requestId }),
});
const streaming = useMutation<void, string>({
mutationKey: ['grpc_streaming', conn?.id ?? 'n/a'],
mutationFn: async () => {
await invoke('cmd_grpc_streaming', { requestId });
},
mutationFn: async () => await invoke('cmd_grpc_streaming', { requestId }),
});
const send = useMutation({
mutationFn: async ({ message }: { message: string }) => {
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message });
},
mutationFn: async ({ message }: { message: string }) =>
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }),
});
const cancel = useMutation({
mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'],
mutationFn: async () => {
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel');
},
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'),
});
const commit = useMutation({
mutationKey: ['grpc_commit', conn?.id ?? 'n/a'],
mutationFn: async () => {
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit');
},
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'),
});
const reflect = useQuery<ReflectResponseService[]>({
queryKey: ['grpc_reflect', conn?.id ?? 'n/a'],
queryKey: ['grpc_reflect', req?.url ?? 'n/a'],
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return (await invoke('cmd_grpc_reflect', { requestId })) as ReflectResponseService[];
},
});
@@ -74,7 +63,7 @@ export function useGrpc(req: GrpcRequest | null, conn: GrpcConnection | null) {
clientStreaming,
serverStreaming,
streaming,
services: reflect.data,
reflect,
cancel,
commit,
isStreaming: conn?.elapsed === 0,