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

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, request_id, connection_id, created_at, message,\n is_server, is_info\n FROM grpc_messages\n WHERE connection_id = ?\n ", "query": "\n SELECT\n id, model, workspace_id, request_id, connection_id, created_at, content,\n event_type AS \"event_type!: GrpcEventType\",\n metadata AS \"metadata!: sqlx::types::Json<HashMap<String, String>>\"\n FROM grpc_events\n WHERE id = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -34,19 +34,19 @@
"type_info": "Datetime" "type_info": "Datetime"
}, },
{ {
"name": "message", "name": "content",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "is_server", "name": "event_type!: GrpcEventType",
"ordinal": 7, "ordinal": 7,
"type_info": "Bool" "type_info": "Text"
}, },
{ {
"name": "is_info", "name": "metadata!: sqlx::types::Json<HashMap<String, String>>",
"ordinal": 8, "ordinal": 8,
"type_info": "Bool" "type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -64,5 +64,5 @@
false false
] ]
}, },
"hash": "196ed792c8d96425d428cb9609b0c1b18e8f1ba3c1fdfb38c91ffd7bada97f59" "hash": "20d6b878bb8d16bde3e78e22cf801b5b191905d867091bb54a210256a0145a17"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_events (\n id, workspace_id, request_id, connection_id, content, event_type, metadata\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n content = excluded.content,\n event_type = excluded.event_type,\n metadata = excluded.metadata\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "3dce053aef78e831db2369f3c49e891cb8a9e1ba6e7a60fe9e24292a3f97dca3"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, request_id, created_at, updated_at, service,\n method, elapsed\n FROM grpc_connections\n WHERE request_id = ?\n ORDER BY created_at DESC\n ", "query": "\n SELECT\n id, model, workspace_id, request_id, created_at, updated_at, service,\n method, elapsed, status, error, url,\n trailers AS \"trailers!: sqlx::types::Json<HashMap<String, String>>\"\n FROM grpc_connections\n WHERE request_id = ?\n ORDER BY created_at DESC\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -47,6 +47,26 @@
"name": "elapsed", "name": "elapsed",
"ordinal": 8, "ordinal": 8,
"type_info": "Int64" "type_info": "Int64"
},
{
"name": "status",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "trailers!: sqlx::types::Json<HashMap<String, String>>",
"ordinal": 12,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -61,8 +81,12 @@
false, false,
false, false,
false, false,
false,
false,
true,
false,
false false
] ]
}, },
"hash": "80a85f83d0946d532a60f0add87aa0ade7e35a6b56cb058e2caf9ca005ce6407" "hash": "3e8651cca7feecc208a676dfd24c7d8775040d5287c16890056dcb474674edfb"
} }

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_messages (\n id, workspace_id, request_id, connection_id, message, is_server, is_info\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n message = excluded.message,\n is_server = excluded.is_server,\n is_info = excluded.is_info\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "4b45b681698cbfe8531a7c3ba368a1d8003fa17d5585bc126debb18cae670460"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_connections (\n id, workspace_id, request_id, service, method, elapsed,\n status, error, trailers, url\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n service = excluded.service,\n method = excluded.method,\n elapsed = excluded.elapsed,\n status = excluded.status,\n error = excluded.error,\n trailers = excluded.trailers,\n url = excluded.url\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "66deed028199c78ed15ea2f837907887c2a2cb564d1d076dd4ebf0ecbc82e098"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, request_id, connection_id, created_at, message,\n is_server, is_info\n FROM grpc_messages\n WHERE id = ?\n ", "query": "\n SELECT\n id, model, workspace_id, request_id, connection_id, created_at, content,\n event_type AS \"event_type!: GrpcEventType\",\n metadata AS \"metadata!: sqlx::types::Json<HashMap<String, String>>\"\n FROM grpc_events\n WHERE connection_id = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -34,19 +34,19 @@
"type_info": "Datetime" "type_info": "Datetime"
}, },
{ {
"name": "message", "name": "content",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "is_server", "name": "event_type!: GrpcEventType",
"ordinal": 7, "ordinal": 7,
"type_info": "Bool" "type_info": "Text"
}, },
{ {
"name": "is_info", "name": "metadata!: sqlx::types::Json<HashMap<String, String>>",
"ordinal": 8, "ordinal": 8,
"type_info": "Bool" "type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -64,5 +64,5 @@
false false
] ]
}, },
"hash": "3c52c0fa3372cdd2657a775c3b93fb65f42d3226cec27220469558e14973328c" "hash": "737045ddd5f8ba3454425e82b9d3943f93649742d8f78613e01d322745e47ebd"
} }

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_connections (\n id, workspace_id, request_id, service, method, elapsed\n )\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n service = excluded.service,\n method = excluded.method,\n elapsed = excluded.elapsed\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "9d7bc2b0eb0c09652d9826db4a7ae47591405e1b5bec1229f2e2734c73e66163"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n id, model, workspace_id, request_id, created_at, updated_at, service,\n method, elapsed\n FROM grpc_connections\n WHERE id = ?\n ", "query": "\n SELECT\n id, model, workspace_id, request_id, created_at, updated_at, service,\n method, elapsed, status, error, url,\n trailers AS \"trailers!: sqlx::types::Json<HashMap<String, String>>\"\n FROM grpc_connections\n WHERE id = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -47,6 +47,26 @@
"name": "elapsed", "name": "elapsed",
"ordinal": 8, "ordinal": 8,
"type_info": "Int64" "type_info": "Int64"
},
{
"name": "status",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "error",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "trailers!: sqlx::types::Json<HashMap<String, String>>",
"ordinal": 12,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -61,8 +81,12 @@
false, false,
false, false,
false, false,
false,
false,
true,
false,
false false
] ]
}, },
"hash": "3330be44d8851f8e3456c403b5d1067f4e70e85ef8829b7aaad5b1993c3d01e8" "hash": "d4b64c466624eb75e0f5bd201ebfb6a73d25eb7c9e09cb9690afdb7fef5fca8b"
} }

View File

