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

View File

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

View File

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

View File

@@ -86,10 +86,8 @@ export function GlobalHooks() {
queryClient.setQueryData<Model[]>(queryKey, (values = []) => { queryClient.setQueryData<Model[]>(queryKey, (values = []) => {
const index = values.findIndex((v) => modelsEq(v, payload)) ?? -1; const index = values.findIndex((v) => modelsEq(v, payload)) ?? -1;
if (index >= 0) { if (index >= 0) {
console.log('UPDATED MODEL', payload);
return [...values.slice(0, index), payload, ...values.slice(index + 1)]; return [...values.slice(0, index), payload, ...values.slice(index + 1)];
} else { } else {
console.log('INSERTED MODEL', payload);
return pushToFront ? [payload, ...(values ?? [])] : [...(values ?? []), 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 classNames from 'classnames';
import { format } from 'date-fns'; import type { CSSProperties } from 'react';
import type { CSSProperties, FormEvent } from 'react'; import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc'; import { useGrpc } from '../hooks/useGrpc';
import { useGrpcConnections } from '../hooks/useGrpcConnections'; import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages'; import { useGrpcMessages } from '../hooks/useGrpcMessages';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList'; 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 { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { GrpcConnectionMessagesPane } from './GrpcConnectionMessagesPane';
import { GrpcEditor } from './GrpcEditor'; import { GrpcConnectionSetupPane } from './GrpcConnectionSetupPane';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
import { UrlBar } from './UrlBar';
interface Props { interface Props {
style: CSSProperties; style: CSSProperties;
@@ -30,62 +19,19 @@ interface Props {
export function GrpcConnectionLayout({ style }: Props) { export function GrpcConnectionLayout({ style }: Props) {
const activeRequest = useActiveRequest('grpc_request'); const activeRequest = useActiveRequest('grpc_request');
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null); const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null);
const alert = useAlert();
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const connections = useGrpcConnections(activeRequest?.id ?? null); const connections = useGrpcConnections(activeRequest?.id ?? null);
const activeConnection = connections[0] ?? null; const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null); const messages = useGrpcMessages(activeConnection?.id ?? null);
const grpc = useGrpc(activeRequest, activeConnection); const grpc = useGrpc(activeRequest, activeConnection);
const activeMethod = useMemo(() => { const services = grpc.reflect.data ?? null;
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,
],
);
useEffect(() => { useEffect(() => {
if (grpc.services == null || activeRequest == null) return; if (services == null || activeRequest == null) return;
const s = grpc.services.find((s) => s.name === activeRequest.service); const s = services.find((s) => s.name === activeRequest.service);
if (s == null) { if (s == null) {
updateRequest.mutate({ updateRequest.mutate({
service: grpc.services[0]?.name ?? null, service: services[0]?.name ?? null,
method: grpc.services[0]?.methods[0]?.name ?? null, method: services[0]?.methods[0]?.name ?? null,
}); });
return; return;
} }
@@ -95,61 +41,30 @@ export function GrpcConnectionLayout({ style }: Props) {
updateRequest.mutate({ method: s.methods[0]?.name ?? null }); updateRequest.mutate({ method: s.methods[0]?.name ?? null });
return; return;
} }
}, [activeRequest, grpc.services, updateRequest]); }, [activeRequest, services, updateRequest]);
const handleChangeService = useCallback( const activeMethod = useMemo(() => {
async (v: string) => { if (services == null || activeRequest == null) return null;
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 handleChangeUrl = useCallback( const s = services.find((s) => s.name === activeRequest.service);
(url: string) => updateRequest.mutateAsync({ url }), if (s == null) return null;
[updateRequest], return s.methods.find((m) => m.name === activeRequest.method);
); }, [activeRequest, services]);
const handleChangeMessage = useCallback( const methodType:
(message: string) => updateRequest.mutateAsync({ message }), | 'unary'
[updateRequest], | 'server_streaming'
); | 'client_streaming'
| 'streaming'
const select = useMemo(() => { | 'no-schema'
const options = | 'no-method' = useMemo(() => {
grpc.services?.flatMap((s) => if (services == null) return 'no-schema';
s.methods.map((m) => ({ if (activeMethod == null) return 'no-method';
label: `${s.name.split('.', 2).pop() ?? s.name}/${m.name}`, if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
value: `${s.name}/${m.name}`, if (activeMethod.clientStreaming) return 'client_streaming';
})), if (activeMethod.serverStreaming) return 'server_streaming';
) ?? []; return 'unary';
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`; }, [activeMethod, services]);
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]);
if (activeRequest == null) { if (activeRequest == null) {
return null; return null;
@@ -160,110 +75,28 @@ export function GrpcConnectionLayout({ style }: Props) {
name="grpc_layout" name="grpc_layout"
className="p-3 gap-1.5" className="p-3 gap-1.5"
style={style} style={style}
firstSlot={() => ( firstSlot={({ style }) => (
<VStack space={2}> <GrpcConnectionSetupPane
<div style={style}
ref={urlContainerEl} activeRequest={activeRequest}
className={classNames( methodType={methodType}
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5', onUnary={grpc.unary.mutate}
paneSize < 400 && '!grid-cols-1', onServerStreaming={grpc.serverStreaming.mutate}
)} onClientStreaming={grpc.clientStreaming.mutate}
> onStreaming={grpc.streaming.mutate}
<UrlBar onCommit={grpc.commit.mutate}
url={activeRequest.url ?? ''} onCancel={grpc.cancel.mutate}
method={null} onSend={grpc.send.mutate}
submitIcon={null} onReflectRefetch={grpc.reflect.refetch}
forceUpdateKey={activeRequest?.id ?? ''} services={services ?? null}
placeholder="localhost:50051" reflectionError={grpc.reflect.error as string | undefined}
onSubmit={handleConnect} reflectionLoading={grpc.reflect.isLoading}
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>
)} )}
secondSlot={() => secondSlot={({ style }) =>
!grpc.unary.isLoading && ( !grpc.unary.isLoading && (
<div <div
style={style}
className={classNames( className={classNames(
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1', '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', 'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
@@ -275,102 +108,8 @@ export function GrpcConnectionLayout({ style }: Props) {
{grpc.unary.error} {grpc.unary.error}
</Banner> </Banner>
) : messages.length >= 0 ? ( ) : messages.length >= 0 ? (
<SplitLayout <GrpcConnectionMessagesPane activeRequest={activeRequest} methodType={methodType} />
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>
)
}
/>
) : ( ) : (
// ) : ? (
// <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']} /> <HotKeyList hotkeys={['grpc_request.send', 'sidebar.toggle', 'urlBar.focus']} />
)} )}
</div> </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 type { EditorView } from 'codemirror';
import { updateSchema } from 'codemirror-json-schema'; import { updateSchema } from 'codemirror-json-schema';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useAlert } from '../hooks/useAlert'; import { useAlert } from '../hooks/useAlert';
import { useGrpc } from '../hooks/useGrpc'; import type { ReflectResponseService } from '../hooks/useGrpc';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor'; import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor'; import { Editor } from './core/Editor';
import { FormattedError } from './core/FormattedError'; import { FormattedError } from './core/FormattedError';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
type Props = Pick< type Props = Pick<
EditorProps, EditorProps,
@@ -18,17 +19,30 @@ type Props = Pick<
url: string; url: string;
service: string | null; service: string | null;
method: 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 editorViewRef = useRef<EditorView>(null);
const activeRequestId = useActiveRequestId();
const grpc = useGrpc(url, activeRequestId);
const alert = useAlert(); const alert = useAlert();
const dialog = useDialog();
useEffect(() => { useEffect(() => {
if (editorViewRef.current == null || grpc.services == null) return; if (editorViewRef.current == null || services === null) return;
const s = grpc.services?.find((s) => s.name === service);
const s = services?.find((s) => s.name === service);
if (service != null && s == null) { if (service != null && s == null) {
alert({ alert({
id: 'grpc-find-service-error', 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); console.log('Failed to parse method schema', method, schema);
} }
}, [alert, grpc.services, method, service]); }, [alert, services, method, service]);
return ( return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]"> <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" heightMode="auto"
placeholder="..." placeholder="..."
ref={editorViewRef} 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} {...extraEditorProps}
/> />
</div> </div>

View File

@@ -755,12 +755,14 @@ const SidebarItem = forwardRef(function SidebarItem(
)} )}
{latestGrpcConnection ? ( {latestGrpcConnection ? (
<div className="ml-auto"> <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> </div>
) : latestHttpResponse ? ( ) : latestHttpResponse ? (
<div className="ml-auto"> <div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? ( {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} /> <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'> & { export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
innerClassName?: string; innerClassName?: string;
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger'; color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
variant?: 'border' | 'solid';
isLoading?: boolean; isLoading?: boolean;
size?: 'sm' | 'md' | 'xs'; size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center'; justify?: 'start' | 'center';
@@ -27,10 +28,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
innerClassName, innerClassName,
children, children,
forDropdown, forDropdown,
color, color = 'default',
type = 'button', type = 'button',
justify = 'center', justify = 'center',
size = 'md', size = 'md',
variant = 'solid',
leftSlot, leftSlot,
rightSlot, rightSlot,
disabled, disabled,
@@ -53,24 +55,53 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'flex-shrink-0 flex items-center', 'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md', 'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto', 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 === 'start' && 'justify-start',
justify === 'center' && 'justify-center', justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3', size === 'md' && 'h-md px-3',
size === 'sm' && 'h-sm px-2.5 text-sm', size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 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); const buttonRef = useRef<HTMLButtonElement>(null);

View File

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

View File

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