@@ -1,11 +1,15 @@
use prost_reflect::{DynamicMessage, SerializeOptions}; use prost_reflect::{DynamicMessage, MethodDescriptor, SerializeOptions};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Deserializer;
mod codec; mod codec;
mod json_schema; mod json_schema;
pub mod manager; pub mod manager;
mod proto; mod proto;
pub use tonic::metadata::*;
pub use tonic::Code;
pub fn serialize_options() -> SerializeOptions { pub fn serialize_options() -> SerializeOptions {
SerializeOptions::new().skip_default_fields(false) SerializeOptions::new().skip_default_fields(false)
} }
@@ -38,3 +42,11 @@ pub fn serialize_message(msg: &DynamicMessage) -> Result<String, String> {
let s = String::from_utf8(buf).expect("serde_json to emit valid utf8"); let s = String::from_utf8(buf).expect("serde_json to emit valid utf8");
Ok(s) Ok(s)
} }
pub fn deserialize_message(msg: &str, method: MethodDescriptor) -> Result<DynamicMessage, String> {
let mut deserializer = Deserializer::from_str(&msg);
let req_message = DynamicMessage::deserialize(method.input(), &mut deserializer)
.map_err(|e| e.to_string())?;
deserializer.end().map_err(|e| e.to_string())?;
Ok(req_message)
}

View File

@@ -9,7 +9,6 @@ pub use prost_reflect::DynamicMessage;
use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor}; use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor};
use serde_json::Deserializer; use serde_json::Deserializer;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::StreamExt;
use tonic::body::BoxBody; use tonic::body::BoxBody;
use tonic::metadata::{MetadataKey, MetadataValue}; use tonic::metadata::{MetadataKey, MetadataValue};
use tonic::transport::Uri; use tonic::transport::Uri;
@@ -50,7 +49,7 @@ impl GrpcConnection {
method: &str, method: &str,
message: &str, message: &str,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
) -> Result<DynamicMessage, String> { ) -> Result<Response<DynamicMessage>, String> {
let method = &self.method(&service, &method)?; let method = &self.method(&service, &method)?;
let input_message = method.input(); let input_message = method.input();
@@ -68,34 +67,23 @@ impl GrpcConnection {
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
client.ready().await.unwrap(); client.ready().await.unwrap();
Ok(client client
.unary(req, path, codec) .unary(req, path, codec)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())
.into_inner())
} }
pub async fn streaming( pub async fn streaming(
&self, &self,
service: &str, service: &str,
method: &str, method: &str,
stream: ReceiverStream<String>, stream: ReceiverStream<DynamicMessage>,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
) -> Result<Result<Response<Streaming<DynamicMessage>>, Status>, String> { ) -> Result<Result<Response<Streaming<DynamicMessage>>, Status>, String> {
let method = &self.method(&service, &method)?; let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let method2 = method.clone(); let mut req = stream.into_streaming_request();
let mut req = stream
.map(move |s| {
let mut deserializer = Deserializer::from_str(&s);
let req_message = DynamicMessage::deserialize(method2.input(), &mut deserializer)
.map_err(|e| e.to_string())
.unwrap();
deserializer.end().unwrap();
req_message
})
.into_streaming_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?; decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
@@ -109,37 +97,21 @@ impl GrpcConnection {
&self, &self,
service: &str, service: &str,
method: &str, method: &str,
stream: ReceiverStream<String>, stream: ReceiverStream<DynamicMessage>,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
) -> Result<DynamicMessage, String> { ) -> Result<Response<DynamicMessage>, String> {
let method = &self.method(&service, &method)?; let method = &self.method(&service, &method)?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = stream.into_streaming_request();
let mut req = {
let method = method.clone();
stream
.map(move |s| {
let mut deserializer = Deserializer::from_str(&s);
let req_message =
DynamicMessage::deserialize(method.input(), &mut deserializer)
.map_err(|e| e.to_string())
.unwrap();
deserializer.end().unwrap();
req_message
})
.into_streaming_request()
};
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?; decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
client.ready().await.unwrap(); client.ready().await.unwrap();
Ok(client client
.client_streaming(req, path, codec) .client_streaming(req, path, codec)
.await .await
.map_err(|s| s.to_string())? .map_err(|s| s.to_string())
.into_inner())
} }
pub async fn server_streaming( pub async fn server_streaming(
@@ -230,54 +202,6 @@ impl GrpcHandle {
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
pub async fn server_streaming(
&mut self,
id: &str,
uri: Uri,
proto_files: Vec<PathBuf>,
service: &str,
method: &str,
message: &str,
metadata: HashMap<String, String>,
) -> Result<Result<Response<Streaming<DynamicMessage>>, Status>, String> {
self.connect(id, uri, proto_files)
.await?
.server_streaming(service, method, message, metadata)
.await
}
pub async fn client_streaming(
&mut self,
id: &str,
uri: Uri,
proto_files: Vec<PathBuf>,
service: &str,
method: &str,
stream: ReceiverStream<String>,
metadata: HashMap<String, String>,
) -> Result<DynamicMessage, String> {
self.connect(id, uri, proto_files)
.await?
.client_streaming(service, method, stream, metadata)
.await
}
pub async fn streaming(
&mut self,
id: &str,
uri: Uri,
proto_files: Vec<PathBuf>,
service: &str,
method: &str,
stream: ReceiverStream<String>,
metadata: HashMap<String, String>,
) -> Result<Result<Response<Streaming<DynamicMessage>>, Status>, String> {
self.connect(id, uri, proto_files)
.await?
.streaming(service, method, stream, metadata)
.await
}
pub async fn connect( pub async fn connect(
&mut self, &mut self,
id: &str, id: &str,

View File

@@ -1,63 +1,67 @@
CREATE TABLE grpc_requests CREATE TABLE grpc_requests
( (
id TEXT NOT NULL id TEXT NOT NULL
PRIMARY KEY, PRIMARY KEY,
model TEXT DEFAULT 'grpc_request' NOT NULL, model TEXT DEFAULT 'grpc_request' NOT NULL,
workspace_id TEXT NOT NULL workspace_id TEXT NOT NULL
REFERENCES workspaces REFERENCES workspaces
ON DELETE CASCADE, ON DELETE CASCADE,
folder_id TEXT NULL folder_id TEXT NULL
REFERENCES folders REFERENCES folders
ON DELETE CASCADE, ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
sort_priority REAL NOT NULL, sort_priority REAL NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
service TEXT NULL, service TEXT NULL,
method TEXT NULL, method TEXT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
proto_files TEXT DEFAULT '[]' NOT NULL, proto_files TEXT DEFAULT '[]' NOT NULL,
authentication TEXT DEFAULT '{}' NOT NULL, authentication TEXT DEFAULT '{}' NOT NULL,
authentication_type TEXT NULL, authentication_type TEXT NULL,
metadata TEXT DEFAULT '[]' NOT NULL metadata TEXT DEFAULT '[]' NOT NULL
); );
CREATE TABLE grpc_connections CREATE TABLE grpc_connections
( (
id TEXT NOT NULL id TEXT NOT NULL
PRIMARY KEY, PRIMARY KEY,
model TEXT DEFAULT 'grpc_connection' NOT NULL, model TEXT DEFAULT 'grpc_connection' NOT NULL,
workspace_id TEXT NOT NULL workspace_id TEXT NOT NULL
REFERENCES workspaces REFERENCES workspaces
ON DELETE CASCADE, ON DELETE CASCADE,
request_id TEXT NOT NULL request_id TEXT NOT NULL
REFERENCES grpc_requests REFERENCES grpc_requests
ON DELETE CASCADE, ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
service TEXT NOT NULL, url TEXT NOT NULL,
method TEXT NOT NULL, service TEXT NOT NULL,
elapsed INTEGER NOT NULL method TEXT NOT NULL,
status INTEGER DEFAULT -1 NOT NULL,
error TEXT NULL,
elapsed INTEGER DEFAULT 0 NOT NULL,
trailers TEXT DEFAULT '{}' NOT NULL
); );
CREATE TABLE grpc_messages CREATE TABLE grpc_events
( (
id TEXT NOT NULL id TEXT NOT NULL
PRIMARY KEY, PRIMARY KEY,
model TEXT DEFAULT 'grpc_message' NOT NULL, model TEXT DEFAULT 'grpc_event' NOT NULL,
workspace_id TEXT NOT NULL workspace_id TEXT NOT NULL
REFERENCES workspaces REFERENCES workspaces
ON DELETE CASCADE, ON DELETE CASCADE,
request_id TEXT NOT NULL request_id TEXT NOT NULL
REFERENCES grpc_requests REFERENCES grpc_requests
ON DELETE CASCADE, ON DELETE CASCADE,
connection_id TEXT NOT NULL connection_id TEXT NOT NULL
REFERENCES grpc_connections REFERENCES grpc_connections
ON DELETE CASCADE, ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
is_server BOOLEAN NOT NULL, metadata TEXT DEFAULT '{}' NOT NULL,
is_info BOOLEAN NOT NULL, event_type TEXT NOT NULL,
message TEXT NOT NULL content TEXT NOT NULL
); );

View File

@@ -15,7 +15,7 @@ pub enum AnalyticsResource {
Environment, Environment,
Folder, Folder,
GrpcConnection, GrpcConnection,
GrpcMessage, GrpcEvent,
GrpcRequest, GrpcRequest,
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
@@ -33,7 +33,7 @@ impl AnalyticsResource {
"Environment" => Some(AnalyticsResource::Environment), "Environment" => Some(AnalyticsResource::Environment),
"Folder" => Some(AnalyticsResource::Folder), "Folder" => Some(AnalyticsResource::Folder),
"GrpcConnection" => Some(AnalyticsResource::GrpcConnection), "GrpcConnection" => Some(AnalyticsResource::GrpcConnection),
"GrpcMessage" => Some(AnalyticsResource::GrpcMessage), "GrpcEvent" => Some(AnalyticsResource::GrpcEvent),
"GrpcRequest" => Some(AnalyticsResource::GrpcRequest), "GrpcRequest" => Some(AnalyticsResource::GrpcRequest),
"HttpRequest" => Some(AnalyticsResource::HttpRequest), "HttpRequest" => Some(AnalyticsResource::HttpRequest),
"HttpResponse" => Some(AnalyticsResource::HttpResponse), "HttpResponse" => Some(AnalyticsResource::HttpResponse),
@@ -96,7 +96,7 @@ fn resource_name(resource: AnalyticsResource) -> &'static str {
AnalyticsResource::Folder => "folder", AnalyticsResource::Folder => "folder",
AnalyticsResource::GrpcRequest => "grpc_request", AnalyticsResource::GrpcRequest => "grpc_request",
AnalyticsResource::GrpcConnection => "grpc_connection", AnalyticsResource::GrpcConnection => "grpc_connection",
AnalyticsResource::GrpcMessage => "grpc_message", AnalyticsResource::GrpcEvent => "grpc_event",
AnalyticsResource::HttpRequest => "http_request", AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response", AnalyticsResource::HttpResponse => "http_response",
AnalyticsResource::KeyValue => "key_value", AnalyticsResource::KeyValue => "key_value",

16
src-tauri/src/grpc.rs Normal file
View File

@@ -0,0 +1,16 @@
use std::collections::HashMap;
use KeyAndValueRef::{Ascii, Binary};
use grpc::{KeyAndValueRef, MetadataMap};
pub fn metadata_to_map(metadata: MetadataMap) -> HashMap<String, String> {
let mut entries = HashMap::new();
for r in metadata.iter() {
match r {
Ascii(k, v) => entries.insert(k.to_string(), v.to_str().unwrap().to_string()),
Binary(k, v) => entries.insert(k.to_string(), format!("{:?}", v)),
};
}
entries
}

View File

@@ -35,31 +35,33 @@ use tokio::sync::Mutex;
use tokio::time::sleep; use tokio::time::sleep;
use window_shadows::set_shadow; use window_shadows::set_shadow;
use grpc::manager::GrpcHandle; use ::grpc::manager::{DynamicMessage, GrpcHandle};
use grpc::{serialize_message, ServiceDefinition}; use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use window_ext::TrafficLightWindowExt; use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource}; use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::grpc::metadata_to_map;
use crate::http::send_http_request; use crate::http::send_http_request;
use crate::models::{ use crate::models::{
cancel_pending_grpc_connections, cancel_pending_responses, create_http_response, cancel_pending_grpc_connections, cancel_pending_responses, create_http_response,
delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment,
delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request,
delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request, delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request,
get_cookie_jar, get_environment, get_folder, get_grpc_request, get_http_request, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request,
get_http_response, get_key_value_raw, get_or_create_settings, get_workspace, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace,
get_workspace_export_resources, list_cookie_jars, list_environments, list_folders, get_workspace_export_resources, list_cookie_jars, list_environments, list_folders,
list_grpc_connections, list_grpc_messages, list_grpc_requests, list_requests, list_responses, list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses,
list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar, list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar,
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_message, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event,
upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar, Environment, upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar, Environment,
EnvironmentVariable, Folder, GrpcConnection, GrpcMessage, GrpcRequest, HttpRequest, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest,
HttpResponse, KeyValue, Settings, Workspace, HttpRequest, HttpResponse, KeyValue, Settings, Workspace,
}; };
use crate::plugin::{ImportResources, ImportResult}; use crate::plugin::{ImportResources, ImportResult};
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater}; use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
mod analytics; mod analytics;
mod grpc;
mod http; mod http;
mod models; mod models;
mod plugin; mod plugin;
@@ -143,137 +145,6 @@ async fn cmd_grpc_go(
let workspace = get_workspace(&w, &req.workspace_id) let workspace = get_workspace(&w, &req.workspace_id)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let conn = {
let req = req.clone();
upsert_grpc_connection(
&w,
&GrpcConnection {
workspace_id: req.workspace_id,
request_id: req.id,
..Default::default()
},
)
.await
.map_err(|e| e.to_string())?
};
let base_msg = GrpcMessage {
workspace_id: req.clone().workspace_id,
request_id: req.clone().id,
connection_id: conn.clone().id,
..Default::default()
};
upsert_grpc_message(
&w,
&GrpcMessage {
message: "Initiating connection".to_string(),
is_info: true,
..base_msg.clone()
},
)
.await
.expect("Failed to upsert message");
let (in_msg_tx, in_msg_rx) = tauri::async_runtime::channel::<String>(16);
let maybe_in_msg_tx = std::sync::Mutex::new(Some(in_msg_tx.clone()));
let (cancelled_tx, mut cancelled_rx) = tokio::sync::watch::channel(false);
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
let in_msg_stream = tokio_stream::wrappers::ReceiverStream::new(in_msg_rx);
let (service, method) = {
let req = req.clone();
match (req.service, req.method) {
(Some(service), Some(method)) => (service, method),
_ => return Err("Service and method are required".to_string()),
}
};
let start = std::time::Instant::now();
let connection = grpc_handle
.lock()
.await
.connect(
&req.clone().id,
uri,
req.proto_files
.0
.iter()
.map(|p| PathBuf::from_str(p).unwrap())
.collect(),
)
.await?;
let method_desc = connection
.method(&service, &method)
.expect("Service not found");
#[derive(serde::Deserialize)]
enum IncomingMsg {
Message(String),
Cancel,
Commit,
}
let cb = {
let cancelled_rx = cancelled_rx.clone();
let environment = environment.clone();
let workspace = workspace.clone();
let w = w.clone();
let base_msg = base_msg.clone();
move |ev: tauri::Event| {
if *cancelled_rx.borrow() {
// Stream is cancelled
return;
}
let mut maybe_in_msg_tx = maybe_in_msg_tx
.lock()
.expect("previous holder not to panic");
let in_msg_tx = if let Some(in_msg_tx) = maybe_in_msg_tx.as_ref() {
in_msg_tx
} else {
// This would mean that the stream is already committed because
// we have already dropped the sending half
return;
};
match serde_json::from_str::<IncomingMsg>(ev.payload().unwrap()) {
Ok(IncomingMsg::Message(raw_msg)) => {
in_msg_tx.try_send(raw_msg.clone()).unwrap();
let w = w.clone();
let base_msg = base_msg.clone();
let environment_ref = environment.as_ref();
let msg = render::render(raw_msg.as_str(), &workspace, environment_ref);
tauri::async_runtime::spawn(async move {
upsert_grpc_message(
&w,
&GrpcMessage {
message: msg,
..base_msg.clone()
},
)
.await
.map_err(|e| e.to_string())
.unwrap();
});
}
Ok(IncomingMsg::Commit) => {
maybe_in_msg_tx.take();
}
Ok(IncomingMsg::Cancel) => {
cancelled_tx.send_replace(true);
}
Err(e) => {
error!("Failed to parse gRPC message: {:?}", e);
}
}
}
};
let event_handler = w.listen_global(format!("grpc_client_msg_{}", conn.id).as_str(), cb);
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
// Add rest of metadata // Add rest of metadata
@@ -322,7 +193,150 @@ async fn cmd_grpc_go(
} }
} }
println!("METADATA: {:?}", metadata); let conn = {
let req = req.clone();
upsert_grpc_connection(
&w,
&GrpcConnection {
workspace_id: req.workspace_id,
request_id: req.id,
status: -1,
url: req.url.clone(),
..Default::default()
},
)
.await
.map_err(|e| e.to_string())?
};
let conn_id = conn.id.clone();
let base_msg = GrpcEvent {
workspace_id: req.clone().workspace_id,
request_id: req.clone().id,
connection_id: conn.clone().id,
..Default::default()
};
let (in_msg_tx, in_msg_rx) = tauri::async_runtime::channel::<DynamicMessage>(16);
let maybe_in_msg_tx = std::sync::Mutex::new(Some(in_msg_tx.clone()));
let (cancelled_tx, mut cancelled_rx) = tokio::sync::watch::channel(false);
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
let in_msg_stream = tokio_stream::wrappers::ReceiverStream::new(in_msg_rx);
let (service, method) = {
let req = req.clone();
match (req.service, req.method) {
(Some(service), Some(method)) => (service, method),
_ => return Err("Service and method are required".to_string()),
}
};
let start = std::time::Instant::now();
let connection = grpc_handle
.lock()
.await
.connect(
&req.clone().id,
uri,
req.proto_files
.0
.iter()
.map(|p| PathBuf::from_str(p).unwrap())
.collect(),
)
.await?;
let method_desc = connection
.method(&service, &method)
.expect("Service not found");
#[derive(serde::Deserialize)]
enum IncomingMsg {
Message(String),
Cancel,
Commit,
}
let cb = {
let cancelled_rx = cancelled_rx.clone();
let environment = environment.clone();
let workspace = workspace.clone();
let w = w.clone();
let base_msg = base_msg.clone();
let method_desc = method_desc.clone();
move |ev: tauri::Event| {
if *cancelled_rx.borrow() {
// Stream is cancelled
return;
}
let mut maybe_in_msg_tx = maybe_in_msg_tx
.lock()
.expect("previous holder not to panic");
let in_msg_tx = if let Some(in_msg_tx) = maybe_in_msg_tx.as_ref() {
in_msg_tx
} else {
// This would mean that the stream is already committed because
// we have already dropped the sending half
return;
};
match serde_json::from_str::<IncomingMsg>(ev.payload().unwrap()) {
Ok(IncomingMsg::Message(raw_msg)) => {
let w = w.clone();
let base_msg = base_msg.clone();
let environment_ref = environment.as_ref();
let method_desc = method_desc.clone();
let msg = render::render(raw_msg.as_str(), &workspace, environment_ref);
let d_msg: DynamicMessage = match deserialize_message(msg.as_str(), method_desc)
{
Ok(d_msg) => d_msg,
Err(e) => {
tauri::async_runtime::spawn(async move {
upsert_grpc_event(
&w,
&GrpcEvent {
event_type: GrpcEventType::Error,
content: e.to_string(),
..base_msg.clone()
},
)
.await
.unwrap();
});
return;
}
};
in_msg_tx.try_send(d_msg).unwrap();
tauri::async_runtime::spawn(async move {
upsert_grpc_event(
&w,
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_msg.clone()
},
)
.await
.unwrap();
});
}
Ok(IncomingMsg::Commit) => {
maybe_in_msg_tx.take();
}
Ok(IncomingMsg::Cancel) => {
cancelled_tx.send_replace(true);
}
Err(e) => {
error!("Failed to parse gRPC message: {:?}", e);
}
}
}
};
let event_handler = w.listen_global(format!("grpc_client_msg_{}", conn.id).as_str(), cb);
let grpc_listen = { let grpc_listen = {
let w = w.clone(); let w = w.clone();
@@ -330,7 +344,26 @@ async fn cmd_grpc_go(
let req = req.clone(); let req = req.clone();
let workspace = workspace.clone(); let workspace = workspace.clone();
let environment = environment.clone(); let environment = environment.clone();
let msg = render::render(&req.message, &workspace, environment.as_ref()); let raw_msg = if req.message.is_empty() {
"{}".to_string()
} else {
req.message
};
let msg = render::render(&raw_msg, &workspace, environment.as_ref());
let conn_id = conn_id.clone();
upsert_grpc_event(
&w,
&GrpcEvent {
content: format!("Connecting to {}", req.url),
event_type: GrpcEventType::Info,
metadata: Json(metadata.clone()),
..base_msg.clone()
},
)
.await
.unwrap();
async move { async move {
let (maybe_stream, maybe_msg) = match ( let (maybe_stream, maybe_msg) = match (
method_desc.is_client_streaming(), method_desc.is_client_streaming(),
@@ -366,54 +399,104 @@ async fn cmd_grpc_go(
), ),
}; };
if !method_desc.is_client_streaming() {
upsert_grpc_event(
&w,
&GrpcEvent {
event_type: GrpcEventType::ClientMessage,
content: msg,
..base_msg.clone()
},
)
.await
.unwrap();
}
match maybe_msg { match maybe_msg {
Some(Ok(msg)) => { Some(Ok(msg)) => {
println!("Message: {:?}", msg); upsert_grpc_event(
upsert_grpc_message(
&w, &w,
&GrpcMessage { &GrpcEvent {
message: serialize_message(&msg).unwrap(), metadata: Json(metadata_to_map(msg.metadata().clone())),
is_server: true, content: if msg.metadata().len() == 0 {
"Connection established"
} else {
"Received metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_msg.clone() ..base_msg.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
upsert_grpc_event(
&w,
&GrpcEvent {
content: serialize_message(&msg.into_inner()).unwrap(),
event_type: GrpcEventType::ServerMessage,
..base_msg.clone()
},
)
.await
.unwrap();
upsert_grpc_connection(
&w,
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i64,
status: Code::Ok as i64,
..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
},
)
.await
.unwrap();
} }
Some(Err(e)) => { Some(Err(e)) => {
// TODO: Make into error upsert_grpc_connection(
println!("Error connecting: {:?}", e);
upsert_grpc_message(
&w, &w,
&GrpcMessage { &GrpcConnection {
message: e.to_string(), error: Some(e.to_string()),
is_server: true, elapsed: start.elapsed().as_millis() as i64,
is_info: true, status: Code::Unknown as i64,
..base_msg.clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
} }
None => {} None => {
// Server streaming doesn't return initial message
}
} }
let mut stream = match maybe_stream { let mut stream = match maybe_stream {
Some(Ok(Ok(s))) => { Some(Ok(Ok(s))) => {
// TODO: Store metadata on... connection? Or in a message upsert_grpc_event(
println!("METADATA: {:?}", s.metadata()); &w,
&GrpcEvent {
metadata: Json(metadata_to_map(s.metadata().clone())),
content: if s.metadata().len() == 0 {
"Connection established"
} else {
"Received metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_msg.clone()
},
)
.await
.unwrap();
s.into_inner() s.into_inner()
} }
Some(Ok(Err(e))) => { Some(Ok(Err(e))) => {
// TODO: Make into error, and use status upsert_grpc_connection(
println!("Connection status error: {:?}", e);
upsert_grpc_message(
&w, &w,
&GrpcMessage { &GrpcConnection {
message: e.message().to_string(), error: Some(e.message().to_string()),
is_server: true, status: e.code() as i64,
is_info: true, elapsed: start.elapsed().as_millis() as i64,
..base_msg.clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
.await .await
@@ -421,15 +504,13 @@ async fn cmd_grpc_go(
return; return;
} }
Some(Err(e)) => { Some(Err(e)) => {
// TODO: Make into error upsert_grpc_connection(
println!("Generic error: {:?}", e);
upsert_grpc_message(
&w, &w,
&GrpcMessage { &GrpcConnection {
message: e.to_string(), error: Some(e),
is_server: true, status: Code::Unknown as i64,
is_info: true, elapsed: start.elapsed().as_millis() as i64,
..base_msg.clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
.await .await
@@ -443,11 +524,11 @@ async fn cmd_grpc_go(
match stream.message().await { match stream.message().await {
Ok(Some(msg)) => { Ok(Some(msg)) => {
let message = serialize_message(&msg).unwrap(); let message = serialize_message(&msg).unwrap();
upsert_grpc_message( upsert_grpc_event(
&w, &w,
&GrpcMessage { &GrpcEvent {
message, content: message,
is_server: true, event_type: GrpcEventType::ServerMessage,
..base_msg.clone() ..base_msg.clone()
}, },
) )
@@ -455,16 +536,18 @@ async fn cmd_grpc_go(
.unwrap(); .unwrap();
} }
Ok(None) => { Ok(None) => {
// TODO: Store trailers on connection let trailers = stream
let trailers = stream.trailers().await.unwrap_or_default(); .trailers()
info!("gRPC stream closed by sender {:?}", trailers,); .await
// TODO: Mark this on connection instead .unwrap_or_default()
upsert_grpc_message( .unwrap_or_default();
upsert_grpc_connection(
&w, &w,
&GrpcMessage { &GrpcConnection {
message: "Connection closed".to_string(), elapsed: start.elapsed().as_millis() as i64,
is_info: true, status: Code::Unavailable as i64,
..base_msg.clone() trailers: Json(metadata_to_map(trailers)),
..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
.await .await
@@ -472,15 +555,13 @@ async fn cmd_grpc_go(
break; break;
} }
Err(status) => { Err(status) => {
// TODO: Make into error upsert_grpc_connection(
println!("Error status: {:?}", status);
upsert_grpc_message(
&w, &w,
&GrpcMessage { &GrpcConnection {
message: status.message().to_string(), elapsed: start.elapsed().as_millis() as i64,
is_server: true, status: Code::Unavailable as i64,
is_info: true, trailers: Json(metadata_to_map(status.metadata().clone())),
..base_msg.clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
.await .await
@@ -492,36 +573,31 @@ async fn cmd_grpc_go(
}; };
{ {
let conn = conn.clone(); let conn_id = conn_id.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let w = w.clone(); let w = w.clone();
tokio::select! { tokio::select! {
_ = grpc_listen => { _ = grpc_listen => {
upsert_grpc_connection( // upsert_grpc_connection(
&w, // &w,
&GrpcConnection{ // &GrpcConnection{
elapsed: start.elapsed().as_millis() as i64, // elapsed: start.elapsed().as_millis() as i64,
..conn // status: Code::Ok as i64,
}, // ..conn
).await.unwrap(); // },
// ).await.unwrap();
}, },
_ = cancelled_rx.changed() => { _ = cancelled_rx.changed() => {
upsert_grpc_message(
&w,
&GrpcMessage {
message: "Connection cancelled".to_string(),
is_info: true,
..base_msg.clone()
},
)
.await.unwrap();
upsert_grpc_connection( upsert_grpc_connection(
&w, &w,
&GrpcConnection{ &GrpcConnection {
elapsed: start.elapsed().as_millis() as i64, elapsed: start.elapsed().as_millis() as i64,
..conn status: Code::Cancelled as i64,
..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
).await.unwrap(); )
.await
.unwrap();
}, },
} }
w.unlisten(event_handler); w.unlisten(event_handler);
@@ -1037,11 +1113,8 @@ async fn cmd_list_grpc_connections(
} }
#[tauri::command] #[tauri::command]
async fn cmd_list_grpc_messages( async fn cmd_list_grpc_events(connection_id: &str, w: Window) -> Result<Vec<GrpcEvent>, String> {
connection_id: &str, list_grpc_events(&w, connection_id)
w: Window,
) -> Result<Vec<GrpcMessage>, String> {
list_grpc_messages(&w, connection_id)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -1334,7 +1407,7 @@ fn main() {
cmd_list_http_requests, cmd_list_http_requests,
cmd_list_grpc_requests, cmd_list_grpc_requests,
cmd_list_grpc_connections, cmd_list_grpc_connections,
cmd_list_grpc_messages, cmd_list_grpc_events,
cmd_list_http_responses, cmd_list_http_responses,
cmd_list_workspaces, cmd_list_workspaces,
cmd_new_window, cmd_new_window,

View File

@@ -231,33 +231,41 @@ pub struct GrpcConnection {
pub service: String, pub service: String,
pub method: String, pub method: String,
pub elapsed: i64, pub elapsed: i64,
pub status: i64,
pub url: String,
pub error: Option<String>,
pub trailers: Json<HashMap<String, String>>,
}
#[derive(sqlx::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "snake_case")]
pub enum GrpcEventType {
Info,
Error,
ClientMessage,
ServerMessage,
ConnectionResponse,
}
impl Default for GrpcEventType {
fn default() -> Self {
GrpcEventType::Info
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct GrpcMessage { pub struct GrpcEvent {
pub id: String, pub id: String,
pub model: String, pub model: String,
pub workspace_id: String, pub workspace_id: String,
pub request_id: String, pub request_id: String,
pub connection_id: String, pub connection_id: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub message: String, pub content: String,
pub is_server: bool, pub event_type: GrpcEventType,
pub is_info: bool, pub metadata: Json<HashMap<String, String>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct GrpcResponse {
pub id: String,
pub model: String,
pub workspace_id: String,
pub grpc_endpoint_id: String,
pub grpc_connection_id: String,
pub request_id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
@@ -612,14 +620,19 @@ pub async fn upsert_grpc_connection(
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO grpc_connections ( INSERT INTO grpc_connections (
id, workspace_id, request_id, service, method, elapsed id, workspace_id, request_id, service, method, elapsed,
status, error, trailers, url
) )
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP,
service = excluded.service, service = excluded.service,
method = excluded.method, method = excluded.method,
elapsed = excluded.elapsed elapsed = excluded.elapsed,
status = excluded.status,
error = excluded.error,
trailers = excluded.trailers,
url = excluded.url
"#, "#,
id, id,
connection.workspace_id, connection.workspace_id,
@@ -627,6 +640,10 @@ pub async fn upsert_grpc_connection(
connection.service, connection.service,
connection.method, connection.method,
connection.elapsed, connection.elapsed,
connection.status,
connection.error,
connection.trailers,
connection.url,
) )
.execute(&db) .execute(&db)
.await?; .await?;
@@ -647,7 +664,8 @@ pub async fn get_grpc_connection(
r#" r#"
SELECT SELECT
id, model, workspace_id, request_id, created_at, updated_at, service, id, model, workspace_id, request_id, created_at, updated_at, service,
method, elapsed method, elapsed, status, error, url,
trailers AS "trailers!: sqlx::types::Json<HashMap<String, String>>"
FROM grpc_connections FROM grpc_connections
WHERE id = ? WHERE id = ?
"#, "#,
@@ -667,7 +685,8 @@ pub async fn list_grpc_connections(
r#" r#"
SELECT SELECT
id, model, workspace_id, request_id, created_at, updated_at, service, id, model, workspace_id, request_id, created_at, updated_at, service,
method, elapsed method, elapsed, status, error, url,
trailers AS "trailers!: sqlx::types::Json<HashMap<String, String>>"
FROM grpc_connections FROM grpc_connections
WHERE request_id = ? WHERE request_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -678,56 +697,57 @@ pub async fn list_grpc_connections(
.await .await
} }
pub async fn upsert_grpc_message( pub async fn upsert_grpc_event(
mgr: &impl Manager<Wry>, mgr: &impl Manager<Wry>,
message: &GrpcMessage, message: &GrpcEvent,
) -> Result<GrpcMessage, sqlx::Error> { ) -> Result<GrpcEvent, sqlx::Error> {
let db = get_db(mgr).await; let db = get_db(mgr).await;
let id = match message.id.as_str() { let id = match message.id.as_str() {
"" => generate_id(Some("gm")), "" => generate_id(Some("ge")),
_ => message.id.to_string(), _ => message.id.to_string(),
}; };
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO grpc_messages ( INSERT INTO grpc_events (
id, workspace_id, request_id, connection_id, message, is_server, is_info id, workspace_id, request_id, connection_id, content, event_type, metadata
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP,
message = excluded.message, content = excluded.content,
is_server = excluded.is_server, event_type = excluded.event_type,
is_info = excluded.is_info metadata = excluded.metadata
"#, "#,
id, id,
message.workspace_id, message.workspace_id,
message.request_id, message.request_id,
message.connection_id, message.connection_id,
message.message, message.content,
message.is_server, message.event_type,
message.is_info, message.metadata,
) )
.execute(&db) .execute(&db)
.await?; .await?;
match get_grpc_message(mgr, &id).await { match get_grpc_event(mgr, &id).await {
Ok(m) => Ok(emit_upserted_model(mgr, m)), Ok(m) => Ok(emit_upserted_model(mgr, m)),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
pub async fn get_grpc_message( pub async fn get_grpc_event(
mgr: &impl Manager<Wry>, mgr: &impl Manager<Wry>,
id: &str, id: &str,
) -> Result<GrpcMessage, sqlx::Error> { ) -> Result<GrpcEvent, sqlx::Error> {
let db = get_db(mgr).await; let db = get_db(mgr).await;
sqlx::query_as!( sqlx::query_as!(
GrpcMessage, GrpcEvent,
r#" r#"
SELECT SELECT
id, model, workspace_id, request_id, connection_id, created_at, message, id, model, workspace_id, request_id, connection_id, created_at, content,
is_server, is_info event_type AS "event_type!: GrpcEventType",
FROM grpc_messages metadata AS "metadata!: sqlx::types::Json<HashMap<String, String>>"
FROM grpc_events
WHERE id = ? WHERE id = ?
"#, "#,
id, id,
@@ -736,18 +756,19 @@ pub async fn get_grpc_message(
.await .await
} }
pub async fn list_grpc_messages( pub async fn list_grpc_events(
mgr: &impl Manager<Wry>, mgr: &impl Manager<Wry>,
connection_id: &str, connection_id: &str,
) -> Result<Vec<GrpcMessage>, sqlx::Error> { ) -> Result<Vec<GrpcEvent>, sqlx::Error> {
let db = get_db(mgr).await; let db = get_db(mgr).await;
sqlx::query_as!( sqlx::query_as!(
GrpcMessage, GrpcEvent,
r#" r#"
SELECT SELECT
id, model, workspace_id, request_id, connection_id, created_at, message, id, model, workspace_id, request_id, connection_id, created_at, content,
is_server, is_info event_type AS "event_type!: GrpcEventType",
FROM grpc_messages metadata AS "metadata!: sqlx::types::Json<HashMap<String, String>>"
FROM grpc_events
WHERE connection_id = ? WHERE connection_id = ?
"#, "#,
connection_id, connection_id,

View File

@@ -6,7 +6,7 @@ interface Props {
export function EmptyStateText({ children }: Props) { export function EmptyStateText({ children }: Props) {
return ( 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} {children}
</div> </div>
); );

View File

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

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
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 { useGrpcEvents } from '../hooks/useGrpcEvents';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList'; import { HotKeyList } from './core/HotKeyList';
@@ -21,7 +21,7 @@ export function GrpcConnectionLayout({ style }: Props) {
const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? null); const updateRequest = useUpdateGrpcRequest(activeRequest?.id ?? 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 = useGrpcEvents(activeConnection?.id ?? null);
const grpc = useGrpc(activeRequest, activeConnection); const grpc = useGrpc(activeRequest, activeConnection);
const services = grpc.reflect.data ?? null; const services = grpc.reflect.data ?? null;

View File

@@ -1,15 +1,17 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { format } from 'date-fns'; import { format, addMilliseconds } from 'date-fns';
import type { CSSProperties } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useGrpcConnections } from '../hooks/useGrpcConnections'; import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useGrpcMessages } from '../hooks/useGrpcMessages'; import { useGrpcEvents } from '../hooks/useGrpcEvents';
import type { GrpcRequest } from '../lib/models'; import type { GrpcEvent, GrpcRequest } from '../lib/models';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { JsonAttributeTree } from './core/JsonAttributeTree'; import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout'; import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown'; import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
interface Props { interface Props {
@@ -25,34 +27,80 @@ interface Props {
| 'no-method'; | 'no-method';
} }
const CONNECTION_RESPONSE_EVENT_ID = 'connection_response';
export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }: Props) { 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 connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null; const activeConnection = connections[0] ?? null;
const messages = useGrpcMessages(activeConnection?.id ?? null); const ogEvents = useGrpcEvents(activeConnection?.id ?? null);
const activeMessage = useMemo( const events = useMemo(() => {
() => messages.find((m) => m.id === activeMessageId) ?? null, const createdAt =
[activeMessageId, messages], 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 ( return (
<SplitLayout <SplitLayout
layout="vertical" layout="vertical"
style={style} style={style}
name={methodType === 'unary' ? 'grpc_messages_unary' : 'grpc_messages_streaming'} name="grpc_events"
defaultRatio={methodType === 'unary' ? 0.75 : 0.3} defaultRatio={0.4}
minHeightPx={20} minHeightPx={20}
firstSlot={() => ( firstSlot={() =>
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center"> activeConnection && (
<HStack className="pl-3 mb-1 font-mono" alignItems="center"> <div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack alignItems="center" space={2}> <HStack className="pl-3 mb-1 font-mono" alignItems="center">
<span>{messages.filter((m) => !m.isInfo).length} messages</span> <HStack alignItems="center" space={2}>
{activeConnection?.elapsed === 0 && ( <span>{events.length} messages</span>
<Icon icon="refresh" size="sm" spin className="text-gray-600" /> {activeConnection.elapsed === 0 && (
)} <Icon icon="refresh" size="sm" spin className="text-gray-600" />
</HStack> )}
{activeConnection && ( </HStack>
<RecentConnectionsDropdown <RecentConnectionsDropdown
connections={connections} connections={connections}
activeConnection={activeConnection} activeConnection={activeConnection}
@@ -60,63 +108,59 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
// todo // todo
}} }}
/> />
)} </HStack>
</HStack> <div className="overflow-y-auto h-full">
<div className="overflow-y-auto h-full"> {...events.map((m) => (
{...messages.map((m) => ( <MessageRow
<HStack key={m.id}
role="button" isActive={m.id === activeEventId}
key={m.id} eventType={m.eventType}
space={2} timestamp={m.createdAt}
onClick={() => { onClick={() => {
if (m.id === activeMessageId) setActiveMessageId(null); if (m.id === activeEventId) setActiveEventId(null);
else setActiveMessageId(m.id); else setActiveEventId(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',
)}
> >
{m.message} {m.content}
</div> </MessageRow>
<div ))}
className={classNames( </div>
'text-gray-600 text-2xs group-hover:text-gray-700',
m.id === activeMessageId && 'text-gray-700',
)}
>
{format(m.createdAt, 'HH:mm:ss')}
</div>
</HStack>
))}
</div> </div>
</div> )
)} }
secondSlot={ secondSlot={
activeMessage && activeEvent &&
(() => ( (() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]"> <div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2"> <div className="pb-3 px-2">
<Separator /> <Separator />
</div> </div>
<div className="pl-2 overflow-y-auto"> <div className="pl-2 overflow-y-auto">
{activeMessage.isInfo ? ( {activeEvent.eventType === 'client_message' ||
<span>{activeMessage.message}</span> 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>
</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" className="border border-highlight"
size="sm" size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'} title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction={isStreaming ? undefined : 'http_request.send'} hotkeyAction="grpc_request.send"
onClick={handleConnect} onClick={handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'} disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={ icon={
@@ -240,24 +240,24 @@ export function GrpcConnectionSetupPane({
disabled={!isStreaming} 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 && ( {(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && (
<IconButton <>
className="border border-highlight" <IconButton
size="sm" className="border border-highlight"
title="to-do" size="sm"
hotkeyAction="grpc_request.send" title="to-do"
onClick={() => onSend({ message: activeRequest.message ?? '' })} onClick={onCommit}
icon="sendHorizontal" icon="check"
/> />
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
</>
)} )}
</HStack> </HStack>
</div> </div>

View File

@@ -1,10 +1,8 @@
import { shell } from '@tauri-apps/api'; import { shell } from '@tauri-apps/api';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { HStack } from './core/Stacks';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -13,16 +11,16 @@ interface Props {
export function ResponseHeaders({ response }: Props) { export function ResponseHeaders({ response }: Props) {
return ( return (
<div className="overflow-auto h-full pb-4"> <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) => ( {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> <Separator className="my-4">Other Info</Separator>
<dl className="text-xs w-full font-mono divide-highlightSecondary"> <KeyValueRows>
<Row label="Version" value={response.version} /> <KeyValueRow label="Version" value={response.version} />
<Row label="Remote Address" value={response.remoteAddr} /> <KeyValueRow label="Remote Address" value={response.remoteAddr} />
<Row <KeyValueRow
label={ label={
<div className="flex items-center"> <div className="flex items-center">
URL URL
@@ -41,26 +39,7 @@ export function ResponseHeaders({ response }: Props) {
</div> </div>
} }
/> />
</dl> </KeyValueRows>
</div> </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', key: 'create-grpc-request',
label: 'GRPC Call', label: 'gRPC Call',
onSelect: () => createGrpcRequest.mutate({}), onSelect: () => createGrpcRequest.mutate({}),
}, },
{ {

View File

@@ -69,7 +69,7 @@ export const UrlBar = memo(function UrlBar({
<RequestMethodDropdown <RequestMethodDropdown
method={method} method={method}
onChange={onMethodChange} 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 { useSidebarWidth } from '../hooks/useSidebarWidth';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList'; import { HotKeyList } from './core/HotKeyList';
import { HStack } from './core/Stacks';
import { GrpcConnectionLayout } from './GrpcConnectionLayout'; import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HttpRequestLayout } from './HttpRequestLayout'; import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay'; import { Overlay } from './Overlay';
@@ -160,7 +161,19 @@ export default function Workspace() {
<WorkspaceHeader className="pointer-events-none" /> <WorkspaceHeader className="pointer-events-none" />
</HeaderSize> </HeaderSize>
{activeRequest == null ? ( {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' ? ( ) : activeRequest.model === 'grpc_request' ? (
<GrpcConnectionLayout style={body} /> <GrpcConnectionLayout style={body} />
) : ( ) : (

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { GrpcEvent } from '../lib/models';
export function grpcEventsQueryKey({ connectionId }: { connectionId: string }) {
return ['grpc_events', { connectionId }];
}
export function useGrpcEvents(connectionId: string | null) {
return (
useQuery<GrpcEvent[]>({
enabled: connectionId !== null,
initialData: [],
queryKey: grpcEventsQueryKey({ connectionId: connectionId ?? 'n/a' }),
queryFn: async () => {
return (await invoke('cmd_list_grpc_events', {
connectionId,
limit: 200,
})) as GrpcEvent[];
},
}).data ?? []
);
}

View File

@@ -1,23 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { GrpcMessage } from '../lib/models';
export function grpcMessagesQueryKey({ connectionId }: { connectionId: string }) {
return ['grpc_messages', { connectionId }];
}
export function useGrpcMessages(connectionId: string | null) {
return (
useQuery<GrpcMessage[]>({
enabled: connectionId !== null,
initialData: [],
queryKey: grpcMessagesQueryKey({ connectionId: connectionId ?? 'n/a' }),
queryFn: async () => {
return (await invoke('cmd_list_grpc_messages', {
connectionId,
limit: 200,
})) as GrpcMessage[];
},
}).data ?? []
);
}

View File

@@ -9,7 +9,7 @@ export function trackEvent(
| 'Workspace' | 'Workspace'
| 'Environment' | 'Environment'
| 'Folder' | 'Folder'
| 'GrpcMessage' | 'GrpcEvent'
| 'GrpcConnection' | 'GrpcConnection'
| 'GrpcRequest' | 'GrpcRequest'
| 'HttpRequest' | 'HttpRequest'

View File

@@ -15,7 +15,7 @@ export type Model =
| Workspace | Workspace
| GrpcConnection | GrpcConnection
| GrpcRequest | GrpcRequest
| GrpcMessage | GrpcEvent
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| KeyValue | KeyValue
@@ -127,14 +127,14 @@ export interface GrpcRequest extends BaseModel {
metadata: GrpcMetadataEntry[]; metadata: GrpcMetadataEntry[];
} }
export interface GrpcMessage extends BaseModel { export interface GrpcEvent extends BaseModel {
readonly workspaceId: string; readonly workspaceId: string;
readonly requestId: string; readonly requestId: string;
readonly connectionId: string; readonly connectionId: string;
readonly model: 'grpc_message'; readonly model: 'grpc_event';
message: string; content: string;
isServer: boolean; eventType: 'info' | 'error' | 'client_message' | 'server_message' | 'connection_response';
isInfo: boolean; metadata: Record<string, string>;
} }
export interface GrpcConnection extends BaseModel { export interface GrpcConnection extends BaseModel {
@@ -144,6 +144,11 @@ export interface GrpcConnection extends BaseModel {
service: string; service: string;
method: string; method: string;
elapsed: number; elapsed: number;
elapsedConnection: number;
status: number;
url: string;
error: string | null;
trailers: Record<string, string>;
} }
export interface HttpRequest extends BaseModel { export interface HttpRequest extends BaseModel {