mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-19 06:07:22 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3e6e6508 |
@@ -1,7 +1,7 @@
|
|||||||
name: Generate Artifacts
|
name: Generate Artifacts
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [v*]
|
tags: [ v* ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
@@ -13,37 +13,37 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
|
||||||
args: "--target aarch64-apple-darwin"
|
args: '--target aarch64-apple-darwin'
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "macos"
|
os: 'macos'
|
||||||
targets: "aarch64-apple-darwin"
|
targets: 'aarch64-apple-darwin'
|
||||||
- platform: "macos-latest" # for Intel-based Macs.
|
- platform: 'macos-latest' # for Intel-based Macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: '--target x86_64-apple-darwin'
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "macos"
|
os: 'macos'
|
||||||
targets: "x86_64-apple-darwin"
|
targets: 'x86_64-apple-darwin'
|
||||||
- platform: "ubuntu-22.04"
|
- platform: 'ubuntu-22.04'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "ubuntu"
|
os: 'ubuntu'
|
||||||
targets: ""
|
targets: ''
|
||||||
- platform: "ubuntu-22.04-arm"
|
- platform: 'ubuntu-22.04-arm'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "ubuntu"
|
os: 'ubuntu'
|
||||||
targets: ""
|
targets: ''
|
||||||
- platform: "windows-latest"
|
- platform: 'windows-latest'
|
||||||
args: ""
|
args: ''
|
||||||
yaak_arch: "x64"
|
yaak_arch: 'x64'
|
||||||
os: "windows"
|
os: 'windows'
|
||||||
targets: ""
|
targets: ''
|
||||||
# Windows ARM64
|
# Windows ARM64
|
||||||
- platform: "windows-latest"
|
- platform: 'windows-latest'
|
||||||
args: "--target aarch64-pc-windows-msvc"
|
args: '--target aarch64-pc-windows-msvc'
|
||||||
yaak_arch: "arm64"
|
yaak_arch: 'arm64'
|
||||||
os: "windows"
|
os: 'windows'
|
||||||
targets: "aarch64-pc-windows-msvc"
|
targets: 'aarch64-pc-windows-msvc'
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
@@ -88,7 +88,6 @@ jobs:
|
|||||||
& $exe --version
|
& $exe --version
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run bootstrap
|
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: npm test
|
run: npm test
|
||||||
@@ -100,29 +99,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
YAAK_VERSION: ${{ github.ref_name }}
|
YAAK_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Sign vendored binaries (macOS only)
|
|
||||||
if: matrix.os == 'macos'
|
|
||||||
env:
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
# Create keychain
|
|
||||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
|
||||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
|
|
||||||
# Import certificate
|
|
||||||
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
|
|
||||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
|
||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
|
||||||
|
|
||||||
# Sign vendored binaries with hardened runtime and their specific entitlements
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
|
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
|
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||||
@@ -145,9 +121,9 @@ jobs:
|
|||||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: "v__VERSION__"
|
tagName: 'v__VERSION__'
|
||||||
releaseName: "Release __VERSION__"
|
releaseName: 'Release __VERSION__'
|
||||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
|
args: '${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json'
|
||||||
|
|||||||
Generated
-4
@@ -8075,7 +8075,6 @@ name = "yaak-common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8122,10 +8121,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"url",
|
"url",
|
||||||
"yaak-common",
|
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
"yaak-sync",
|
"yaak-sync",
|
||||||
]
|
]
|
||||||
@@ -8152,7 +8149,6 @@ dependencies = [
|
|||||||
"tonic",
|
"tonic",
|
||||||
"tonic-reflection",
|
"tonic-reflection",
|
||||||
"uuid",
|
"uuid",
|
||||||
"yaak-common",
|
|
||||||
"yaak-tls",
|
"yaak-tls",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
|
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use yaak_models::util::UpdateSource;
|
|||||||
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
|
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "yaakcli")]
|
#[command(name = "yaakcli")]
|
||||||
@@ -149,7 +149,14 @@ async fn render_http_request(
|
|||||||
// Apply path placeholders (e.g., /users/:id -> /users/123)
|
// Apply path placeholders (e.g., /users/:id -> /users/123)
|
||||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||||
|
|
||||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
|
Ok(HttpRequest {
|
||||||
|
url,
|
||||||
|
url_parameters,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
authentication,
|
||||||
|
..r.to_owned()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -162,10 +169,16 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the same app_id for both data directory and keyring
|
// Use the same app_id for both data directory and keyring
|
||||||
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
|
let app_id = if cfg!(debug_assertions) {
|
||||||
|
"app.yaak.desktop.dev"
|
||||||
|
} else {
|
||||||
|
"app.yaak.desktop"
|
||||||
|
};
|
||||||
|
|
||||||
let data_dir = cli.data_dir.unwrap_or_else(|| {
|
let data_dir = cli.data_dir.unwrap_or_else(|| {
|
||||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
dirs::data_dir()
|
||||||
|
.expect("Could not determine data directory")
|
||||||
|
.join(app_id)
|
||||||
});
|
});
|
||||||
|
|
||||||
let db_path = data_dir.join("db.sqlite");
|
let db_path = data_dir.join("db.sqlite");
|
||||||
@@ -178,7 +191,9 @@ async fn main() {
|
|||||||
|
|
||||||
// Initialize encryption manager for secure() template function
|
// Initialize encryption manager for secure() template function
|
||||||
// Use the same app_id as the Tauri app for keyring access
|
// Use the same app_id as the Tauri app for keyring access
|
||||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
let encryption_manager = Arc::new(
|
||||||
|
EncryptionManager::new(query_manager.clone(), app_id),
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize plugin manager for template functions
|
// Initialize plugin manager for template functions
|
||||||
let vendored_plugin_dir = data_dir.join("vendored-plugins");
|
let vendored_plugin_dir = data_dir.join("vendored-plugins");
|
||||||
@@ -188,8 +203,9 @@ async fn main() {
|
|||||||
let node_bin_path = PathBuf::from("node");
|
let node_bin_path = PathBuf::from("node");
|
||||||
|
|
||||||
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
|
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
|
||||||
let plugin_runtime_main =
|
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
|
||||||
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
// Development fallback: look relative to crate root
|
// Development fallback: look relative to crate root
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
||||||
@@ -210,10 +226,14 @@ async fn main() {
|
|||||||
// Initialize plugins from database
|
// Initialize plugins from database
|
||||||
let plugins = db.list_plugins().unwrap_or_default();
|
let plugins = db.list_plugins().unwrap_or_default();
|
||||||
if !plugins.is_empty() {
|
if !plugins.is_empty() {
|
||||||
let errors =
|
let errors = plugin_manager
|
||||||
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
|
.initialize_all_plugins(plugins, &PluginContext::new_empty())
|
||||||
|
.await;
|
||||||
for (plugin_dir, error_msg) in errors {
|
for (plugin_dir, error_msg) in errors {
|
||||||
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg);
|
eprintln!(
|
||||||
|
"Warning: Failed to initialize plugin '{}': {}",
|
||||||
|
plugin_dir, error_msg
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +249,9 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Requests { workspace_id } => {
|
Commands::Requests { workspace_id } => {
|
||||||
let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests");
|
let requests = db
|
||||||
|
.list_http_requests(&workspace_id)
|
||||||
|
.expect("Failed to list requests");
|
||||||
if requests.is_empty() {
|
if requests.is_empty() {
|
||||||
println!("No requests found in workspace {}", workspace_id);
|
println!("No requests found in workspace {}", workspace_id);
|
||||||
} else {
|
} else {
|
||||||
@@ -239,7 +261,9 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Send { request_id } => {
|
Commands::Send { request_id } => {
|
||||||
let request = db.get_http_request(&request_id).expect("Failed to get request");
|
let request = db
|
||||||
|
.get_http_request(&request_id)
|
||||||
|
.expect("Failed to get request");
|
||||||
|
|
||||||
// Resolve environment chain for variable substitution
|
// Resolve environment chain for variable substitution
|
||||||
let environment_chain = db
|
let environment_chain = db
|
||||||
@@ -294,13 +318,18 @@ async fn main() {
|
|||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
// Drain events silently
|
// Drain events silently
|
||||||
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
tokio::spawn(async move {
|
||||||
|
while event_rx.recv().await.is_some() {}
|
||||||
|
});
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
||||||
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
let response = sender
|
||||||
|
.send(sendable, event_tx)
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
// Wait for event handler to finish
|
// Wait for event handler to finish
|
||||||
if let Some(handle) = verbose_handle {
|
if let Some(handle) = verbose_handle {
|
||||||
@@ -354,13 +383,18 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
tokio::spawn(async move {
|
||||||
|
while event_rx.recv().await.is_some() {}
|
||||||
|
});
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
||||||
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
let response = sender
|
||||||
|
.send(sendable, event_tx)
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
if let Some(handle) = verbose_handle {
|
if let Some(handle) = verbose_handle {
|
||||||
let _ = handle.await;
|
let _ = handle.await;
|
||||||
@@ -387,7 +421,12 @@ async fn main() {
|
|||||||
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
||||||
println!("{}", body);
|
println!("{}", body);
|
||||||
}
|
}
|
||||||
Commands::Create { workspace_id, name, method, url } => {
|
Commands::Create {
|
||||||
|
workspace_id,
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
} => {
|
||||||
let request = HttpRequest {
|
let request = HttpRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- Enable for NodeJS execution -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Allow loading 1Password's dylib (signed with different Team ID) -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
|
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
|
||||||
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
|
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
|
||||||
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
|
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<!-- Enable for NodeJS/V8 JIT compiler -->
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<!-- Allow loading plugins signed with different Team IDs (e.g., 1Password) -->
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::PluginContextExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
@@ -54,12 +54,7 @@ pub(crate) async fn cmd_secure_template<R: Runtime>(
|
|||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let plugin_context = window.plugin_context();
|
let plugin_context = window.plugin_context();
|
||||||
Ok(encrypt_secure_template_function(
|
Ok(encrypt_secure_template_function(plugin_manager, encryption_manager, &plugin_context, template)?)
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&plugin_context,
|
|
||||||
template,
|
|
||||||
)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ use crate::error::Result;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
use yaak_git::{
|
use yaak_git::{
|
||||||
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential,
|
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult,
|
||||||
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch,
|
git_add, git_add_credential, git_add_remote, git_checkout_branch, git_commit,
|
||||||
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes,
|
git_create_branch, git_delete_branch, git_fetch_all, git_init, git_log,
|
||||||
git_rm_remote, git_status, git_unstage,
|
git_merge_branch, git_pull, git_push, git_remotes, git_rm_remote, git_status,
|
||||||
|
git_unstage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||||
@@ -51,22 +52,22 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
Ok(git_commit(dir, message).await?)
|
Ok(git_commit(dir, message)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
||||||
Ok(git_fetch_all(dir).await?)
|
Ok(git_fetch_all(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
||||||
Ok(git_push(dir).await?)
|
Ok(git_push(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
Ok(git_pull(dir).await?)
|
Ok(git_pull(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::PluginContextExt;
|
||||||
use KeyAndValueRef::{Ascii, Binary};
|
use KeyAndValueRef::{Ascii, Binary};
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||||
use yaak_models::models::GrpcRequest;
|
use yaak_models::models::GrpcRequest;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
const NAMESPACE: &str = "analytics";
|
const NAMESPACE: &str = "analytics";
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::BlobManagerExt;
|
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use crate::render::render_http_request;
|
use crate::render::render_http_request;
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||||
use tokio::fs::{File, create_dir_all};
|
use tokio::fs::{File, create_dir_all};
|
||||||
@@ -19,19 +15,22 @@ use yaak_http::client::{
|
|||||||
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
|
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
|
||||||
};
|
};
|
||||||
use yaak_http::cookies::CookieStore;
|
use yaak_http::cookies::CookieStore;
|
||||||
use yaak_http::manager::{CachedClient, HttpConnectionManager};
|
use yaak_http::manager::HttpConnectionManager;
|
||||||
use yaak_http::sender::ReqwestSender;
|
use yaak_http::sender::ReqwestSender;
|
||||||
use yaak_http::tee_reader::TeeReader;
|
use yaak_http::tee_reader::TeeReader;
|
||||||
use yaak_http::transaction::HttpTransaction;
|
use yaak_http::transaction::HttpTransaction;
|
||||||
use yaak_http::types::{
|
use yaak_http::types::{
|
||||||
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
|
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
|
||||||
};
|
};
|
||||||
|
use crate::models_ext::BlobManagerExt;
|
||||||
use yaak_models::blob_manager::BodyChunk;
|
use yaak_models::blob_manager::BodyChunk;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
|
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
|
||||||
HttpResponseState, ProxySetting, ProxySettingAuth,
|
HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||||
};
|
};
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
use crate::PluginContextExt;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
|
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
|
||||||
};
|
};
|
||||||
@@ -174,12 +173,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
let environment_id = environment.map(|e| e.id);
|
let environment_id = environment.map(|e| e.id);
|
||||||
let workspace = window.db().get_workspace(workspace_id)?;
|
let workspace = window.db().get_workspace(workspace_id)?;
|
||||||
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
|
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(plugin_manager.clone(), encryption_manager.clone(), &plugin_context, RenderPurpose::Send);
|
||||||
plugin_manager.clone(),
|
|
||||||
encryption_manager.clone(),
|
|
||||||
&plugin_context,
|
|
||||||
RenderPurpose::Send,
|
|
||||||
);
|
|
||||||
let env_chain =
|
let env_chain =
|
||||||
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
||||||
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
||||||
@@ -234,13 +228,12 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached_client = connection_manager
|
let client = connection_manager
|
||||||
.get_client(&HttpConnectionOptions {
|
.get_client(&HttpConnectionOptions {
|
||||||
id: plugin_context.id.clone(),
|
id: plugin_context.id.clone(),
|
||||||
validate_certificates: workspace.setting_validate_certificates,
|
validate_certificates: workspace.setting_validate_certificates,
|
||||||
proxy: proxy_setting,
|
proxy: proxy_setting,
|
||||||
client_certificate,
|
client_certificate,
|
||||||
dns_overrides: workspace.setting_dns_overrides.clone(),
|
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -257,7 +250,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
|
|
||||||
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
|
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
|
||||||
let result = execute_transaction(
|
let result = execute_transaction(
|
||||||
cached_client,
|
client,
|
||||||
sendable_request,
|
sendable_request,
|
||||||
response_ctx,
|
response_ctx,
|
||||||
cancelled_rx.clone(),
|
cancelled_rx.clone(),
|
||||||
@@ -317,7 +310,7 @@ pub fn resolve_http_request<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_transaction<R: Runtime>(
|
async fn execute_transaction<R: Runtime>(
|
||||||
cached_client: CachedClient,
|
client: reqwest::Client,
|
||||||
mut sendable_request: SendableHttpRequest,
|
mut sendable_request: SendableHttpRequest,
|
||||||
response_ctx: &mut ResponseContext<R>,
|
response_ctx: &mut ResponseContext<R>,
|
||||||
mut cancelled_rx: Receiver<bool>,
|
mut cancelled_rx: Receiver<bool>,
|
||||||
@@ -328,10 +321,7 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
let workspace_id = response_ctx.response().workspace_id.clone();
|
let workspace_id = response_ctx.response().workspace_id.clone();
|
||||||
let is_persisted = response_ctx.is_persisted();
|
let is_persisted = response_ctx.is_persisted();
|
||||||
|
|
||||||
// Keep a reference to the resolver for DNS timing events
|
let sender = ReqwestSender::with_client(client);
|
||||||
let resolver = cached_client.resolver.clone();
|
|
||||||
|
|
||||||
let sender = ReqwestSender::with_client(cached_client.client);
|
|
||||||
let transaction = match cookie_store {
|
let transaction = match cookie_store {
|
||||||
Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
|
Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
|
||||||
None => HttpTransaction::new(sender),
|
None => HttpTransaction::new(sender),
|
||||||
@@ -356,39 +346,21 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
let (event_tx, mut event_rx) =
|
let (event_tx, mut event_rx) =
|
||||||
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
|
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
|
||||||
|
|
||||||
// Set the event sender on the DNS resolver so it can emit DNS timing events
|
|
||||||
resolver.set_event_sender(Some(event_tx.clone())).await;
|
|
||||||
|
|
||||||
// Shared state to capture DNS timing from the event processing task
|
|
||||||
let dns_elapsed = Arc::new(AtomicI32::new(0));
|
|
||||||
|
|
||||||
// Write events to DB in a task (only for persisted responses)
|
// Write events to DB in a task (only for persisted responses)
|
||||||
if is_persisted {
|
if is_persisted {
|
||||||
let response_id = response_id.clone();
|
let response_id = response_id.clone();
|
||||||
let app_handle = app_handle.clone();
|
let app_handle = app_handle.clone();
|
||||||
let update_source = response_ctx.update_source.clone();
|
let update_source = response_ctx.update_source.clone();
|
||||||
let workspace_id = workspace_id.clone();
|
let workspace_id = workspace_id.clone();
|
||||||
let dns_elapsed = dns_elapsed.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = event_rx.recv().await {
|
while let Some(event) = event_rx.recv().await {
|
||||||
// Capture DNS timing when we see a DNS event
|
|
||||||
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
|
|
||||||
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
|
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
|
||||||
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
|
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// For ephemeral responses, just drain the events but still capture DNS timing
|
// For ephemeral responses, just drain the events
|
||||||
let dns_elapsed = dns_elapsed.clone();
|
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(event) = event_rx.recv().await {
|
|
||||||
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
|
|
||||||
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture request body as it's sent (only for persisted responses)
|
// Capture request body as it's sent (only for persisted responses)
|
||||||
@@ -556,14 +528,10 @@ async fn execute_transaction<R: Runtime>(
|
|||||||
// Final update with closed state and accurate byte count
|
// Final update with closed state and accurate byte count
|
||||||
response_ctx.update(|r| {
|
response_ctx.update(|r| {
|
||||||
r.elapsed = start.elapsed().as_millis() as i32;
|
r.elapsed = start.elapsed().as_millis() as i32;
|
||||||
r.elapsed_dns = dns_elapsed.load(Ordering::SeqCst);
|
|
||||||
r.content_length = Some(written_bytes as i32);
|
r.content_length = Some(written_bytes as i32);
|
||||||
r.state = HttpResponseState::Closed;
|
r.state = HttpResponseState::Closed;
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Clear the event sender from the resolver since this request is done
|
|
||||||
resolver.set_event_sender(None).await;
|
|
||||||
|
|
||||||
Ok((response_ctx.response().clone(), maybe_blob_write_handle))
|
Ok((response_ctx.response().clone(), maybe_blob_write_handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
use crate::PluginContextExt;
|
||||||
use log::info;
|
use log::info;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
|
||||||
|
|
||||||
pub(crate) async fn import_data<R: Runtime>(
|
pub(crate) async fn import_data<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
|
|||||||
@@ -360,8 +360,10 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
|
|
||||||
let cb = {
|
let cb = {
|
||||||
let cancelled_rx = cancelled_rx.clone();
|
let cancelled_rx = cancelled_rx.clone();
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
let environment_chain = environment_chain.clone();
|
let environment_chain = environment_chain.clone();
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
|
let base_msg = base_msg.clone();
|
||||||
let plugin_manager = plugin_manager.clone();
|
let plugin_manager = plugin_manager.clone();
|
||||||
let encryption_manager = encryption_manager.clone();
|
let encryption_manager = encryption_manager.clone();
|
||||||
|
|
||||||
@@ -383,12 +385,14 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
match serde_json::from_str::<IncomingMsg>(ev.payload()) {
|
match serde_json::from_str::<IncomingMsg>(ev.payload()) {
|
||||||
Ok(IncomingMsg::Message(msg)) => {
|
Ok(IncomingMsg::Message(msg)) => {
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
let base_msg = base_msg.clone();
|
||||||
let environment_chain = environment_chain.clone();
|
let environment_chain = environment_chain.clone();
|
||||||
let plugin_manager = plugin_manager.clone();
|
let plugin_manager = plugin_manager.clone();
|
||||||
let encryption_manager = encryption_manager.clone();
|
let encryption_manager = encryption_manager.clone();
|
||||||
let msg = block_in_place(|| {
|
let msg = block_in_place(|| {
|
||||||
tauri::async_runtime::block_on(async {
|
tauri::async_runtime::block_on(async {
|
||||||
let result = render_template(
|
render_template(
|
||||||
msg.as_str(),
|
msg.as_str(),
|
||||||
environment_chain,
|
environment_chain,
|
||||||
&PluginTemplateCallback::new(
|
&PluginTemplateCallback::new(
|
||||||
@@ -402,11 +406,24 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
),
|
),
|
||||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
result.expect("Failed to render template")
|
.expect("Failed to render template")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
in_msg_tx.try_send(msg.clone()).unwrap();
|
in_msg_tx.try_send(msg.clone()).unwrap();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
app_handle
|
||||||
|
.db()
|
||||||
|
.upsert_grpc_event(
|
||||||
|
&GrpcEvent {
|
||||||
|
content: msg,
|
||||||
|
event_type: GrpcEventType::ClientMessage,
|
||||||
|
..base_msg.clone()
|
||||||
|
},
|
||||||
|
&UpdateSource::from_window_label(window.label()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(IncomingMsg::Commit) => {
|
Ok(IncomingMsg::Commit) => {
|
||||||
maybe_in_msg_tx.take();
|
maybe_in_msg_tx.take();
|
||||||
@@ -453,48 +470,12 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
// Create callback for streaming methods that handles both success and error
|
|
||||||
let on_message = {
|
|
||||||
let app_handle = app_handle.clone();
|
|
||||||
let base_event = base_event.clone();
|
|
||||||
let window_label = window.label().to_string();
|
|
||||||
move |result: std::result::Result<String, String>| match result {
|
|
||||||
Ok(msg) => {
|
|
||||||
let _ = app_handle.db().upsert_grpc_event(
|
|
||||||
&GrpcEvent {
|
|
||||||
content: msg,
|
|
||||||
event_type: GrpcEventType::ClientMessage,
|
|
||||||
..base_event.clone()
|
|
||||||
},
|
|
||||||
&UpdateSource::from_window_label(&window_label),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = app_handle.db().upsert_grpc_event(
|
|
||||||
&GrpcEvent {
|
|
||||||
content: format!("Failed to send message: {}", error),
|
|
||||||
event_type: GrpcEventType::Error,
|
|
||||||
..base_event.clone()
|
|
||||||
},
|
|
||||||
&UpdateSource::from_window_label(&window_label),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (maybe_stream, maybe_msg) =
|
let (maybe_stream, maybe_msg) =
|
||||||
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
|
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
|
||||||
(true, true) => (
|
(true, true) => (
|
||||||
Some(
|
Some(
|
||||||
connection
|
connection
|
||||||
.streaming(
|
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
|
||||||
&service,
|
|
||||||
&method,
|
|
||||||
in_msg_stream,
|
|
||||||
&metadata,
|
|
||||||
client_cert,
|
|
||||||
on_message.clone(),
|
|
||||||
)
|
|
||||||
.await,
|
.await,
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -509,7 +490,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
in_msg_stream,
|
in_msg_stream,
|
||||||
&metadata,
|
&metadata,
|
||||||
client_cert,
|
client_cert,
|
||||||
on_message.clone(),
|
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
),
|
),
|
||||||
@@ -1684,13 +1664,6 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_add_remote,
|
git_ext::cmd_git_add_remote,
|
||||||
git_ext::cmd_git_rm_remote,
|
git_ext::cmd_git_rm_remote,
|
||||||
//
|
//
|
||||||
// Plugin commands
|
|
||||||
plugins_ext::cmd_plugins_search,
|
|
||||||
plugins_ext::cmd_plugins_install,
|
|
||||||
plugins_ext::cmd_plugins_uninstall,
|
|
||||||
plugins_ext::cmd_plugins_updates,
|
|
||||||
plugins_ext::cmd_plugins_update_all,
|
|
||||||
//
|
|
||||||
// WebSocket commands
|
// WebSocket commands
|
||||||
ws_ext::cmd_ws_upsert_request,
|
ws_ext::cmd_ws_upsert_request,
|
||||||
ws_ext::cmd_ws_duplicate_request,
|
ws_ext::cmd_ws_duplicate_request,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::history::get_or_upsert_launch_info;
|
use crate::history::get_or_upsert_launch_info;
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
@@ -9,8 +8,9 @@ use std::time::Instant;
|
|||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_common::platform::get_os_str;
|
use yaak_common::platform::get_os_str;
|
||||||
use yaak_models::util::UpdateSource;
|
|
||||||
use yaak_tauri_utils::api_client::yaak_api_client;
|
use yaak_tauri_utils::api_client::yaak_api_client;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
// Check for updates every hour
|
// Check for updates every hour
|
||||||
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
|
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::http_request::send_http_request_with_context;
|
use crate::http_request::send_http_request_with_context;
|
||||||
use crate::models_ext::BlobManagerExt;
|
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
||||||
use crate::window::{CreateWindowConfig, create_window};
|
use crate::window::{CreateWindowConfig, create_window};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -16,8 +14,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime};
|
|||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
|
use crate::models_ext::BlobManagerExt;
|
||||||
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
|
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
|
||||||
use yaak_models::queries::any_request::AnyRequest;
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::error::Error::PluginErr;
|
use yaak_plugins::error::Error::PluginErr;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
@@ -31,7 +32,6 @@ use yaak_plugins::events::{
|
|||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_handle::PluginHandle;
|
use yaak_plugins::plugin_handle::PluginHandle;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
|
|
||||||
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||||
@@ -57,10 +57,6 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
|
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
|
||||||
Ok(call_frontend(&window, event).await)
|
Ok(call_frontend(&window, event).await)
|
||||||
}
|
}
|
||||||
InternalEventPayload::PromptFormRequest(_) => {
|
|
||||||
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
|
|
||||||
Ok(call_frontend(&window, event).await)
|
|
||||||
}
|
|
||||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||||
let http_responses = app_handle
|
let http_responses = app_handle
|
||||||
.db()
|
.db()
|
||||||
@@ -170,12 +166,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose,
|
|
||||||
);
|
|
||||||
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||||
let grpc_request =
|
let grpc_request =
|
||||||
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
|
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
|
||||||
@@ -196,12 +187,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose,
|
|
||||||
);
|
|
||||||
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||||
let http_request =
|
let http_request =
|
||||||
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
|
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
|
||||||
@@ -232,12 +218,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose);
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose,
|
|
||||||
);
|
|
||||||
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||||
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
|
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
|
||||||
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
|
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use tauri::path::BaseDirectory;
|
|||||||
use tauri::plugin::{Builder, TauriPlugin};
|
use tauri::plugin::{Builder, TauriPlugin};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
|
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
|
||||||
is_dev,
|
generate_handler, is_dev,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
@@ -132,7 +132,7 @@ impl PluginUpdater {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_plugins_search<R: Runtime>(
|
pub(crate) async fn cmd_plugins_search<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<PluginSearchResponse> {
|
) -> Result<PluginSearchResponse> {
|
||||||
@@ -141,7 +141,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_plugins_install<R: Runtime>(
|
pub(crate) async fn cmd_plugins_install<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
name: &str,
|
name: &str,
|
||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
@@ -163,7 +163,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
pub(crate) async fn cmd_plugins_uninstall<R: Runtime>(
|
||||||
plugin_id: &str,
|
plugin_id: &str,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<Plugin> {
|
) -> Result<Plugin> {
|
||||||
@@ -174,7 +174,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
pub(crate) async fn cmd_plugins_updates<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
) -> Result<PluginUpdatesResponse> {
|
) -> Result<PluginUpdatesResponse> {
|
||||||
let http_client = yaak_api_client(&app_handle)?;
|
let http_client = yaak_api_client(&app_handle)?;
|
||||||
@@ -183,7 +183,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_plugins_update_all<R: Runtime>(
|
pub(crate) async fn cmd_plugins_update_all<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<Vec<PluginNameVersion>> {
|
) -> Result<Vec<PluginNameVersion>> {
|
||||||
let http_client = yaak_api_client(window.app_handle())?;
|
let http_client = yaak_api_client(window.app_handle())?;
|
||||||
@@ -233,6 +233,13 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
|
|||||||
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::new("yaak-plugins")
|
Builder::new("yaak-plugins")
|
||||||
|
.invoke_handler(generate_handler![
|
||||||
|
cmd_plugins_search,
|
||||||
|
cmd_plugins_install,
|
||||||
|
cmd_plugins_uninstall,
|
||||||
|
cmd_plugins_updates,
|
||||||
|
cmd_plugins_update_all
|
||||||
|
])
|
||||||
.setup(|app_handle, _| {
|
.setup(|app_handle, _| {
|
||||||
// Resolve paths for plugin manager
|
// Resolve paths for plugin manager
|
||||||
let vendored_plugin_dir = app_handle
|
let vendored_plugin_dir = app_handle
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use std::path::PathBuf;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||||
@@ -12,6 +11,7 @@ use tauri_plugin_updater::{Update, UpdaterExt};
|
|||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use yaak_models::util::generate_id;
|
use yaak_models::util::generate_id;
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::import::import_data;
|
use crate::import::import_data;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
use crate::PluginContextExt;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
|
use yaak_tauri_utils::api_client::yaak_api_client;
|
||||||
use yaak_models::util::generate_id;
|
use yaak_models::util::generate_id;
|
||||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||||
use yaak_plugins::install::download_and_install;
|
use yaak_plugins::install::download_and_install;
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_tauri_utils::api_client::yaak_api_client;
|
|
||||||
|
|
||||||
pub(crate) async fn handle_deep_link<R: Runtime>(
|
pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||||
app_handle: &AppHandle<R>,
|
app_handle: &AppHandle<R>,
|
||||||
@@ -55,8 +55,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
|||||||
&plugin_context,
|
&plugin_context,
|
||||||
name,
|
name,
|
||||||
version,
|
version,
|
||||||
)
|
).await?;
|
||||||
.await?;
|
|
||||||
app_handle.emit(
|
app_handle.emit(
|
||||||
"show_toast",
|
"show_toast",
|
||||||
ShowToastRequest {
|
ShowToastRequest {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
use crate::window_menu::app_menu;
|
use crate::window_menu::app_menu;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use rand::random;
|
use rand::random;
|
||||||
@@ -9,6 +8,7 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
|
||||||
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
|
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
|
||||||
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
|
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! WebSocket Tauri command wrappers
|
//! WebSocket Tauri command wrappers
|
||||||
//! These wrap the core yaak-ws functionality for Tauri IPC.
|
//! These wrap the core yaak-ws functionality for Tauri IPC.
|
||||||
|
|
||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
use crate::PluginContextExt;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -56,10 +56,9 @@ pub async fn cmd_ws_delete_request<R: Runtime>(
|
|||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<WebsocketRequest> {
|
) -> Result<WebsocketRequest> {
|
||||||
Ok(app_handle.db().delete_websocket_request_by_id(
|
Ok(app_handle
|
||||||
request_id,
|
.db()
|
||||||
&UpdateSource::from_window_label(window.label()),
|
.delete_websocket_request_by_id(request_id, &UpdateSource::from_window_label(window.label()))?)
|
||||||
)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -68,10 +67,12 @@ pub async fn cmd_ws_delete_connection<R: Runtime>(
|
|||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<WebsocketConnection> {
|
) -> Result<WebsocketConnection> {
|
||||||
Ok(app_handle.db().delete_websocket_connection_by_id(
|
Ok(app_handle
|
||||||
connection_id,
|
.db()
|
||||||
&UpdateSource::from_window_label(window.label()),
|
.delete_websocket_connection_by_id(
|
||||||
)?)
|
connection_id,
|
||||||
|
&UpdateSource::from_window_label(window.label()),
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -295,10 +296,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for header in plugin_result.set_headers.unwrap_or_default() {
|
for header in plugin_result.set_headers.unwrap_or_default() {
|
||||||
match (
|
match (http::HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value))
|
||||||
http::HeaderName::from_str(&header.name),
|
{
|
||||||
HeaderValue::from_str(&header.value),
|
|
||||||
) {
|
|
||||||
(Ok(name), Ok(value)) => {
|
(Ok(name), Ok(value)) => {
|
||||||
headers.insert(name, value);
|
headers.insert(name, value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
"vendored/protoc/include",
|
"vendored/protoc/include",
|
||||||
"vendored/plugins",
|
"vendored/plugins",
|
||||||
"vendored/plugin-runtime",
|
"vendored/plugin-runtime",
|
||||||
"vendored/node/yaaknode*",
|
"vendored/node/yaaknode",
|
||||||
"vendored/protoc/yaakprotoc*"
|
"vendored/protoc/yaakprotoc"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ use std::time::Duration;
|
|||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_common::platform::get_os_str;
|
use yaak_common::platform::get_os_str;
|
||||||
|
use yaak_tauri_utils::api_client::yaak_api_client;
|
||||||
use yaak_models::db_context::DbContext;
|
use yaak_models::db_context::DbContext;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_tauri_utils::api_client::yaak_api_client;
|
|
||||||
|
|
||||||
/// Extension trait for accessing the QueryManager from Tauri Manager types.
|
/// Extension trait for accessing the QueryManager from Tauri Manager types.
|
||||||
/// This is needed temporarily until all crates are refactored to not use Tauri.
|
/// This is needed temporarily until all crates are refactored to not use Tauri.
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process"] }
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
use std::ffi::OsStr;
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
||||||
|
|
||||||
/// Creates a new `tokio::process::Command` that won't spawn a console window on Windows.
|
|
||||||
pub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Command {
|
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut cmd = tokio::process::Command::new(program);
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
pub mod command;
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod serde;
|
pub mod serde;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["io-util"] }
|
|
||||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
yaak-common = { workspace = true }
|
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-sync = { workspace = true }
|
yaak-sync = { workspace = true }
|
||||||
|
|||||||
Generated
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
@@ -20,4 +18,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ
|
|||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
use crate::error::Error::GitNotFound;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::{Command, Stdio};
|
||||||
use tokio::process::Command;
|
|
||||||
use yaak_common::command::new_xplatform_command;
|
|
||||||
|
|
||||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
use crate::error::Error::GitNotFound;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
|
||||||
|
pub(crate) fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||||
// 1. Probe that `git` exists and is runnable
|
// 1. Probe that `git` exists and is runnable
|
||||||
let mut probe = new_xplatform_command("git");
|
let mut probe = Command::new("git");
|
||||||
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
|
|
||||||
let status = probe.status().await.map_err(|_| GitNotFound)?;
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
probe.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = probe.status().map_err(|_| GitNotFound)?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(GitNotFound);
|
return Err(GitNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build the reusable git command
|
// 2. Build the reusable git command
|
||||||
let mut cmd = new_xplatform_command("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(dir);
|
cmd.arg("-C").arg(dir);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use crate::error::Error::GenericError;
|
|||||||
use log::info;
|
use log::info;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
pub fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
||||||
let out =
|
let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?;
|
||||||
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?;
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::binary::new_binary_command;
|
use crate::binary::new_binary_command;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub async fn git_add_credential(
|
pub async fn git_add_credential(
|
||||||
@@ -18,8 +18,7 @@ pub async fn git_add_credential(
|
|||||||
let host = url.host_str().unwrap();
|
let host = url.host_str().unwrap();
|
||||||
let path = Some(url.path());
|
let path = Some(url.path());
|
||||||
|
|
||||||
let mut child = new_binary_command(dir)
|
let mut child = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["credential", "approve"])
|
.args(["credential", "approve"])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
@@ -27,21 +26,19 @@ pub async fn git_add_credential(
|
|||||||
|
|
||||||
{
|
{
|
||||||
let stdin = child.stdin.as_mut().unwrap();
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?;
|
writeln!(stdin, "protocol={}", protocol)?;
|
||||||
stdin.write_all(format!("host={}\n", host).as_bytes()).await?;
|
writeln!(stdin, "host={}", host)?;
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
if !path.is_empty() {
|
if !path.is_empty() {
|
||||||
stdin
|
writeln!(stdin, "path={}", path.trim_start_matches('/'))?;
|
||||||
.write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes())
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stdin.write_all(format!("username={}\n", username).as_bytes()).await?;
|
writeln!(stdin, "username={}", username)?;
|
||||||
stdin.write_all(format!("password={}\n", password).as_bytes()).await?;
|
writeln!(stdin, "password={}", password)?;
|
||||||
stdin.write_all(b"\n").await?; // blank line terminator
|
writeln!(stdin)?; // blank line terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = child.wait().await?;
|
let status = child.wait()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(GenericError("Failed to approve git credential".to_string()));
|
return Err(GenericError("Failed to approve git credential".to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ use crate::error::Error::GenericError;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub async fn git_fetch_all(dir: &Path) -> Result<()> {
|
pub fn git_fetch_all(dir: &Path) -> Result<()> {
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["fetch", "--all", "--prune", "--tags"])
|
.args(["fetch", "--all", "--prune", "--tags"])
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|||||||
@@ -17,25 +17,17 @@ pub enum PullResult {
|
|||||||
NeedsCredentials { url: String, error: Option<String> },
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
pub fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
// Extract all git2 data before any await points (git2 types are not Send)
|
let repo = open_repo(dir)?;
|
||||||
let (branch_name, remote_name, remote_url) = {
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
let repo = open_repo(dir)?;
|
let remote = get_default_remote_in_repo(&repo)?;
|
||||||
let branch_name = get_current_branch_name(&repo)?;
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
let remote = get_default_remote_in_repo(&repo)?;
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
let remote_name =
|
|
||||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
|
||||||
let remote_url =
|
|
||||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
|
||||||
(branch_name, remote_name, remote_url)
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["pull", &remote_name, &branch_name])
|
.args(["pull", &remote_name, &branch_name])
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
|||||||
@@ -17,25 +17,17 @@ pub enum PushResult {
|
|||||||
NeedsCredentials { url: String, error: Option<String> },
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn git_push(dir: &Path) -> Result<PushResult> {
|
pub fn git_push(dir: &Path) -> Result<PushResult> {
|
||||||
// Extract all git2 data before any await points (git2 types are not Send)
|
let repo = open_repo(dir)?;
|
||||||
let (branch_name, remote_name, remote_url) = {
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
let repo = open_repo(dir)?;
|
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
||||||
let branch_name = get_current_branch_name(&repo)?;
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
let remote_name =
|
|
||||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
|
||||||
let remote_url =
|
|
||||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
|
||||||
(branch_name, remote_name, remote_url)
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = new_binary_command(dir)
|
let out = new_binary_command(dir)?
|
||||||
.await?
|
|
||||||
.args(["push", &remote_name, &branch_name])
|
.args(["push", &remote_name, &branch_name])
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
.output()
|
.output()
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
|||||||
@@ -22,6 +22,5 @@ tokio-stream = "0.1.14"
|
|||||||
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
|
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
|
||||||
tonic-reflection = "0.12.3"
|
tonic-reflection = "0.12.3"
|
||||||
uuid = { version = "1.7.0", features = ["v4"] }
|
uuid = { version = "1.7.0", features = ["v4"] }
|
||||||
yaak-common = { workspace = true }
|
|
||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
|
|||||||
@@ -115,18 +115,14 @@ impl GrpcConnection {
|
|||||||
Ok(client.unary(req, path, codec).await?)
|
Ok(client.unary(req, path, codec).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn streaming<F>(
|
pub async fn streaming(
|
||||||
&self,
|
&self,
|
||||||
service: &str,
|
service: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
stream: ReceiverStream<String>,
|
stream: ReceiverStream<String>,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
on_message: F,
|
) -> Result<Response<Streaming<DynamicMessage>>> {
|
||||||
) -> Result<Response<Streaming<DynamicMessage>>>
|
|
||||||
where
|
|
||||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
|
||||||
{
|
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
let mapped_stream = {
|
let mapped_stream = {
|
||||||
let input_message = method.input();
|
let input_message = method.input();
|
||||||
@@ -135,39 +131,31 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
stream
|
stream.filter_map(move |json| {
|
||||||
.then(move |json| {
|
let pool = pool.clone();
|
||||||
let pool = pool.clone();
|
let uri = uri.clone();
|
||||||
let uri = uri.clone();
|
let input_message = input_message.clone();
|
||||||
let input_message = input_message.clone();
|
let md = md.clone();
|
||||||
let md = md.clone();
|
let use_reflection = use_reflection.clone();
|
||||||
let use_reflection = use_reflection.clone();
|
let client_cert = client_cert.clone();
|
||||||
let client_cert = client_cert.clone();
|
tokio::runtime::Handle::current().block_on(async move {
|
||||||
let on_message = on_message.clone();
|
if use_reflection {
|
||||||
let json_clone = json.clone();
|
if let Err(e) =
|
||||||
async move {
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
if use_reflection {
|
{
|
||||||
if let Err(e) =
|
warn!("Failed to resolve Any types: {e}");
|
||||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
|
||||||
{
|
|
||||||
warn!("Failed to resolve Any types: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mut de = Deserializer::from_str(&json);
|
}
|
||||||
match DynamicMessage::deserialize(input_message, &mut de) {
|
let mut de = Deserializer::from_str(&json);
|
||||||
Ok(m) => {
|
match DynamicMessage::deserialize(input_message, &mut de) {
|
||||||
on_message(Ok(json_clone));
|
Ok(m) => Some(m),
|
||||||
Some(m)
|
Err(e) => {
|
||||||
}
|
warn!("Failed to deserialize message: {e}");
|
||||||
Err(e) => {
|
None
|
||||||
warn!("Failed to deserialize message: {e}");
|
|
||||||
on_message(Err(e.to_string()));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter_map(|x| x)
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
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());
|
||||||
@@ -181,18 +169,14 @@ impl GrpcConnection {
|
|||||||
Ok(client.streaming(req, path, codec).await?)
|
Ok(client.streaming(req, path, codec).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn client_streaming<F>(
|
pub async fn client_streaming(
|
||||||
&self,
|
&self,
|
||||||
service: &str,
|
service: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
stream: ReceiverStream<String>,
|
stream: ReceiverStream<String>,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
on_message: F,
|
) -> Result<Response<DynamicMessage>> {
|
||||||
) -> Result<Response<DynamicMessage>>
|
|
||||||
where
|
|
||||||
F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,
|
|
||||||
{
|
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
let mapped_stream = {
|
let mapped_stream = {
|
||||||
let input_message = method.input();
|
let input_message = method.input();
|
||||||
@@ -201,39 +185,31 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
stream
|
stream.filter_map(move |json| {
|
||||||
.then(move |json| {
|
let pool = pool.clone();
|
||||||
let pool = pool.clone();
|
let uri = uri.clone();
|
||||||
let uri = uri.clone();
|
let input_message = input_message.clone();
|
||||||
let input_message = input_message.clone();
|
let md = md.clone();
|
||||||
let md = md.clone();
|
let use_reflection = use_reflection.clone();
|
||||||
let use_reflection = use_reflection.clone();
|
let client_cert = client_cert.clone();
|
||||||
let client_cert = client_cert.clone();
|
tokio::runtime::Handle::current().block_on(async move {
|
||||||
let on_message = on_message.clone();
|
if use_reflection {
|
||||||
let json_clone = json.clone();
|
if let Err(e) =
|
||||||
async move {
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
if use_reflection {
|
{
|
||||||
if let Err(e) =
|
warn!("Failed to resolve Any types: {e}");
|
||||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
|
||||||
{
|
|
||||||
warn!("Failed to resolve Any types: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mut de = Deserializer::from_str(&json);
|
}
|
||||||
match DynamicMessage::deserialize(input_message, &mut de) {
|
let mut de = Deserializer::from_str(&json);
|
||||||
Ok(m) => {
|
match DynamicMessage::deserialize(input_message, &mut de) {
|
||||||
on_message(Ok(json_clone));
|
Ok(m) => Some(m),
|
||||||
Some(m)
|
Err(e) => {
|
||||||
}
|
warn!("Failed to deserialize message: {e}");
|
||||||
Err(e) => {
|
None
|
||||||
warn!("Failed to deserialize message: {e}");
|
|
||||||
on_message(Err(e.to_string()));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter_map(|x| x)
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
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());
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio::process::Command;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tonic::codegen::http::uri::PathAndQuery;
|
use tonic::codegen::http::uri::PathAndQuery;
|
||||||
use tonic::transport::Uri;
|
use tonic::transport::Uri;
|
||||||
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
|
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
|
||||||
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
|
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
|
||||||
use yaak_common::command::new_xplatform_command;
|
|
||||||
use yaak_tls::ClientCertificateConfig;
|
use yaak_tls::ClientCertificateConfig;
|
||||||
|
|
||||||
pub async fn fill_pool_from_files(
|
pub async fn fill_pool_from_files(
|
||||||
@@ -91,11 +91,11 @@ pub async fn fill_pool_from_files(
|
|||||||
|
|
||||||
info!("Invoking protoc with {}", args.join(" "));
|
info!("Invoking protoc with {}", args.join(" "));
|
||||||
|
|
||||||
let mut cmd = new_xplatform_command(&config.protoc_bin_path);
|
let out = Command::new(&config.protoc_bin_path)
|
||||||
cmd.args(&args);
|
.args(&args)
|
||||||
|
.output()
|
||||||
let out =
|
.await
|
||||||
cmd.output().await.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?;
|
.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?;
|
||||||
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(GenericError(format!(
|
return Err(GenericError(format!(
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ use crate::dns::LocalhostResolver;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use reqwest::{Client, Proxy, redirect};
|
use reqwest::{Client, Proxy, redirect};
|
||||||
use std::sync::Arc;
|
|
||||||
use yaak_models::models::DnsOverride;
|
|
||||||
use yaak_tls::{ClientCertificateConfig, get_tls_config};
|
use yaak_tls::{ClientCertificateConfig, get_tls_config};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -30,14 +28,10 @@ pub struct HttpConnectionOptions {
|
|||||||
pub validate_certificates: bool,
|
pub validate_certificates: bool,
|
||||||
pub proxy: HttpConnectionProxySetting,
|
pub proxy: HttpConnectionProxySetting,
|
||||||
pub client_certificate: Option<ClientCertificateConfig>,
|
pub client_certificate: Option<ClientCertificateConfig>,
|
||||||
pub dns_overrides: Vec<DnsOverride>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpConnectionOptions {
|
impl HttpConnectionOptions {
|
||||||
/// Build a reqwest Client and return it along with the DNS resolver.
|
pub(crate) fn build_client(&self) -> Result<Client> {
|
||||||
/// The resolver is returned separately so it can be configured per-request
|
|
||||||
/// to emit DNS timing events to the appropriate channel.
|
|
||||||
pub(crate) fn build_client(&self) -> Result<(Client, Arc<LocalhostResolver>)> {
|
|
||||||
let mut client = Client::builder()
|
let mut client = Client::builder()
|
||||||
.connection_verbose(true)
|
.connection_verbose(true)
|
||||||
.redirect(redirect::Policy::none())
|
.redirect(redirect::Policy::none())
|
||||||
@@ -46,19 +40,15 @@ impl HttpConnectionOptions {
|
|||||||
.no_brotli()
|
.no_brotli()
|
||||||
.no_deflate()
|
.no_deflate()
|
||||||
.referer(false)
|
.referer(false)
|
||||||
.tls_info(true)
|
.tls_info(true);
|
||||||
// Disable connection pooling to ensure DNS resolution happens on each request
|
|
||||||
// This is needed so we can emit DNS timing events for each request
|
|
||||||
.pool_max_idle_per_host(0);
|
|
||||||
|
|
||||||
// Configure TLS with optional client certificate
|
// Configure TLS with optional client certificate
|
||||||
let config =
|
let config =
|
||||||
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
|
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
|
||||||
client = client.use_preconfigured_tls(config);
|
client = client.use_preconfigured_tls(config);
|
||||||
|
|
||||||
// Configure DNS resolver - keep a reference to configure per-request
|
// Configure DNS resolver
|
||||||
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
|
client = client.dns_resolver(LocalhostResolver::new());
|
||||||
client = client.dns_resolver(resolver.clone());
|
|
||||||
|
|
||||||
// Configure proxy
|
// Configure proxy
|
||||||
match self.proxy.clone() {
|
match self.proxy.clone() {
|
||||||
@@ -79,7 +69,7 @@ impl HttpConnectionOptions {
|
|||||||
self.client_certificate.is_some()
|
self.client_certificate.is_some()
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((client.build()?, resolver))
|
Ok(client.build()?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-143
@@ -1,185 +1,53 @@
|
|||||||
use crate::sender::HttpResponseEvent;
|
|
||||||
use hyper_util::client::legacy::connect::dns::{
|
use hyper_util::client::legacy::connect::dns::{
|
||||||
GaiResolver as HyperGaiResolver, Name as HyperName,
|
GaiResolver as HyperGaiResolver, Name as HyperName,
|
||||||
};
|
};
|
||||||
use log::info;
|
|
||||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::{RwLock, mpsc};
|
|
||||||
use tower_service::Service;
|
use tower_service::Service;
|
||||||
use yaak_models::models::DnsOverride;
|
|
||||||
|
|
||||||
/// Stores resolved addresses for a hostname override
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ResolvedOverride {
|
|
||||||
pub ipv4: Vec<Ipv4Addr>,
|
|
||||||
pub ipv6: Vec<Ipv6Addr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalhostResolver {
|
pub struct LocalhostResolver {
|
||||||
fallback: HyperGaiResolver,
|
fallback: HyperGaiResolver,
|
||||||
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
|
|
||||||
overrides: Arc<HashMap<String, ResolvedOverride>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalhostResolver {
|
impl LocalhostResolver {
|
||||||
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
let resolver = HyperGaiResolver::new();
|
let resolver = HyperGaiResolver::new();
|
||||||
|
Arc::new(Self { fallback: resolver })
|
||||||
// Pre-parse DNS overrides into a lookup map
|
|
||||||
let mut overrides = HashMap::new();
|
|
||||||
for o in dns_overrides {
|
|
||||||
if !o.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let hostname = o.hostname.to_lowercase();
|
|
||||||
|
|
||||||
let ipv4: Vec<Ipv4Addr> =
|
|
||||||
o.ipv4.iter().filter_map(|s| s.parse::<Ipv4Addr>().ok()).collect();
|
|
||||||
|
|
||||||
let ipv6: Vec<Ipv6Addr> =
|
|
||||||
o.ipv6.iter().filter_map(|s| s.parse::<Ipv6Addr>().ok()).collect();
|
|
||||||
|
|
||||||
// Only add if at least one address is valid
|
|
||||||
if !ipv4.is_empty() || !ipv6.is_empty() {
|
|
||||||
overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Arc::new(Self {
|
|
||||||
fallback: resolver,
|
|
||||||
event_tx: Arc::new(RwLock::new(None)),
|
|
||||||
overrides: Arc::new(overrides),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the event sender for the current request.
|
|
||||||
/// This should be called before each request to direct DNS events
|
|
||||||
/// to the appropriate channel.
|
|
||||||
pub async fn set_event_sender(&self, tx: Option<mpsc::Sender<HttpResponseEvent>>) {
|
|
||||||
let mut guard = self.event_tx.write().await;
|
|
||||||
*guard = tx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Resolve for LocalhostResolver {
|
impl Resolve for LocalhostResolver {
|
||||||
fn resolve(&self, name: Name) -> Resolving {
|
fn resolve(&self, name: Name) -> Resolving {
|
||||||
let host = name.as_str().to_lowercase();
|
let host = name.as_str().to_lowercase();
|
||||||
let event_tx = self.event_tx.clone();
|
|
||||||
let overrides = self.overrides.clone();
|
|
||||||
|
|
||||||
info!("DNS resolve called for: {}", host);
|
|
||||||
|
|
||||||
// Check for DNS override first
|
|
||||||
if let Some(resolved) = overrides.get(&host) {
|
|
||||||
log::debug!("DNS override found for: {}", host);
|
|
||||||
let hostname = host.clone();
|
|
||||||
let mut addrs: Vec<SocketAddr> = Vec::new();
|
|
||||||
|
|
||||||
// Add IPv4 addresses
|
|
||||||
for ip in &resolved.ipv4 {
|
|
||||||
addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add IPv6 addresses
|
|
||||||
for ip in &resolved.ipv6 {
|
|
||||||
addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
|
||||||
|
|
||||||
return Box::pin(async move {
|
|
||||||
// Emit DNS event for override
|
|
||||||
let guard = event_tx.read().await;
|
|
||||||
if let Some(tx) = guard.as_ref() {
|
|
||||||
let _ = tx
|
|
||||||
.send(HttpResponseEvent::DnsResolved {
|
|
||||||
hostname,
|
|
||||||
addresses,
|
|
||||||
duration: 0,
|
|
||||||
overridden: true,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for .localhost suffix
|
|
||||||
let is_localhost = host.ends_with(".localhost");
|
let is_localhost = host.ends_with(".localhost");
|
||||||
if is_localhost {
|
if is_localhost {
|
||||||
let hostname = host.clone();
|
|
||||||
// Port 0 is fine; reqwest replaces it with the URL's explicit
|
// Port 0 is fine; reqwest replaces it with the URL's explicit
|
||||||
// port or the scheme's default (80/443, etc.).
|
// port or the scheme’s default (80/443, etc.).
|
||||||
|
// (See docs note below.)
|
||||||
let addrs: Vec<SocketAddr> = vec![
|
let addrs: Vec<SocketAddr> = vec![
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
|
||||||
|
|
||||||
return Box::pin(async move {
|
return Box::pin(async move {
|
||||||
// Emit DNS event for localhost resolution
|
|
||||||
let guard = event_tx.read().await;
|
|
||||||
if let Some(tx) = guard.as_ref() {
|
|
||||||
let _ = tx
|
|
||||||
.send(HttpResponseEvent::DnsResolved {
|
|
||||||
hostname,
|
|
||||||
addresses,
|
|
||||||
duration: 0,
|
|
||||||
overridden: false,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
|
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to system DNS
|
|
||||||
let mut fallback = self.fallback.clone();
|
let mut fallback = self.fallback.clone();
|
||||||
let name_str = name.as_str().to_string();
|
let name_str = name.as_str().to_string();
|
||||||
let hostname = host.clone();
|
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let start = Instant::now();
|
match HyperName::from_str(&name_str) {
|
||||||
|
Ok(n) => fallback
|
||||||
let result = match HyperName::from_str(&name_str) {
|
.call(n)
|
||||||
Ok(n) => fallback.call(n).await,
|
.await
|
||||||
Err(e) => return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
|
.map(|addrs| Box::new(addrs) as Addrs)
|
||||||
};
|
.map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
|
||||||
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
|
||||||
let duration = start.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(addrs) => {
|
|
||||||
// Collect addresses for event emission
|
|
||||||
let addr_vec: Vec<SocketAddr> = addrs.collect();
|
|
||||||
let addresses: Vec<String> =
|
|
||||||
addr_vec.iter().map(|a| a.ip().to_string()).collect();
|
|
||||||
|
|
||||||
// Emit DNS event
|
|
||||||
let guard = event_tx.read().await;
|
|
||||||
if let Some(tx) = guard.as_ref() {
|
|
||||||
let _ = tx
|
|
||||||
.send(HttpResponseEvent::DnsResolved {
|
|
||||||
hostname,
|
|
||||||
addresses,
|
|
||||||
duration,
|
|
||||||
overridden: false,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Box::new(addr_vec.into_iter()) as Addrs)
|
|
||||||
}
|
|
||||||
Err(err) => Err(Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::client::HttpConnectionOptions;
|
use crate::client::HttpConnectionOptions;
|
||||||
use crate::dns::LocalhostResolver;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use log::info;
|
use log::info;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -8,15 +7,8 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// A cached HTTP client along with its DNS resolver.
|
|
||||||
/// The resolver is needed to set the event sender per-request.
|
|
||||||
pub struct CachedClient {
|
|
||||||
pub client: Client,
|
|
||||||
pub resolver: Arc<LocalhostResolver>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct HttpConnectionManager {
|
pub struct HttpConnectionManager {
|
||||||
connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>,
|
connections: Arc<RwLock<BTreeMap<String, (Client, Instant)>>>,
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,26 +20,21 @@ impl HttpConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
|
||||||
let mut connections = self.connections.write().await;
|
let mut connections = self.connections.write().await;
|
||||||
let id = opt.id.clone();
|
let id = opt.id.clone();
|
||||||
|
|
||||||
// Clean old connections
|
// Clean old connections
|
||||||
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
||||||
|
|
||||||
if let Some((cached, last_used)) = connections.get_mut(&id) {
|
if let Some((c, last_used)) = connections.get_mut(&id) {
|
||||||
info!("Re-using HTTP client {id}");
|
info!("Re-using HTTP client {id}");
|
||||||
*last_used = Instant::now();
|
*last_used = Instant::now();
|
||||||
return Ok(CachedClient {
|
return Ok(c.clone());
|
||||||
client: cached.client.clone(),
|
|
||||||
resolver: cached.resolver.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (client, resolver) = opt.build_client()?;
|
let c = opt.build_client()?;
|
||||||
let cached = CachedClient { client: client.clone(), resolver: resolver.clone() };
|
connections.insert(id.into(), (c.clone(), Instant::now()));
|
||||||
connections.insert(id.into(), (cached, Instant::now()));
|
Ok(c)
|
||||||
|
|
||||||
Ok(CachedClient { client, resolver })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ pub enum HttpResponseEvent {
|
|||||||
ChunkReceived {
|
ChunkReceived {
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
},
|
},
|
||||||
DnsResolved {
|
|
||||||
hostname: String,
|
|
||||||
addresses: Vec<String>,
|
|
||||||
duration: u64,
|
|
||||||
overridden: bool,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for HttpResponseEvent {
|
impl Display for HttpResponseEvent {
|
||||||
@@ -73,19 +67,6 @@ impl Display for HttpResponseEvent {
|
|||||||
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
|
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
|
||||||
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
|
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
|
||||||
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
|
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
|
||||||
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
|
|
||||||
if *overridden {
|
|
||||||
write!(f, "* DNS override {} -> {}", hostname, addresses.join(", "))
|
|
||||||
} else {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"* DNS resolved {} to {} ({}ms)",
|
|
||||||
hostname,
|
|
||||||
addresses.join(", "),
|
|
||||||
duration
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,9 +93,6 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
|||||||
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
|
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
|
||||||
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
|
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
|
||||||
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
|
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
|
||||||
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
|
|
||||||
D::DnsResolved { hostname, addresses, duration, overridden }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,8 +342,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_transaction_single_redirect() {
|
async fn test_transaction_single_redirect() {
|
||||||
let redirect_headers =
|
let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
|
||||||
vec![("Location".to_string(), "https://example.com/new".to_string())];
|
|
||||||
|
|
||||||
let responses = vec![
|
let responses = vec![
|
||||||
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
|
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
|
||||||
@@ -374,8 +373,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_transaction_max_redirects_exceeded() {
|
async fn test_transaction_max_redirects_exceeded() {
|
||||||
let redirect_headers =
|
let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
|
||||||
vec![("Location".to_string(), "https://example.com/loop".to_string())];
|
|
||||||
|
|
||||||
// Create more redirects than allowed
|
// Create more redirects than allowed
|
||||||
let responses: Vec<MockResponse> = (0..12)
|
let responses: Vec<MockResponse> = (0..12)
|
||||||
@@ -527,8 +525,7 @@ mod tests {
|
|||||||
_request: SendableHttpRequest,
|
_request: SendableHttpRequest,
|
||||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let headers =
|
let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
||||||
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
|
||||||
|
|
||||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||||
Box::pin(std::io::Cursor::new(vec![]));
|
Box::pin(std::io::Cursor::new(vec![]));
|
||||||
@@ -587,10 +584,7 @@ mod tests {
|
|||||||
let headers = vec![
|
let headers = vec![
|
||||||
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
|
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
|
||||||
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
|
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
|
||||||
(
|
("set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string()),
|
||||||
"set-cookie".to_string(),
|
|
||||||
"preferences=dark; Path=/; Max-Age=86400".to_string(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||||
|
|||||||
+3
-5
@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
|||||||
|
|
||||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
|
||||||
|
|
||||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
export type EncryptedKey = { encryptedKey: string, };
|
||||||
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
|
|
||||||
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
|||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
@@ -93,6 +91,6 @@ export type WebsocketMessageType = "text" | "binary";
|
|||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||||
|
|
||||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||||
|
|||||||
@@ -206,22 +206,6 @@ export function replaceModelsInStore<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeModelsInStore<
|
|
||||||
M extends AnyModel['model'],
|
|
||||||
T extends Extract<AnyModel, { model: M }>,
|
|
||||||
>(model: M, models: T[]) {
|
|
||||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
|
||||||
const existingModels = { ...prev[model] } as Record<string, T>;
|
|
||||||
for (const m of models) {
|
|
||||||
existingModels[m.id] = m;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[model]: existingModels,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
|
function shouldIgnoreModel({ model, updateSource }: ModelPayload) {
|
||||||
// Never ignore updates from non-user sources
|
// Never ignore updates from non-user sources
|
||||||
if (updateSource.type !== 'window') {
|
if (updateSource.type !== 'window') {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add DNS resolution timing to http_responses
|
|
||||||
ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add DNS overrides setting to workspaces
|
|
||||||
ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL;
|
|
||||||
@@ -73,20 +73,6 @@ pub struct ClientCertificate {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
pub struct DnsOverride {
|
|
||||||
pub hostname: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub ipv4: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub ipv6: Vec<String>,
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
#[ts(optional, as = "Option<bool>")]
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
@@ -317,8 +303,6 @@ pub struct Workspace {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub setting_follow_redirects: bool,
|
pub setting_follow_redirects: bool,
|
||||||
pub setting_request_timeout: i32,
|
pub setting_request_timeout: i32,
|
||||||
#[serde(default)]
|
|
||||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Workspace {
|
impl UpsertModelInfo for Workspace {
|
||||||
@@ -359,7 +343,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +359,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
WorkspaceIden::SettingFollowRedirects,
|
WorkspaceIden::SettingFollowRedirects,
|
||||||
WorkspaceIden::SettingRequestTimeout,
|
WorkspaceIden::SettingRequestTimeout,
|
||||||
WorkspaceIden::SettingValidateCertificates,
|
WorkspaceIden::SettingValidateCertificates,
|
||||||
WorkspaceIden::SettingDnsOverrides,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +368,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
{
|
{
|
||||||
let headers: String = row.get("headers")?;
|
let headers: String = row.get("headers")?;
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -401,7 +382,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||||
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
||||||
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1353,7 +1333,6 @@ pub struct HttpResponse {
|
|||||||
pub content_length_compressed: Option<i32>,
|
pub content_length_compressed: Option<i32>,
|
||||||
pub elapsed: i32,
|
pub elapsed: i32,
|
||||||
pub elapsed_headers: i32,
|
pub elapsed_headers: i32,
|
||||||
pub elapsed_dns: i32,
|
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub headers: Vec<HttpResponseHeader>,
|
pub headers: Vec<HttpResponseHeader>,
|
||||||
pub remote_addr: Option<String>,
|
pub remote_addr: Option<String>,
|
||||||
@@ -1402,7 +1381,6 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
(ContentLengthCompressed, self.content_length_compressed.into()),
|
(ContentLengthCompressed, self.content_length_compressed.into()),
|
||||||
(Elapsed, self.elapsed.into()),
|
(Elapsed, self.elapsed.into()),
|
||||||
(ElapsedHeaders, self.elapsed_headers.into()),
|
(ElapsedHeaders, self.elapsed_headers.into()),
|
||||||
(ElapsedDns, self.elapsed_dns.into()),
|
|
||||||
(Error, self.error.into()),
|
(Error, self.error.into()),
|
||||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||||
(RemoteAddr, self.remote_addr.into()),
|
(RemoteAddr, self.remote_addr.into()),
|
||||||
@@ -1424,7 +1402,6 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
HttpResponseIden::ContentLengthCompressed,
|
HttpResponseIden::ContentLengthCompressed,
|
||||||
HttpResponseIden::Elapsed,
|
HttpResponseIden::Elapsed,
|
||||||
HttpResponseIden::ElapsedHeaders,
|
HttpResponseIden::ElapsedHeaders,
|
||||||
HttpResponseIden::ElapsedDns,
|
|
||||||
HttpResponseIden::Error,
|
HttpResponseIden::Error,
|
||||||
HttpResponseIden::Headers,
|
HttpResponseIden::Headers,
|
||||||
HttpResponseIden::RemoteAddr,
|
HttpResponseIden::RemoteAddr,
|
||||||
@@ -1458,7 +1435,6 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
version: r.get("version")?,
|
version: r.get("version")?,
|
||||||
elapsed: r.get("elapsed")?,
|
elapsed: r.get("elapsed")?,
|
||||||
elapsed_headers: r.get("elapsed_headers")?,
|
elapsed_headers: r.get("elapsed_headers")?,
|
||||||
elapsed_dns: r.get("elapsed_dns").unwrap_or_default(),
|
|
||||||
remote_addr: r.get("remote_addr")?,
|
remote_addr: r.get("remote_addr")?,
|
||||||
status: r.get("status")?,
|
status: r.get("status")?,
|
||||||
status_reason: r.get("status_reason")?,
|
status_reason: r.get("status_reason")?,
|
||||||
@@ -1515,12 +1491,6 @@ pub enum HttpResponseEventData {
|
|||||||
ChunkReceived {
|
ChunkReceived {
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
},
|
},
|
||||||
DnsResolved {
|
|
||||||
hostname: String,
|
|
||||||
addresses: Vec<String>,
|
|
||||||
duration: u64,
|
|
||||||
overridden: bool,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HttpResponseEventData {
|
impl Default for HttpResponseEventData {
|
||||||
|
|||||||
@@ -65,28 +65,7 @@ impl<'a> DbContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||||
let mut workspace = w.clone();
|
self.upsert(w, source)
|
||||||
|
|
||||||
// Add default headers only for NEW workspaces (empty ID means insert, not update)
|
|
||||||
// This prevents re-adding headers if a user intentionally removes all headers
|
|
||||||
if workspace.id.is_empty() && workspace.headers.is_empty() {
|
|
||||||
workspace.headers = vec![
|
|
||||||
HttpRequestHeader {
|
|
||||||
enabled: true,
|
|
||||||
name: "User-Agent".to_string(),
|
|
||||||
value: "yaak".to_string(),
|
|
||||||
id: None,
|
|
||||||
},
|
|
||||||
HttpRequestHeader {
|
|
||||||
enabled: true,
|
|
||||||
name: "Accept".to_string(),
|
|
||||||
value: "*/*".to_string(),
|
|
||||||
id: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
self.upsert(&workspace, source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_auth_for_workspace(
|
pub fn resolve_auth_for_workspace(
|
||||||
|
|||||||
+3
-5
@@ -12,8 +12,6 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
|||||||
|
|
||||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
|
||||||
|
|
||||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
export type EncryptedKey = { encryptedKey: string, };
|
||||||
@@ -40,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
|
|
||||||
@@ -49,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
|||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
@@ -79,6 +77,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
|
|||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||||
|
|
||||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ pub async fn check_plugin_updates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search for plugins in the registry.
|
/// Search for plugins in the registry.
|
||||||
pub async fn search_plugins(http_client: &Client, query: &str) -> Result<PluginSearchResponse> {
|
pub async fn search_plugins(
|
||||||
|
http_client: &Client,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<PluginSearchResponse> {
|
||||||
let mut url = build_url("/search");
|
let mut url = build_url("/search");
|
||||||
{
|
{
|
||||||
let mut query_pairs = url.query_pairs_mut();
|
let mut query_pairs = url.query_pairs_mut();
|
||||||
|
|||||||
@@ -378,8 +378,7 @@ impl PluginManager {
|
|||||||
plugins: Vec<PluginHandle>,
|
plugins: Vec<PluginHandle>,
|
||||||
timeout_duration: Duration,
|
timeout_duration: Duration,
|
||||||
) -> Result<Vec<InternalEvent>> {
|
) -> Result<Vec<InternalEvent>> {
|
||||||
let event_type = payload.type_name();
|
let label = format!("wait[{}.{}]", plugins.len(), payload.type_name());
|
||||||
let label = format!("wait[{}.{}]", plugins.len(), event_type);
|
|
||||||
let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
|
let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
|
||||||
|
|
||||||
// 1. Build the events with IDs and everything
|
// 1. Build the events with IDs and everything
|
||||||
@@ -413,21 +412,10 @@ impl PluginManager {
|
|||||||
|
|
||||||
// Timeout to prevent hanging forever if plugin doesn't respond
|
// Timeout to prevent hanging forever if plugin doesn't respond
|
||||||
if timeout(timeout_duration, collect_events).await.is_err() {
|
if timeout(timeout_duration, collect_events).await.is_err() {
|
||||||
let responded_ids: Vec<&String> =
|
|
||||||
found_events.iter().filter_map(|e| e.reply_id.as_ref()).collect();
|
|
||||||
let non_responding: Vec<&str> = events_to_send
|
|
||||||
.iter()
|
|
||||||
.filter(|e| !responded_ids.contains(&&e.id))
|
|
||||||
.map(|e| e.plugin_name.as_str())
|
|
||||||
.collect();
|
|
||||||
warn!(
|
warn!(
|
||||||
"Timeout ({:?}) waiting for {} responses. Got {}/{} responses. \
|
"Timeout waiting for plugin responses. Got {}/{} responses",
|
||||||
Non-responding plugins: [{}]",
|
|
||||||
timeout_duration,
|
|
||||||
event_type,
|
|
||||||
found_events.len(),
|
found_events.len(),
|
||||||
events_to_send.len(),
|
events_to_send.len()
|
||||||
non_responding.join(", ")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,11 +196,7 @@ pub fn decrypt_secure_template_function(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
new_tokens.push(Token::Raw {
|
new_tokens.push(Token::Raw {
|
||||||
text: template_function_secure_run(
|
text: template_function_secure_run(encryption_manager, args_map, plugin_context)?,
|
||||||
encryption_manager,
|
|
||||||
args_map,
|
|
||||||
plugin_context,
|
|
||||||
)?,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
t => {
|
t => {
|
||||||
@@ -220,8 +216,7 @@ pub fn encrypt_secure_template_function(
|
|||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
template: &str,
|
template: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let decrypted =
|
let decrypted = decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
|
||||||
decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
|
|
||||||
let tokens = Tokens {
|
let tokens = Tokens {
|
||||||
tokens: vec. Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
@@ -22,4 +20,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string,
|
|||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||||
|
|||||||
@@ -296,7 +296,11 @@ pub fn compute_sync_ops(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_models(db: &DbContext, version: &str, workspace_id: &str) -> Result<Vec<SyncModel>> {
|
fn workspace_models(
|
||||||
|
db: &DbContext,
|
||||||
|
version: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<Vec<SyncModel>> {
|
||||||
// We want to include private environments here so that we can take them into account during
|
// We want to include private environments here so that we can take them into account during
|
||||||
// the sync process. Otherwise, they would be treated as deleted.
|
// the sync process. Otherwise, they would be treated as deleted.
|
||||||
let include_private_environments = true;
|
let include_private_environments = true;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use crate::connect::ws_connect;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use futures_util::stream::SplitSink;
|
use futures_util::stream::SplitSink;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use http::HeaderMap;
|
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -11,6 +10,7 @@ use tokio::net::TcpStream;
|
|||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tokio_tungstenite::tungstenite::handshake::client::Response;
|
use tokio_tungstenite::tungstenite::handshake::client::Response;
|
||||||
|
use http::HeaderMap;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||||
use yaak_tls::ClientCertificateConfig;
|
use yaak_tls::ClientCertificateConfig;
|
||||||
|
|||||||
+2
-2
@@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string
|
|||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
|||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
|
|
||||||
import { patchModel } from '@yaakapp-internal/models';
|
|
||||||
import { useCallback, useId, useMemo } from 'react';
|
|
||||||
import { Button } from './core/Button';
|
|
||||||
import { Checkbox } from './core/Checkbox';
|
|
||||||
import { IconButton } from './core/IconButton';
|
|
||||||
import { PlainInput } from './core/PlainInput';
|
|
||||||
import { HStack, VStack } from './core/Stacks';
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
workspace: Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DnsOverrideWithId extends DnsOverride {
|
|
||||||
_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DnsOverridesEditor({ workspace }: Props) {
|
|
||||||
const reactId = useId();
|
|
||||||
|
|
||||||
// Ensure each override has an internal ID for React keys
|
|
||||||
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
|
|
||||||
return workspace.settingDnsOverrides.map((override, index) => ({
|
|
||||||
...override,
|
|
||||||
_id: `${reactId}-${index}`,
|
|
||||||
}));
|
|
||||||
}, [workspace.settingDnsOverrides, reactId]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(overrides: DnsOverride[]) => {
|
|
||||||
patchModel(workspace, { settingDnsOverrides: overrides });
|
|
||||||
},
|
|
||||||
[workspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAdd = useCallback(() => {
|
|
||||||
const newOverride: DnsOverride = {
|
|
||||||
hostname: '',
|
|
||||||
ipv4: [''],
|
|
||||||
ipv6: [],
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
handleChange([...workspace.settingDnsOverrides, newOverride]);
|
|
||||||
}, [workspace.settingDnsOverrides, handleChange]);
|
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
|
||||||
(index: number, update: Partial<DnsOverride>) => {
|
|
||||||
const updated = workspace.settingDnsOverrides.map((o, i) =>
|
|
||||||
i === index ? { ...o, ...update } : o,
|
|
||||||
);
|
|
||||||
handleChange(updated);
|
|
||||||
},
|
|
||||||
[workspace.settingDnsOverrides, handleChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
|
|
||||||
handleChange(updated);
|
|
||||||
},
|
|
||||||
[workspace.settingDnsOverrides, handleChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={3} className="pb-3">
|
|
||||||
<div className="text-text-subtle text-sm">
|
|
||||||
Override DNS resolution for specific hostnames. This works like{' '}
|
|
||||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
|
|
||||||
but only for requests made from this workspace.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{overridesWithIds.length > 0 && (
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell className="w-8" />
|
|
||||||
<TableHeaderCell>Hostname</TableHeaderCell>
|
|
||||||
<TableHeaderCell>IPv4 Address</TableHeaderCell>
|
|
||||||
<TableHeaderCell>IPv6 Address</TableHeaderCell>
|
|
||||||
<TableHeaderCell className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{overridesWithIds.map((override, index) => (
|
|
||||||
<DnsOverrideRow
|
|
||||||
key={override._id}
|
|
||||||
override={override}
|
|
||||||
onUpdate={(update) => handleUpdate(index, update)}
|
|
||||||
onDelete={() => handleDelete(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HStack>
|
|
||||||
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
|
|
||||||
Add DNS Override
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DnsOverrideRowProps {
|
|
||||||
override: DnsOverride;
|
|
||||||
onUpdate: (update: Partial<DnsOverride>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
|
|
||||||
const ipv4Value = override.ipv4.join(', ');
|
|
||||||
const ipv6Value = override.ipv6.join(', ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title={override.enabled ? 'Disable override' : 'Enable override'}
|
|
||||||
checked={override.enabled ?? true}
|
|
||||||
onChange={(enabled) => onUpdate({ enabled })}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="Hostname"
|
|
||||||
placeholder="api.example.com"
|
|
||||||
defaultValue={override.hostname}
|
|
||||||
onChange={(hostname) => onUpdate({ hostname })}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="IPv4 addresses"
|
|
||||||
placeholder="127.0.0.1"
|
|
||||||
defaultValue={ipv4Value}
|
|
||||||
onChange={(value) =>
|
|
||||||
onUpdate({
|
|
||||||
ipv4: value
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
hideLabel
|
|
||||||
label="IPv6 addresses"
|
|
||||||
placeholder="::1"
|
|
||||||
defaultValue={ipv6Value}
|
|
||||||
onChange={(value) =>
|
|
||||||
onUpdate({
|
|
||||||
ipv6: value
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
icon="trash"
|
|
||||||
title="Delete override"
|
|
||||||
onClick={onDelete}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
stateExtensions,
|
stateExtensions,
|
||||||
updateSchema,
|
updateSchema,
|
||||||
} from 'codemirror-json-schema';
|
} from 'codemirror-json-schema';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||||
import { showAlert } from '../lib/alert';
|
import { showAlert } from '../lib/alert';
|
||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../lib/dialog';
|
||||||
@@ -39,15 +39,15 @@ export function GrpcEditor({
|
|||||||
protoFiles,
|
protoFiles,
|
||||||
...extraEditorProps
|
...extraEditorProps
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
const editorViewRef = useRef<EditorView>(null);
|
||||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||||
setEditorView(h);
|
editorViewRef.current = h;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find the schema for the selected service and method and update the editor
|
// Find the schema for the selected service and method and update the editor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
editorView == null ||
|
editorViewRef.current == null ||
|
||||||
services === null ||
|
services === null ||
|
||||||
request.service === null ||
|
request.service === null ||
|
||||||
request.method === null
|
request.method === null
|
||||||
@@ -91,7 +91,7 @@ export function GrpcEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateSchema(editorView, JSON.parse(schema));
|
updateSchema(editorViewRef.current, JSON.parse(schema));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert({
|
showAlert({
|
||||||
id: 'grpc-parse-schema-error',
|
id: 'grpc-parse-schema-error',
|
||||||
@@ -107,7 +107,7 @@ export function GrpcEditor({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [editorView, services, request.method, request.service]);
|
}, [services, request.method, request.service]);
|
||||||
|
|
||||||
const extraExtensions = useMemo(
|
const extraExtensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -118,7 +118,7 @@ export function GrpcEditor({
|
|||||||
jsonLanguage.data.of({
|
jsonLanguage.data.of({
|
||||||
autocomplete: jsonCompletion(),
|
autocomplete: jsonCompletion(),
|
||||||
}),
|
}),
|
||||||
stateExtensions({}),
|
stateExtensions(/** Init with empty schema **/),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
activeGrpcConnectionAtom,
|
activeGrpcConnectionAtom,
|
||||||
activeGrpcConnections,
|
activeGrpcConnections,
|
||||||
@@ -9,14 +11,18 @@ import {
|
|||||||
useGrpcEvents,
|
useGrpcEvents,
|
||||||
} from '../hooks/usePinnedGrpcConnection';
|
} from '../hooks/usePinnedGrpcConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
|
import { copyToClipboard } from '../lib/copy';
|
||||||
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
import { EventDetailHeader, EventViewer } from './core/EventViewer';
|
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
|
||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
@@ -36,7 +42,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const connections = useAtomValue(activeGrpcConnections);
|
const connections = useAtomValue(activeGrpcConnections);
|
||||||
@@ -45,8 +51,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
const activeEvent = useMemo(
|
||||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||||
[activeEventIndex, events],
|
[activeEventId, events],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set the active message to the first message received if unary
|
// Set the active message to the first message received if unary
|
||||||
@@ -55,188 +61,223 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
|
setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null);
|
||||||
if (firstServerMessageIndex !== -1) {
|
|
||||||
setActiveEventIndex(firstServerMessageIndex);
|
|
||||||
}
|
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
if (activeConnection == null) {
|
|
||||||
return (
|
|
||||||
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
|
||||||
<HStack space={2}>
|
|
||||||
<span className="whitespace-nowrap">{events.length} Messages</span>
|
|
||||||
{activeConnection.state !== 'closed' && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<RecentGrpcConnectionsDropdown
|
|
||||||
connections={connections}
|
|
||||||
activeConnection={activeConnection}
|
|
||||||
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style} className="h-full">
|
<SplitLayout
|
||||||
<ErrorBoundary name="GRPC Events">
|
layout="vertical"
|
||||||
<EventViewer
|
style={style}
|
||||||
events={events}
|
name="grpc_events"
|
||||||
getEventKey={(event) => event.id}
|
defaultRatio={0.4}
|
||||||
error={activeConnection.error}
|
minHeightPx={20}
|
||||||
header={header}
|
firstSlot={() =>
|
||||||
splitLayoutName="grpc_events"
|
activeConnection == null ? (
|
||||||
defaultRatio={0.4}
|
<HotkeyList
|
||||||
renderRow={({ event, isActive, onClick }) => (
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
/>
|
||||||
)}
|
) : (
|
||||||
renderDetail={({ event }) => (
|
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
||||||
<GrpcEventDetail
|
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
||||||
event={event}
|
<HStack space={2}>
|
||||||
showLarge={showLarge}
|
<span className="whitespace-nowrap">{events.length} Messages</span>
|
||||||
showingLarge={showingLarge}
|
{activeConnection.state !== 'closed' && (
|
||||||
setShowLarge={setShowLarge}
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
setShowingLarge={setShowingLarge}
|
)}
|
||||||
/>
|
</HStack>
|
||||||
)}
|
<div className="ml-auto">
|
||||||
/>
|
<RecentGrpcConnectionsDropdown
|
||||||
</ErrorBoundary>
|
connections={connections}
|
||||||
</div>
|
activeConnection={activeConnection}
|
||||||
);
|
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
function GrpcEventRow({
|
</HStack>
|
||||||
event,
|
<ErrorBoundary name="GRPC Events">
|
||||||
isActive,
|
<AutoScroller
|
||||||
onClick,
|
data={events}
|
||||||
}: {
|
header={
|
||||||
event: GrpcEvent;
|
activeConnection.error && (
|
||||||
isActive: boolean;
|
<Banner color="danger" className="m-3">
|
||||||
onClick: () => void;
|
{activeConnection.error}
|
||||||
}) {
|
</Banner>
|
||||||
const { eventType, status, content, error } = event;
|
)
|
||||||
const display = getEventDisplay(eventType, status);
|
}
|
||||||
|
render={(event) => (
|
||||||
return (
|
<EventRow
|
||||||
<EventViewerRow
|
key={event.id}
|
||||||
isActive={isActive}
|
event={event}
|
||||||
onClick={onClick}
|
isActive={event.id === activeEventId}
|
||||||
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
onClick={() => {
|
||||||
content={
|
if (event.id === activeEventId) setActiveEventId(null);
|
||||||
<span className="text-xs">
|
else setActiveEventId(event.id);
|
||||||
{content.slice(0, 1000)}
|
}}
|
||||||
{error && <span className="text-warning"> ({error})</span>}
|
/>
|
||||||
</span>
|
)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondSlot={
|
||||||
|
activeEvent != null && activeConnection != null
|
||||||
|
? () => (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
|
||||||
|
{activeEvent.eventType === 'client_message' ||
|
||||||
|
activeEvent.eventType === 'server_message' ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||||
|
<div className="font-semibold">
|
||||||
|
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
title="Copy message"
|
||||||
|
icon="copy"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => copyToClipboard(activeEvent.content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
|
||||||
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
|
Message previews larger than 1MB are hidden
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowingLarge(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowLarge(true);
|
||||||
|
setShowingLarge(false);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
isLoading={showingLarge}
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
language="json"
|
||||||
|
defaultValue={activeEvent.content ?? ''}
|
||||||
|
wrapLines={false}
|
||||||
|
readOnly={true}
|
||||||
|
stateKey={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div>
|
||||||
|
<div className="select-text cursor-text font-semibold">
|
||||||
|
{activeEvent.content}
|
||||||
|
</div>
|
||||||
|
{activeEvent.error && (
|
||||||
|
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
||||||
|
{activeEvent.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="py-2 h-full">
|
||||||
|
{Object.keys(activeEvent.metadata).length === 0 ? (
|
||||||
|
<EmptyStateText>
|
||||||
|
No{' '}
|
||||||
|
{activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
|
||||||
|
</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<KeyValueRows>
|
||||||
|
{Object.entries(activeEvent.metadata).map(([key, value]) => (
|
||||||
|
<KeyValueRow key={key} label={key}>
|
||||||
|
{value}
|
||||||
|
</KeyValueRow>
|
||||||
|
))}
|
||||||
|
</KeyValueRows>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
timestamp={event.createdAt}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GrpcEventDetail({
|
function EventRow({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
event,
|
event,
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
}: {
|
}: {
|
||||||
|
onClick?: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
event: GrpcEvent;
|
event: GrpcEvent;
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
const { eventType, status, createdAt, content, error } = event;
|
||||||
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
|
|
||||||
{!showLarge && event.content.length > 1000 * 1000 ? (
|
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
|
||||||
Message previews larger than 1MB are hidden
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowingLarge(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
isLoading={showingLarge}
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<Editor
|
|
||||||
language="json"
|
|
||||||
defaultValue={event.content ?? ''}
|
|
||||||
wrapLines={false}
|
|
||||||
readOnly={true}
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error or connection_end - show metadata/trailers
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="px-1" ref={ref}>
|
||||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
|
<button
|
||||||
{event.error && (
|
type="button"
|
||||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
onClick={onClick}
|
||||||
{event.error}
|
className={classNames(
|
||||||
</div>
|
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
||||||
)}
|
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
<div className="py-2 h-full">
|
isActive && '!bg-surface-active !text-text',
|
||||||
{Object.keys(event.metadata).length === 0 ? (
|
'text-text-subtle hover:text',
|
||||||
<EmptyStateText>
|
|
||||||
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{Object.entries(event.metadata).map(([key, value]) => (
|
|
||||||
<KeyValueRow key={key} label={key}>
|
|
||||||
{value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
<Icon
|
||||||
|
color={
|
||||||
|
eventType === 'server_message'
|
||||||
|
? 'info'
|
||||||
|
: eventType === 'client_message'
|
||||||
|
? 'primary'
|
||||||
|
: eventType === 'error' || (status != null && status > 0)
|
||||||
|
? 'danger'
|
||||||
|
: eventType === 'connection_end'
|
||||||
|
? 'success'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
eventType === 'server_message'
|
||||||
|
? 'Server message'
|
||||||
|
: eventType === 'client_message'
|
||||||
|
? 'Client message'
|
||||||
|
: eventType === 'error' || (status != null && status > 0)
|
||||||
|
? 'Error'
|
||||||
|
: eventType === 'connection_end'
|
||||||
|
? 'Connection response'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
eventType === 'server_message'
|
||||||
|
? 'arrow_big_down_dash'
|
||||||
|
: eventType === 'client_message'
|
||||||
|
? 'arrow_big_up_dash'
|
||||||
|
: eventType === 'error' || (status != null && status > 0)
|
||||||
|
? 'alert_triangle'
|
||||||
|
: eventType === 'connection_end'
|
||||||
|
? 'check'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={classNames('w-full truncate text-xs')}>
|
||||||
|
{content.slice(0, 1000)}
|
||||||
|
{error && <span className="text-warning"> ({error})</span>}
|
||||||
|
</div>
|
||||||
|
<div className={classNames('opacity-50 text-xs')}>
|
||||||
|
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventDisplay(
|
|
||||||
eventType: GrpcEvent['eventType'],
|
|
||||||
status: GrpcEvent['status'],
|
|
||||||
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
|
|
||||||
if (eventType === 'server_message') {
|
|
||||||
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
|
|
||||||
}
|
|
||||||
if (eventType === 'client_message') {
|
|
||||||
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
|
|
||||||
}
|
|
||||||
if (eventType === 'error' || (status != null && status > 0)) {
|
|
||||||
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
|
|
||||||
}
|
|
||||||
if (eventType === 'connection_end') {
|
|
||||||
return { icon: 'check', color: 'success', title: 'Connection response' };
|
|
||||||
}
|
|
||||||
return { icon: 'info', color: undefined, title: 'Event' };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,16 +33,9 @@ export function HeadersEditor({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const validInheritedHeaders =
|
const validInheritedHeaders =
|
||||||
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
|
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
|
||||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
|
||||||
className={
|
{validInheritedHeaders.length > 0 ? (
|
||||||
hasInheritedHeaders
|
|
||||||
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
|
|
||||||
: '@container w-full h-full'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasInheritedHeaders && (
|
|
||||||
<DetailsBanner
|
<DetailsBanner
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
@@ -70,6 +63,8 @@ export function HeadersEditor({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
)}
|
)}
|
||||||
<PairOrBulkEditor
|
<PairOrBulkEditor
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
label="Request"
|
label="Request"
|
||||||
onChangeValue={setActiveTab}
|
onChangeValue={setActiveTab}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
tabListClassName="mt-1 -mb-1.5"
|
tabListClassName="mt-1 mb-1.5"
|
||||||
storageKey="http_request_tabs_order"
|
storageKey="http_request_tabs_order"
|
||||||
>
|
>
|
||||||
<TabContent value={TAB_AUTH}>
|
<TabContent value={TAB_AUTH}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
|||||||
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||||
import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
|
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -67,10 +67,20 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
|
||||||
const cookieCounts = useMemo(
|
const cookieCount = useMemo(() => {
|
||||||
() => getCookieCounts(responseEvents.data),
|
if (!responseEvents.data) return 0;
|
||||||
[responseEvents.data],
|
let count = 0;
|
||||||
);
|
for (const event of responseEvents.data) {
|
||||||
|
const e = event.event;
|
||||||
|
if (
|
||||||
|
(e.type === 'header_up' && e.name.toLowerCase() === 'cookie') ||
|
||||||
|
(e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie')
|
||||||
|
) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [responseEvents.data]);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -97,19 +107,15 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
label: 'Headers',
|
label: 'Headers',
|
||||||
rightSlot: (
|
rightSlot: (
|
||||||
<CountBadge
|
<CountBadge
|
||||||
count={activeResponse?.requestHeaders.length ?? 0}
|
|
||||||
count2={activeResponse?.headers.length ?? 0}
|
count2={activeResponse?.headers.length ?? 0}
|
||||||
showZero
|
count={activeResponse?.requestHeaders.length ?? 0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: TAB_COOKIES,
|
value: TAB_COOKIES,
|
||||||
label: 'Cookies',
|
label: 'Cookies',
|
||||||
rightSlot:
|
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
|
||||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
|
||||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
|
||||||
) : null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: TAB_TIMELINE,
|
value: TAB_TIMELINE,
|
||||||
@@ -121,8 +127,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
activeResponse?.headers,
|
activeResponse?.headers,
|
||||||
activeResponse?.requestContentLength,
|
activeResponse?.requestContentLength,
|
||||||
activeResponse?.requestHeaders.length,
|
activeResponse?.requestHeaders.length,
|
||||||
cookieCounts.sent,
|
cookieCount,
|
||||||
cookieCounts.received,
|
|
||||||
mimeType,
|
mimeType,
|
||||||
responseEvents.data?.length,
|
responseEvents.data?.length,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
@@ -205,7 +210,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
label="Response"
|
label="Response"
|
||||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||||
tabListClassName="mt-0.5 -mb-1.5"
|
tabListClassName="mt-0.5"
|
||||||
storageKey="http_response_tabs_order"
|
storageKey="http_response_tabs_order"
|
||||||
>
|
>
|
||||||
<TabContent value={TAB_BODY}>
|
<TabContent value={TAB_BODY}>
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import type {
|
|||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
HttpResponseEventData,
|
HttpResponseEventData,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import { type ReactNode, useState } from 'react';
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
import { Banner } from './core/Banner';
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
|
||||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -22,137 +25,164 @@ export function HttpResponseTimeline({ response }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Inner({ response }: Props) {
|
function Inner({ response }: Props) {
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||||
|
|
||||||
|
const activeEvent = useMemo(
|
||||||
|
() => (activeEventIndex == null ? null : events?.[activeEventIndex]),
|
||||||
|
[activeEventIndex, events],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Banner color="danger" className="m-3">
|
||||||
|
{String(error)}
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventViewer
|
<SplitLayout
|
||||||
events={events ?? []}
|
layout="vertical"
|
||||||
getEventKey={(event) => event.id}
|
name="http_response_events"
|
||||||
error={error ? String(error) : null}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage="Loading events..."
|
|
||||||
emptyMessage="No events recorded"
|
|
||||||
splitLayoutName="http_response_events"
|
|
||||||
defaultRatio={0.25}
|
defaultRatio={0.25}
|
||||||
renderRow={({ event, isActive, onClick }) => {
|
minHeightPx={10}
|
||||||
const display = getEventDisplay(event.event);
|
firstSlot={() => (
|
||||||
return (
|
<AutoScroller
|
||||||
<EventViewerRow
|
data={events}
|
||||||
isActive={isActive}
|
render={(event, i) => (
|
||||||
onClick={onClick}
|
<EventRow
|
||||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
key={event.id}
|
||||||
content={display.summary}
|
event={event}
|
||||||
timestamp={event.createdAt}
|
isActive={i === activeEventIndex}
|
||||||
/>
|
onClick={() => {
|
||||||
);
|
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||||
}}
|
else setActiveEventIndex(i);
|
||||||
renderDetail={({ event, onClose }) => (
|
}}
|
||||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
secondSlot={
|
||||||
|
activeEvent
|
||||||
|
? () => (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 overflow-y-auto">
|
||||||
|
<EventDetails event={activeEvent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EventRow({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
event: HttpResponseEvent;
|
||||||
|
}) {
|
||||||
|
const display = getEventDisplay(event.event);
|
||||||
|
const { icon, color, summary } = display;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
||||||
|
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
|
isActive && '!bg-surface-active !text-text',
|
||||||
|
'text-text-subtle hover:text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon color={color} icon={icon} size="sm" />
|
||||||
|
<div className="w-full truncate">{summary}</div>
|
||||||
|
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventDetails({
|
function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||||
event,
|
|
||||||
showRaw,
|
|
||||||
setShowRaw,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
event: HttpResponseEvent;
|
|
||||||
showRaw: boolean;
|
|
||||||
setShowRaw: (v: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { label } = getEventDisplay(event.event);
|
const { label } = getEventDisplay(event.event);
|
||||||
|
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
||||||
const e = event.event;
|
const e = event.event;
|
||||||
|
|
||||||
const actions: EventDetailAction[] = [
|
// Headers - show name and value with Editor for JSON
|
||||||
{
|
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||||
key: 'toggle-raw',
|
return (
|
||||||
label: showRaw ? 'Formatted' : 'Text',
|
<div className="flex flex-col gap-2 h-full">
|
||||||
onClick: () => setShowRaw(!showRaw),
|
<DetailHeader
|
||||||
},
|
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
||||||
];
|
timestamp={timestamp}
|
||||||
|
/>
|
||||||
// Determine the title based on event type
|
|
||||||
const title = (() => {
|
|
||||||
switch (e.type) {
|
|
||||||
case 'header_up':
|
|
||||||
return 'Header Sent';
|
|
||||||
case 'header_down':
|
|
||||||
return 'Header Received';
|
|
||||||
case 'send_url':
|
|
||||||
return 'Request';
|
|
||||||
case 'receive_url':
|
|
||||||
return 'Response';
|
|
||||||
case 'redirect':
|
|
||||||
return 'Redirect';
|
|
||||||
case 'setting':
|
|
||||||
return 'Apply Setting';
|
|
||||||
case 'chunk_sent':
|
|
||||||
return 'Data Sent';
|
|
||||||
case 'chunk_received':
|
|
||||||
return 'Data Received';
|
|
||||||
case 'dns_resolved':
|
|
||||||
return e.overridden ? 'DNS Override' : 'DNS Resolution';
|
|
||||||
default:
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Render content based on view mode and event type
|
|
||||||
const renderContent = () => {
|
|
||||||
// Raw view - show plaintext representation
|
|
||||||
if (showRaw) {
|
|
||||||
const rawText = formatEventRaw(event.event);
|
|
||||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headers - show name and value
|
|
||||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Request URL - show method and path separately
|
// Request URL - show method and path separately
|
||||||
if (e.type === 'send_url') {
|
if (e.type === 'send_url') {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DetailHeader title="Request" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Method">
|
<KeyValueRow label="Method">
|
||||||
<HttpMethodTagRaw forceColor method={e.method} />
|
<HttpMethodTagRaw forceColor method={e.method} />
|
||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Response status - show version and status separately
|
// Response status - show version and status separately
|
||||||
if (e.type === 'receive_url') {
|
if (e.type === 'receive_url') {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DetailHeader title="Response" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
<HttpStatusTagRaw status={e.status} />
|
<HttpStatusTagRaw status={e.status} />
|
||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect - show status, URL, and behavior
|
// Redirect - show status, URL, and behavior
|
||||||
if (e.type === 'redirect') {
|
if (e.type === 'redirect') {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DetailHeader title="Redirect" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
<HttpStatusTagRaw status={e.status} />
|
<HttpStatusTagRaw status={e.status} />
|
||||||
@@ -162,83 +192,51 @@ function EventDetails({
|
|||||||
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Settings - show as key/value
|
// Settings - show as key/value
|
||||||
if (e.type === 'setting') {
|
if (e.type === 'setting') {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Chunks - show formatted bytes
|
// Chunks - show formatted bytes
|
||||||
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
|
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
|
||||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||||
}
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
|
||||||
|
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// DNS Resolution - show hostname, addresses, and timing
|
// Default - use summary
|
||||||
if (e.type === 'dns_resolved') {
|
const { summary } = getEventDisplay(event.event);
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Duration">
|
|
||||||
{e.overridden ? (
|
|
||||||
<span className="text-text-subtlest">--</span>
|
|
||||||
) : (
|
|
||||||
`${String(e.duration)}ms`
|
|
||||||
)}
|
|
||||||
</KeyValueRow>
|
|
||||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default - use summary
|
|
||||||
const { summary } = getEventDisplay(event.event);
|
|
||||||
return <div className="font-mono text-editor">{summary}</div>;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-1">
|
||||||
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} onClose={onClose} />
|
<DetailHeader title={label} timestamp={timestamp} />
|
||||||
{renderContent()}
|
<div className="font-mono text-editor">{summary}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format event as raw plaintext for debugging */
|
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
|
||||||
function formatEventRaw(event: HttpResponseEventData): string {
|
return (
|
||||||
switch (event.type) {
|
<div className="flex items-center justify-between gap-2">
|
||||||
case 'send_url':
|
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
|
||||||
return `${event.method} ${event.path}`;
|
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
|
||||||
case 'receive_url':
|
</div>
|
||||||
return `${event.version} ${event.status}`;
|
);
|
||||||
case 'header_up':
|
|
||||||
return `${event.name}: ${event.value}`;
|
|
||||||
case 'header_down':
|
|
||||||
return `${event.name}: ${event.value}`;
|
|
||||||
case 'redirect':
|
|
||||||
return `${event.status} Redirect: ${event.url}`;
|
|
||||||
case 'setting':
|
|
||||||
return `${event.name} = ${event.value}`;
|
|
||||||
case 'info':
|
|
||||||
return `${event.message}`;
|
|
||||||
case 'chunk_sent':
|
|
||||||
return `[${formatBytes(event.bytes)} sent]`;
|
|
||||||
case 'chunk_received':
|
|
||||||
return `[${formatBytes(event.bytes)} received]`;
|
|
||||||
case 'dns_resolved':
|
|
||||||
if (event.overridden) {
|
|
||||||
return `DNS override ${event.hostname} → ${event.addresses.join(', ')}`;
|
|
||||||
}
|
|
||||||
return `DNS resolved ${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`;
|
|
||||||
default:
|
|
||||||
return '[unknown event]';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventDisplay = {
|
type EventDisplay = {
|
||||||
@@ -267,7 +265,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|||||||
case 'redirect':
|
case 'redirect':
|
||||||
return {
|
return {
|
||||||
icon: 'arrow_big_right_dash',
|
icon: 'arrow_big_right_dash',
|
||||||
color: 'success',
|
color: 'warning',
|
||||||
label: 'Redirect',
|
label: 'Redirect',
|
||||||
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
|
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
|
||||||
};
|
};
|
||||||
@@ -314,15 +312,6 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|||||||
label: 'Chunk',
|
label: 'Chunk',
|
||||||
summary: `${formatBytes(event.bytes)} chunk received`,
|
summary: `${formatBytes(event.bytes)} chunk received`,
|
||||||
};
|
};
|
||||||
case 'dns_resolved':
|
|
||||||
return {
|
|
||||||
icon: 'globe',
|
|
||||||
color: event.overridden ? 'success' : 'secondary',
|
|
||||||
label: event.overridden ? 'DNS Override' : 'DNS',
|
|
||||||
summary: event.overridden
|
|
||||||
? `${event.hostname} → ${event.addresses.join(', ')} (overridden)`
|
|
||||||
: `${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { hexy } from 'hexy';
|
import { hexy } from 'hexy';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useFormatText } from '../hooks/useFormatText';
|
import { useFormatText } from '../hooks/useFormatText';
|
||||||
import {
|
import {
|
||||||
activeWebsocketConnectionAtom,
|
activeWebsocketConnectionAtom,
|
||||||
@@ -11,13 +13,17 @@ import {
|
|||||||
} from '../hooks/usePinnedWebsocketConnection';
|
} from '../hooks/usePinnedWebsocketConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { languageFromContentType } from '../lib/contentType';
|
import { languageFromContentType } from '../lib/contentType';
|
||||||
|
import { copyToClipboard } from '../lib/copy';
|
||||||
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
|
||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
@@ -29,199 +35,227 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsocketResponsePane({ activeRequest }: Props) {
|
export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||||
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
||||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
|
const [hexDumps, setHexDumps] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||||
|
|
||||||
if (activeConnection == null) {
|
const activeEvent = useMemo(
|
||||||
return (
|
() => events.find((m) => m.id === activeEventId) ?? null,
|
||||||
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
[activeEventId, events],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
|
|
||||||
<HStack space={2}>
|
|
||||||
{activeConnection.state !== 'closed' && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
<WebsocketStatusTag connection={activeConnection} />
|
|
||||||
<span>•</span>
|
|
||||||
<span>{events.length} Messages</span>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={0.5} className="ml-auto">
|
|
||||||
<RecentWebsocketConnectionsDropdown
|
|
||||||
connections={connections}
|
|
||||||
activeConnection={activeConnection}
|
|
||||||
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary';
|
||||||
<ErrorBoundary name="Websocket Events">
|
|
||||||
<EventViewer
|
|
||||||
events={events}
|
|
||||||
getEventKey={(event) => event.id}
|
|
||||||
error={activeConnection.error}
|
|
||||||
header={header}
|
|
||||||
splitLayoutName="websocket_events"
|
|
||||||
defaultRatio={0.4}
|
|
||||||
renderRow={({ event, isActive, onClick }) => (
|
|
||||||
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
|
|
||||||
)}
|
|
||||||
renderDetail={({ event, index }) => (
|
|
||||||
<WebsocketEventDetail
|
|
||||||
event={event}
|
|
||||||
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
|
|
||||||
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
|
|
||||||
showLarge={showLarge}
|
|
||||||
showingLarge={showingLarge}
|
|
||||||
setShowLarge={setShowLarge}
|
|
||||||
setShowingLarge={setShowingLarge}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WebsocketEventRow({
|
|
||||||
event,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
event: WebsocketEvent;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const { message: messageBytes, isServer, messageType } = event;
|
|
||||||
const message = messageBytes
|
|
||||||
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const iconColor =
|
|
||||||
messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary';
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
messageType === 'close' || messageType === 'open'
|
|
||||||
? 'info'
|
|
||||||
: isServer
|
|
||||||
? 'arrow_big_down_dash'
|
|
||||||
: 'arrow_big_up_dash';
|
|
||||||
|
|
||||||
const content =
|
|
||||||
messageType === 'close' ? (
|
|
||||||
'Disconnected from server'
|
|
||||||
) : messageType === 'open' ? (
|
|
||||||
'Connected to server'
|
|
||||||
) : message === '' ? (
|
|
||||||
<em className="italic text-text-subtlest">No content</em>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs">{message.slice(0, 1000)}</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventViewerRow
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
icon={<Icon color={iconColor} icon={icon} />}
|
|
||||||
content={content}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WebsocketEventDetail({
|
|
||||||
event,
|
|
||||||
hexDump,
|
|
||||||
setHexDump,
|
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
}: {
|
|
||||||
event: WebsocketEvent;
|
|
||||||
hexDump: boolean;
|
|
||||||
setHexDump: (v: boolean) => void;
|
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const message = useMemo(() => {
|
const message = useMemo(() => {
|
||||||
if (hexDump) {
|
if (hexDump) {
|
||||||
return event.message ? hexy(event.message) : '';
|
return activeEvent?.message ? hexy(activeEvent?.message) : '';
|
||||||
}
|
}
|
||||||
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : '';
|
return activeEvent?.message
|
||||||
}, [event.message, hexDump]);
|
? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message))
|
||||||
|
: '';
|
||||||
|
}, [activeEvent?.message, hexDump]);
|
||||||
|
|
||||||
const language = languageFromContentType(null, message);
|
const language = languageFromContentType(null, message);
|
||||||
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||||
|
|
||||||
const title =
|
return (
|
||||||
event.messageType === 'close'
|
<SplitLayout
|
||||||
? 'Connection Closed'
|
layout="vertical"
|
||||||
: event.messageType === 'open'
|
name="grpc_events"
|
||||||
? 'Connection Open'
|
defaultRatio={0.4}
|
||||||
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
|
minHeightPx={20}
|
||||||
|
firstSlot={() =>
|
||||||
|
activeConnection == null ? (
|
||||||
|
<HotkeyList
|
||||||
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
|
||||||
|
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
|
||||||
|
<HStack space={2}>
|
||||||
|
{activeConnection.state !== 'closed' && (
|
||||||
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
|
)}
|
||||||
|
<WebsocketStatusTag connection={activeConnection} />
|
||||||
|
<span>•</span>
|
||||||
|
<span>{events.length} Messages</span>
|
||||||
|
</HStack>
|
||||||
|
<HStack space={0.5} className="ml-auto">
|
||||||
|
<RecentWebsocketConnectionsDropdown
|
||||||
|
connections={connections}
|
||||||
|
activeConnection={activeConnection}
|
||||||
|
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<ErrorBoundary name="Websocket Events">
|
||||||
|
<AutoScroller
|
||||||
|
data={events}
|
||||||
|
header={
|
||||||
|
activeConnection.error && (
|
||||||
|
<Banner color="danger" className="m-3">
|
||||||
|
{activeConnection.error}
|
||||||
|
</Banner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render={(event) => (
|
||||||
|
<EventRow
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
isActive={event.id === activeEventId}
|
||||||
|
onClick={() => {
|
||||||
|
if (event.id === activeEventId) setActiveEventId(null);
|
||||||
|
else setActiveEventId(event.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondSlot={
|
||||||
|
activeEvent != null && activeConnection != null
|
||||||
|
? () => (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{activeEvent.messageType === 'close'
|
||||||
|
? 'Connection Closed'
|
||||||
|
: activeEvent.messageType === 'open'
|
||||||
|
? 'Connection open'
|
||||||
|
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
|
||||||
|
</div>
|
||||||
|
{message !== '' && (
|
||||||
|
<HStack space={1}>
|
||||||
|
<Button
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
if (activeEventId == null) return;
|
||||||
|
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hexDump ? 'Show Message' : 'Show Hexdump'}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
title="Copy message"
|
||||||
|
icon="copy"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => copyToClipboard(formattedMessage ?? '')}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
|
||||||
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
|
Message previews larger than 1MB are hidden
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowingLarge(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowLarge(true);
|
||||||
|
setShowingLarge(false);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
isLoading={showingLarge}
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : activeEvent.message.length === 0 ? (
|
||||||
|
<EmptyStateText>No Content</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<Editor
|
||||||
|
language={language}
|
||||||
|
defaultValue={formattedMessage ?? ''}
|
||||||
|
wrapLines={false}
|
||||||
|
readOnly={true}
|
||||||
|
stateKey={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const actions: EventDetailAction[] =
|
function EventRow({
|
||||||
message !== ''
|
onClick,
|
||||||
? [
|
isActive,
|
||||||
{
|
event,
|
||||||
key: 'toggle-hexdump',
|
}: {
|
||||||
label: hexDump ? 'Show Message' : 'Show Hexdump',
|
onClick?: () => void;
|
||||||
onClick: () => setHexDump(!hexDump),
|
isActive?: boolean;
|
||||||
},
|
event: WebsocketEvent;
|
||||||
]
|
}) {
|
||||||
: [];
|
const { createdAt, message: messageBytes, isServer, messageType } = event;
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const message = messageBytes
|
||||||
|
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="px-1" ref={ref}>
|
||||||
<EventDetailHeader
|
<button
|
||||||
title={title}
|
type="button"
|
||||||
timestamp={event.createdAt}
|
onClick={onClick}
|
||||||
actions={actions}
|
className={classNames(
|
||||||
copyText={formattedMessage || undefined}
|
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
||||||
|
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
|
isActive && '!bg-surface-active !text-text',
|
||||||
|
'text-text-subtle hover:text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
color={
|
||||||
|
messageType === 'close' || messageType === 'open'
|
||||||
|
? 'secondary'
|
||||||
|
: isServer
|
||||||
|
? 'info'
|
||||||
|
: 'primary'
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
messageType === 'close' || messageType === 'open'
|
||||||
|
? 'info'
|
||||||
|
: isServer
|
||||||
|
? 'arrow_big_down_dash'
|
||||||
|
: 'arrow_big_up_dash'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{!showLarge && event.message.length > 1000 * 1000 ? (
|
<div className={classNames('w-full truncate text-xs')}>
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
{messageType === 'close' ? (
|
||||||
Message previews larger than 1MB are hidden
|
'Disconnected from server'
|
||||||
<div>
|
) : messageType === 'open' ? (
|
||||||
<Button
|
'Connected to server'
|
||||||
onClick={() => {
|
) : message === '' ? (
|
||||||
setShowingLarge(true);
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
setTimeout(() => {
|
) : (
|
||||||
setShowLarge(true);
|
message.slice(0, 1000)
|
||||||
setShowingLarge(false);
|
)}
|
||||||
}, 500);
|
{/*{error && <span className="text-warning"> ({error})</span>}*/}
|
||||||
}}
|
</div>
|
||||||
isLoading={showingLarge}
|
<div className={classNames('opacity-50 text-xs')}>
|
||||||
color="secondary"
|
{format(`${createdAt}Z`, 'HH:mm:ss.SSS')}
|
||||||
variant="border"
|
</div>
|
||||||
size="xs"
|
</button>
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : event.message.length === 0 ? (
|
|
||||||
<EmptyStateText>No Content</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<Editor
|
|
||||||
language={language}
|
|
||||||
defaultValue={formattedMessage ?? ''}
|
|
||||||
wrapLines={false}
|
|
||||||
readOnly={true}
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import { router } from '../lib/router';
|
|||||||
import { CopyIconButton } from './CopyIconButton';
|
import { CopyIconButton } from './CopyIconButton';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { CountBadge } from './core/CountBadge';
|
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from './core/InlineCode';
|
||||||
import { PlainInput } from './core/PlainInput';
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
import { DnsOverridesEditor } from './DnsOverridesEditor';
|
|
||||||
import { HeadersEditor } from './HeadersEditor';
|
import { HeadersEditor } from './HeadersEditor';
|
||||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||||
import { MarkdownEditor } from './MarkdownEditor';
|
import { MarkdownEditor } from './MarkdownEditor';
|
||||||
@@ -29,13 +27,11 @@ interface Props {
|
|||||||
|
|
||||||
const TAB_AUTH = 'auth';
|
const TAB_AUTH = 'auth';
|
||||||
const TAB_DATA = 'data';
|
const TAB_DATA = 'data';
|
||||||
const TAB_DNS = 'dns';
|
|
||||||
const TAB_HEADERS = 'headers';
|
const TAB_HEADERS = 'headers';
|
||||||
const TAB_GENERAL = 'general';
|
const TAB_GENERAL = 'general';
|
||||||
|
|
||||||
export type WorkspaceSettingsTab =
|
export type WorkspaceSettingsTab =
|
||||||
| typeof TAB_AUTH
|
| typeof TAB_AUTH
|
||||||
| typeof TAB_DNS
|
|
||||||
| typeof TAB_HEADERS
|
| typeof TAB_HEADERS
|
||||||
| typeof TAB_GENERAL
|
| typeof TAB_GENERAL
|
||||||
| typeof TAB_DATA;
|
| typeof TAB_DATA;
|
||||||
@@ -81,16 +77,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
{
|
|
||||||
value: TAB_DNS,
|
|
||||||
label: 'DNS',
|
|
||||||
rightSlot:
|
|
||||||
workspace.settingDnsOverrides.length > 0 ? (
|
|
||||||
<CountBadge count={workspace.settingDnsOverrides.length} />
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
storageKey="workspace_settings_tabs_order"
|
|
||||||
>
|
>
|
||||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||||
<HttpAuthenticationEditor model={workspace} />
|
<HttpAuthenticationEditor model={workspace} />
|
||||||
@@ -166,9 +153,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
<WorkspaceEncryptionSetting size="xs" />
|
<WorkspaceEncryptionSetting size="xs" />
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
|
|
||||||
<DnsOverridesEditor workspace={workspace} />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
import type { ReactElement, ReactNode, UIEvent } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
@@ -7,19 +7,9 @@ interface Props<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
render: (item: T, index: number) => ReactElement<HTMLElement>;
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
/** Make container focusable for keyboard navigation */
|
|
||||||
focusable?: boolean;
|
|
||||||
/** Callback to expose the virtualizer for keyboard navigation */
|
|
||||||
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoScroller<T>({
|
export function AutoScroller<T>({ data, render, header }: Props<T>) {
|
||||||
data,
|
|
||||||
render,
|
|
||||||
header,
|
|
||||||
focusable = false,
|
|
||||||
onVirtualizerReady,
|
|
||||||
}: Props<T>) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
const [autoScroll, setAutoScroll] = useState<boolean>(true);
|
||||||
|
|
||||||
@@ -30,11 +20,6 @@ export function AutoScroller<T>({
|
|||||||
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose virtualizer to parent for keyboard navigation
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
onVirtualizerReady?.(rowVirtualizer);
|
|
||||||
}, [rowVirtualizer, onVirtualizerReady]);
|
|
||||||
|
|
||||||
// Scroll to new items
|
// Scroll to new items
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: UIEvent<HTMLDivElement>) => {
|
(e: UIEvent<HTMLDivElement>) => {
|
||||||
@@ -63,7 +48,7 @@ export function AutoScroller<T>({
|
|||||||
}, [autoScroll, data.length]);
|
}, [autoScroll, data.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]">
|
||||||
{!autoScroll && (
|
{!autoScroll && (
|
||||||
<div className="absolute bottom-0 right-0 m-2">
|
<div className="absolute bottom-0 right-0 m-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -78,12 +63,7 @@ export function AutoScroller<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{header ?? <span aria-hidden />}
|
{header ?? <span aria-hidden />}
|
||||||
<div
|
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
|
||||||
ref={containerRef}
|
|
||||||
className="h-full w-full overflow-y-auto focus:outline-none"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
tabIndex={focusable ? 0 : undefined}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
|||||||
@@ -766,24 +766,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
</m.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hotkeys must be rendered even when menu is closed (so they work globally)
|
|
||||||
const hotKeyElements = items.map(
|
|
||||||
(item, i) =>
|
|
||||||
item.type !== 'separator' &&
|
|
||||||
item.type !== 'content' &&
|
|
||||||
!item.hotKeyLabelOnly &&
|
|
||||||
item.hotKeyAction && (
|
|
||||||
<MenuItemHotKey
|
|
||||||
key={`${item.hotKeyAction}::${i}`}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
item={item}
|
|
||||||
action={item.hotKeyAction}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return <>{hotKeyElements}</>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSubmenu) {
|
if (isSubmenu) {
|
||||||
@@ -792,7 +776,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hotKeyElements}
|
{items.map(
|
||||||
|
(item, i) =>
|
||||||
|
item.type !== 'separator' &&
|
||||||
|
item.type !== 'content' &&
|
||||||
|
!item.hotKeyLabelOnly &&
|
||||||
|
item.hotKeyAction && (
|
||||||
|
<MenuItemHotKey
|
||||||
|
key={`${item.hotKeyAction}::${i}`}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
item={item}
|
||||||
|
action={item.hotKeyAction}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
|
|
||||||
import { CopyIconButton } from '../CopyIconButton';
|
|
||||||
import { AutoScroller } from './AutoScroller';
|
|
||||||
import { Banner } from './Banner';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { Separator } from './Separator';
|
|
||||||
import { SplitLayout } from './SplitLayout';
|
|
||||||
import { HStack } from './Stacks';
|
|
||||||
import { IconButton } from './IconButton';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
interface EventViewerProps<T> {
|
|
||||||
/** Array of events to display */
|
|
||||||
events: T[];
|
|
||||||
|
|
||||||
/** Get unique key for each event */
|
|
||||||
getEventKey: (event: T, index: number) => string;
|
|
||||||
|
|
||||||
/** Render the event row - receives event, index, isActive, and onClick */
|
|
||||||
renderRow: (props: {
|
|
||||||
event: T;
|
|
||||||
index: number;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) => ReactNode;
|
|
||||||
|
|
||||||
/** Render the detail pane for the selected event */
|
|
||||||
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
|
|
||||||
|
|
||||||
/** Optional header above the event list (e.g., connection status) */
|
|
||||||
header?: ReactNode;
|
|
||||||
|
|
||||||
/** Error message to display as a banner */
|
|
||||||
error?: string | null;
|
|
||||||
|
|
||||||
/** Name for SplitLayout state persistence */
|
|
||||||
splitLayoutName: string;
|
|
||||||
|
|
||||||
/** Default ratio for the split (0.0 - 1.0) */
|
|
||||||
defaultRatio?: number;
|
|
||||||
|
|
||||||
/** Enable keyboard navigation (arrow keys) */
|
|
||||||
enableKeyboardNav?: boolean;
|
|
||||||
|
|
||||||
/** Loading state */
|
|
||||||
isLoading?: boolean;
|
|
||||||
|
|
||||||
/** Message to show while loading */
|
|
||||||
loadingMessage?: string;
|
|
||||||
|
|
||||||
/** Message to show when no events */
|
|
||||||
emptyMessage?: string;
|
|
||||||
|
|
||||||
/** Callback when active index changes (for controlled state in parent) */
|
|
||||||
onActiveIndexChange?: (index: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventViewer<T>({
|
|
||||||
events,
|
|
||||||
getEventKey,
|
|
||||||
renderRow,
|
|
||||||
renderDetail,
|
|
||||||
header,
|
|
||||||
error,
|
|
||||||
splitLayoutName,
|
|
||||||
defaultRatio = 0.4,
|
|
||||||
enableKeyboardNav = true,
|
|
||||||
isLoading = false,
|
|
||||||
loadingMessage = 'Loading events...',
|
|
||||||
emptyMessage = 'No events recorded',
|
|
||||||
onActiveIndexChange,
|
|
||||||
}: EventViewerProps<T>) {
|
|
||||||
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
|
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
||||||
|
|
||||||
// Wrap setActiveIndex to notify parent
|
|
||||||
const setActiveIndex = useCallback(
|
|
||||||
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
|
|
||||||
setActiveIndexInternal((prev) => {
|
|
||||||
const newIndex =
|
|
||||||
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
|
|
||||||
onActiveIndexChange?.(newIndex);
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onActiveIndexChange],
|
|
||||||
);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
|
|
||||||
|
|
||||||
const activeEvent = useMemo(
|
|
||||||
() => (activeIndex != null ? events[activeIndex] : null),
|
|
||||||
[activeIndex, events],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the event list container is focused
|
|
||||||
const isContainerFocused = useCallback(() => {
|
|
||||||
return containerRef.current?.contains(document.activeElement) ?? false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useEventViewerKeyboard({
|
|
||||||
totalCount: events.length,
|
|
||||||
activeIndex,
|
|
||||||
setActiveIndex,
|
|
||||||
virtualizer: virtualizerRef.current,
|
|
||||||
isContainerFocused,
|
|
||||||
enabled: enableKeyboardNav,
|
|
||||||
closePanel: () => setIsPanelOpen(false),
|
|
||||||
openPanel: () => setIsPanelOpen(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle virtualizer ready callback
|
|
||||||
const handleVirtualizerReady = useCallback(
|
|
||||||
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
|
|
||||||
virtualizerRef.current = virtualizer;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle row click - select and open panel, scroll into view
|
|
||||||
const handleRowClick = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
setActiveIndex(index);
|
|
||||||
setIsPanelOpen(true);
|
|
||||||
// Scroll to ensure selected item is visible after panel opens
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setActiveIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setIsPanelOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length === 0 && !error) {
|
|
||||||
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="h-full">
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
name={splitLayoutName}
|
|
||||||
defaultRatio={defaultRatio}
|
|
||||||
minHeightPx={10}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
{header ?? <span aria-hidden />}
|
|
||||||
<AutoScroller
|
|
||||||
data={events}
|
|
||||||
focusable={enableKeyboardNav}
|
|
||||||
onVirtualizerReady={handleVirtualizerReady}
|
|
||||||
header={
|
|
||||||
error && (
|
|
||||||
<Banner color="danger" className="m-3">
|
|
||||||
{error}
|
|
||||||
</Banner>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
render={(event, index) => (
|
|
||||||
<div key={getEventKey(event, index)}>
|
|
||||||
{renderRow({
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
isActive: index === activeIndex,
|
|
||||||
onClick: () => handleRowClick(index),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={
|
|
||||||
activeEvent != null && renderDetail && isPanelOpen
|
|
||||||
? ({ style }) => (
|
|
||||||
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
|
|
||||||
<div className="pb-3 px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="mx-2 overflow-y-auto">
|
|
||||||
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventDetailAction {
|
|
||||||
/** Unique key for React */
|
|
||||||
key: string;
|
|
||||||
/** Button label */
|
|
||||||
label: string;
|
|
||||||
/** Optional icon */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** Click handler */
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventDetailHeaderProps {
|
|
||||||
title: string;
|
|
||||||
prefix?: ReactNode;
|
|
||||||
timestamp?: string;
|
|
||||||
actions?: EventDetailAction[];
|
|
||||||
copyText?: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventDetailHeader({
|
|
||||||
title,
|
|
||||||
prefix,
|
|
||||||
timestamp,
|
|
||||||
actions,
|
|
||||||
copyText,
|
|
||||||
onClose,
|
|
||||||
}: EventDetailHeaderProps) {
|
|
||||||
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
|
|
||||||
<HStack space={2} className="items-center min-w-0">
|
|
||||||
{prefix}
|
|
||||||
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={2} className="items-center">
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
|
|
||||||
{action.icon}
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{copyText != null && (
|
|
||||||
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
|
||||||
)}
|
|
||||||
{formattedTime && (
|
|
||||||
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
|
||||||
)}
|
|
||||||
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
|
|
||||||
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface EventViewerRowProps {
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
icon: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventViewerRow({
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
icon,
|
|
||||||
content,
|
|
||||||
timestamp,
|
|
||||||
}: EventViewerRowProps) {
|
|
||||||
return (
|
|
||||||
<div className="px-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={classNames(
|
|
||||||
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
|
||||||
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
|
||||||
isActive && 'bg-surface-active !text-text',
|
|
||||||
'text-text-subtle hover:text',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<div className="w-full truncate">{content}</div>
|
|
||||||
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,7 @@ export function HttpResponseDurationTag({ response }: Props) {
|
|||||||
return () => clearInterval(timeout.current);
|
return () => clearInterval(timeout.current);
|
||||||
}, [response.createdAt, response.state]);
|
}, [response.createdAt, response.state]);
|
||||||
|
|
||||||
const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : '--';
|
const title = `HEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
||||||
const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
|
||||||
|
|
||||||
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;
|
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ import {
|
|||||||
GitCommitVerticalIcon,
|
GitCommitVerticalIcon,
|
||||||
GitForkIcon,
|
GitForkIcon,
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
GlobeIcon,
|
|
||||||
GripVerticalIcon,
|
GripVerticalIcon,
|
||||||
HandIcon,
|
HandIcon,
|
||||||
HardDriveDownloadIcon,
|
HardDriveDownloadIcon,
|
||||||
@@ -213,7 +212,6 @@ const icons = {
|
|||||||
git_commit_vertical: GitCommitVerticalIcon,
|
git_commit_vertical: GitCommitVerticalIcon,
|
||||||
git_fork: GitForkIcon,
|
git_fork: GitForkIcon,
|
||||||
git_pull_request: GitPullRequestIcon,
|
git_pull_request: GitPullRequestIcon,
|
||||||
globe: GlobeIcon,
|
|
||||||
grip_vertical: GripVerticalIcon,
|
grip_vertical: GripVerticalIcon,
|
||||||
circle_off: CircleOffIcon,
|
circle_off: CircleOffIcon,
|
||||||
hand: HandIcon,
|
hand: HandIcon,
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
|||||||
interface Props {
|
interface Props {
|
||||||
children:
|
children:
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyValueRows({ children }: Props) {
|
export function KeyValueRows({ children }: Props) {
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
children = Array.isArray(children) ? children : [children];
|
||||||
return (
|
return (
|
||||||
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
<tbody className="divide-y divide-surface-highlight">
|
||||||
{childArray.map((child, i) => (
|
{children.map((child, i) => (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||||
<tr key={i}>{child}</tr>
|
<tr key={i}>{child}</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -369,12 +369,7 @@ function TabButton({
|
|||||||
const btnProps: Partial<ButtonProps> = {
|
const btnProps: Partial<ButtonProps> = {
|
||||||
color: 'custom',
|
color: 'custom',
|
||||||
justify: layout === 'horizontal' ? 'start' : 'center',
|
justify: layout === 'horizontal' ? 'start' : 'center',
|
||||||
onClick: isActive
|
onClick: isActive ? undefined : () => onChangeValue(tab.value),
|
||||||
? undefined
|
|
||||||
: (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault(); // Prevent dropdown from opening on first click
|
|
||||||
onChangeValue(tab.value);
|
|
||||||
},
|
|
||||||
className: classNames(
|
className: classNames(
|
||||||
'flex items-center rounded whitespace-nowrap',
|
'flex items-center rounded whitespace-nowrap',
|
||||||
'!px-2 ml-[1px]',
|
'!px-2 ml-[1px]',
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { Fragment, useMemo, useState } from 'react';
|
|||||||
import { useFormatText } from '../../hooks/useFormatText';
|
import { useFormatText } from '../../hooks/useFormatText';
|
||||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||||
import { isJSON } from '../../lib/contentType';
|
import { isJSON } from '../../lib/contentType';
|
||||||
|
import { AutoScroller } from '../core/AutoScroller';
|
||||||
|
import { Banner } from '../core/Banner';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import type { EditorProps } from '../core/Editor/Editor';
|
import type { EditorProps } from '../core/Editor/Editor';
|
||||||
import { Editor } from '../core/Editor/LazyEditor';
|
import { Editor } from '../core/Editor/LazyEditor';
|
||||||
import { EventDetailHeader, EventViewer } from '../core/EventViewer';
|
|
||||||
import { EventViewerRow } from '../core/EventViewerRow';
|
|
||||||
import { Icon } from '../core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from '../core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
import { Separator } from '../core/Separator';
|
||||||
|
import { SplitLayout } from '../core/SplitLayout';
|
||||||
import { HStack, VStack } from '../core/Stacks';
|
import { HStack, VStack } from '../core/Stacks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,91 +33,93 @@ export function EventStreamViewer({ response }: Props) {
|
|||||||
function ActualEventStreamViewer({ response }: Props) {
|
function ActualEventStreamViewer({ response }: Props) {
|
||||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
|
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||||
const events = useResponseBodyEventSource(response);
|
const events = useResponseBodyEventSource(response);
|
||||||
|
const activeEvent = useMemo(
|
||||||
return (
|
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
|
||||||
<EventViewer
|
[activeEventIndex, events],
|
||||||
events={events.data ?? []}
|
|
||||||
getEventKey={(_, index) => String(index)}
|
|
||||||
error={events.error ? String(events.error) : null}
|
|
||||||
splitLayoutName="sse_events"
|
|
||||||
defaultRatio={0.4}
|
|
||||||
renderRow={({ event, index, isActive, onClick }) => (
|
|
||||||
<EventViewerRow
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={onClick}
|
|
||||||
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
|
|
||||||
content={
|
|
||||||
<HStack space={2} className="items-center">
|
|
||||||
<EventLabels event={event} index={index} isActive={isActive} />
|
|
||||||
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderDetail={({ event, index }) => (
|
|
||||||
<EventDetail
|
|
||||||
event={event}
|
|
||||||
index={index}
|
|
||||||
showLarge={showLarge}
|
|
||||||
showingLarge={showingLarge}
|
|
||||||
setShowLarge={setShowLarge}
|
|
||||||
setShowingLarge={setShowingLarge}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function EventDetail({
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
showLarge,
|
|
||||||
showingLarge,
|
|
||||||
setShowLarge,
|
|
||||||
setShowingLarge,
|
|
||||||
}: {
|
|
||||||
event: ServerSentEvent;
|
|
||||||
index: number;
|
|
||||||
showLarge: boolean;
|
|
||||||
showingLarge: boolean;
|
|
||||||
setShowLarge: (v: boolean) => void;
|
|
||||||
setShowingLarge: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const language = useMemo<'text' | 'json'>(() => {
|
const language = useMemo<'text' | 'json'>(() => {
|
||||||
if (!event?.data) return 'text';
|
if (!activeEvent?.data) return 'text';
|
||||||
return isJSON(event?.data) ? 'json' : 'text';
|
return isJSON(activeEvent?.data) ? 'json' : 'text';
|
||||||
}, [event?.data]);
|
}, [activeEvent?.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<SplitLayout
|
||||||
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
|
layout="vertical"
|
||||||
{!showLarge && event.data.length > 1000 * 1000 ? (
|
name="grpc_events"
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
defaultRatio={0.4}
|
||||||
Message previews larger than 1MB are hidden
|
minHeightPx={20}
|
||||||
<div>
|
firstSlot={() => (
|
||||||
<Button
|
<AutoScroller
|
||||||
|
data={events.data ?? []}
|
||||||
|
header={
|
||||||
|
events.error && (
|
||||||
|
<Banner color="danger" className="m-3">
|
||||||
|
{String(events.error)}
|
||||||
|
</Banner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render={(event, i) => (
|
||||||
|
<EventRow
|
||||||
|
event={event}
|
||||||
|
isActive={i === activeEventIndex}
|
||||||
|
index={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowingLarge(true);
|
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||||
setTimeout(() => {
|
else setActiveEventIndex(i);
|
||||||
setShowLarge(true);
|
|
||||||
setShowingLarge(false);
|
|
||||||
}, 500);
|
|
||||||
}}
|
}}
|
||||||
isLoading={showingLarge}
|
/>
|
||||||
color="secondary"
|
)}
|
||||||
variant="border"
|
/>
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Try Showing
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<FormattedEditor language={language} text={event.data} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
secondSlot={
|
||||||
|
activeEvent
|
||||||
|
? () => (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<div className="pb-3 px-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col pl-2">
|
||||||
|
<HStack space={1.5} className="mb-2 font-semibold">
|
||||||
|
<EventLabels
|
||||||
|
className="text-sm"
|
||||||
|
event={activeEvent}
|
||||||
|
index={activeEventIndex ?? 0}
|
||||||
|
/>
|
||||||
|
Message Received
|
||||||
|
</HStack>
|
||||||
|
{!showLarge && activeEvent.data.length > 1000 * 1000 ? (
|
||||||
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
|
Message previews larger than 1MB are hidden
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowingLarge(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowLarge(true);
|
||||||
|
setShowingLarge(false);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
isLoading={showingLarge}
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Try Showing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<FormattedEditor language={language} text={activeEvent.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,38 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
|
|||||||
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EventRow({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
event: ServerSentEvent;
|
||||||
|
className?: string;
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
|
||||||
|
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
|
||||||
|
isActive && '!bg-surface-active !text-text',
|
||||||
|
'text-text-subtle hover:text',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
|
||||||
|
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
|
||||||
|
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EventLabels({
|
function EventLabels({
|
||||||
className,
|
className,
|
||||||
event,
|
event,
|
||||||
@@ -133,7 +169,7 @@ function EventLabels({
|
|||||||
}: {
|
}: {
|
||||||
event: ServerSentEvent;
|
event: ServerSentEvent;
|
||||||
index: number;
|
index: number;
|
||||||
className?: string;
|
className: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useKey } from 'react-use';
|
|
||||||
|
|
||||||
interface UseEventViewerKeyboardProps {
|
|
||||||
totalCount: number;
|
|
||||||
activeIndex: number | null;
|
|
||||||
setActiveIndex: (index: number | null) => void;
|
|
||||||
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
|
|
||||||
isContainerFocused: () => boolean;
|
|
||||||
enabled?: boolean;
|
|
||||||
closePanel?: () => void;
|
|
||||||
openPanel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEventViewerKeyboard({
|
|
||||||
totalCount,
|
|
||||||
activeIndex,
|
|
||||||
setActiveIndex,
|
|
||||||
virtualizer,
|
|
||||||
isContainerFocused,
|
|
||||||
enabled = true,
|
|
||||||
closePanel,
|
|
||||||
openPanel,
|
|
||||||
}: UseEventViewerKeyboardProps) {
|
|
||||||
const selectPrev = useCallback(() => {
|
|
||||||
if (totalCount === 0) return;
|
|
||||||
|
|
||||||
const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);
|
|
||||||
setActiveIndex(newIndex);
|
|
||||||
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
|
|
||||||
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
|
|
||||||
|
|
||||||
const selectNext = useCallback(() => {
|
|
||||||
if (totalCount === 0) return;
|
|
||||||
|
|
||||||
const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);
|
|
||||||
setActiveIndex(newIndex);
|
|
||||||
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
|
|
||||||
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
(e) => e.key === 'ArrowUp' || e.key === 'k',
|
|
||||||
(e) => {
|
|
||||||
if (!enabled || !isContainerFocused()) return;
|
|
||||||
e.preventDefault();
|
|
||||||
selectPrev();
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[enabled, isContainerFocused, selectPrev],
|
|
||||||
);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
(e) => e.key === 'ArrowDown' || e.key === 'j',
|
|
||||||
(e) => {
|
|
||||||
if (!enabled || !isContainerFocused()) return;
|
|
||||||
e.preventDefault();
|
|
||||||
selectNext();
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[enabled, isContainerFocused, selectNext],
|
|
||||||
);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
(e) => e.key === 'Escape',
|
|
||||||
(e) => {
|
|
||||||
if (!enabled || !isContainerFocused()) return;
|
|
||||||
e.preventDefault();
|
|
||||||
closePanel?.();
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[enabled, isContainerFocused, closePanel],
|
|
||||||
);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
(e) => e.key === 'Enter' || e.key === ' ',
|
|
||||||
(e) => {
|
|
||||||
if (!enabled || !isContainerFocused() || activeIndex == null) return;
|
|
||||||
e.preventDefault();
|
|
||||||
openPanel?.();
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[enabled, isContainerFocused, activeIndex, openPanel],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||||
import {
|
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
|
||||||
httpResponseEventsAtom,
|
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
@@ -17,10 +13,8 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use merge instead of replace to preserve events that came in via model_write
|
|
||||||
// while we were fetching from the database
|
|
||||||
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
|
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
|
||||||
(events) => mergeModelsInStore('http_response_event', events),
|
(events) => replaceModelsInStore('http_response_event', events),
|
||||||
);
|
);
|
||||||
}, [response?.id]);
|
}, [response?.id]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { GrpcConnection, GrpcEvent } from '@yaakapp-internal/models';
|
|||||||
import {
|
import {
|
||||||
grpcConnectionsAtom,
|
grpcConnectionsAtom,
|
||||||
grpcEventsAtom,
|
grpcEventsAtom,
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
replaceModelsInStore,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
@@ -68,10 +67,8 @@ export function useGrpcEvents(connectionId: string | null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use merge instead of replace to preserve events that came in via model_write
|
|
||||||
// while we were fetching from the database
|
|
||||||
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
|
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
|
||||||
mergeModelsInStore('grpc_event', events);
|
replaceModelsInStore('grpc_event', events);
|
||||||
});
|
});
|
||||||
}, [connectionId]);
|
}, [connectionId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
||||||
import {
|
import {
|
||||||
mergeModelsInStore,
|
|
||||||
replaceModelsInStore,
|
replaceModelsInStore,
|
||||||
websocketConnectionsAtom,
|
websocketConnectionsAtom,
|
||||||
websocketEventsAtom,
|
websocketEventsAtom,
|
||||||
@@ -55,10 +54,8 @@ export function useWebsocketEvents(connectionId: string | null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use merge instead of replace to preserve events that came in via model_write
|
|
||||||
// while we were fetching from the database
|
|
||||||
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
|
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
|
||||||
(events) => mergeModelsInStore('websocket_event', events),
|
(events) => replaceModelsInStore('websocket_event', events),
|
||||||
);
|
);
|
||||||
}, [connectionId]);
|
}, [connectionId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import type { HttpResponseEvent } from '@yaakapp-internal/models';
|
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { getCookieCounts } from './model_util';
|
|
||||||
|
|
||||||
function makeEvent(
|
|
||||||
type: string,
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
): HttpResponseEvent {
|
|
||||||
return {
|
|
||||||
id: 'test',
|
|
||||||
model: 'http_response_event',
|
|
||||||
responseId: 'resp',
|
|
||||||
workspaceId: 'ws',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
event: { type, name, value } as HttpResponseEvent['event'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('getCookieCounts', () => {
|
|
||||||
test('returns zeros for undefined events', () => {
|
|
||||||
expect(getCookieCounts(undefined)).toEqual({ sent: 0, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns zeros for empty events', () => {
|
|
||||||
expect(getCookieCounts([])).toEqual({ sent: 0, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts single sent cookie', () => {
|
|
||||||
const events = [makeEvent('header_up', 'Cookie', 'session=abc123')];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts multiple sent cookies in one header', () => {
|
|
||||||
const events = [makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3')];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts single received cookie', () => {
|
|
||||||
const events = [makeEvent('header_down', 'Set-Cookie', 'session=abc123; Path=/')];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts multiple received cookies from multiple headers', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'a=1; Path=/'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'b=2; HttpOnly'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'c=3; Secure'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deduplicates sent cookies by name', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_up', 'Cookie', 'session=old'),
|
|
||||||
makeEvent('header_up', 'Cookie', 'session=new'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deduplicates received cookies by name', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'token=abc; Path=/'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'token=xyz; Path=/'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('counts both sent and received cookies', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'x=10; Path=/'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'y=20; Path=/'),
|
|
||||||
makeEvent('header_down', 'Set-Cookie', 'z=30; Path=/'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores non-cookie headers', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_up', 'Content-Type', 'application/json'),
|
|
||||||
makeEvent('header_down', 'Content-Length', '123'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles case-insensitive header names', () => {
|
|
||||||
const events = [
|
|
||||||
makeEvent('header_up', 'COOKIE', 'a=1'),
|
|
||||||
makeEvent('header_down', 'SET-COOKIE', 'b=2; Path=/'),
|
|
||||||
];
|
|
||||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 1 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import type {
|
import type { AnyModel, Cookie, Environment, HttpResponseHeader } from '@yaakapp-internal/models';
|
||||||
AnyModel,
|
|
||||||
Cookie,
|
|
||||||
Environment,
|
|
||||||
HttpResponseEvent,
|
|
||||||
HttpResponseHeader,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { getMimeTypeFromContentType } from './contentType';
|
import { getMimeTypeFromContentType } from './contentType';
|
||||||
|
|
||||||
export const BODY_TYPE_NONE = null;
|
export const BODY_TYPE_NONE = null;
|
||||||
@@ -65,30 +59,3 @@ export function isSubEnvironment(environment: Environment): boolean {
|
|||||||
export function isFolderEnvironment(environment: Environment): boolean {
|
export function isFolderEnvironment(environment: Environment): boolean {
|
||||||
return environment.parentModel === 'folder';
|
return environment.parentModel === 'folder';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCookieCounts(
|
|
||||||
events: HttpResponseEvent[] | undefined,
|
|
||||||
): { sent: number; received: number } {
|
|
||||||
if (!events) return { sent: 0, received: 0 };
|
|
||||||
|
|
||||||
// Use Sets to deduplicate by cookie name
|
|
||||||
const sentNames = new Set<string>();
|
|
||||||
const receivedNames = new Set<string>();
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
const e = event.event;
|
|
||||||
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') {
|
|
||||||
// Parse "Cookie: name=value; name2=value2" format
|
|
||||||
for (const pair of e.value.split(';')) {
|
|
||||||
const name = pair.split('=')[0]?.trim();
|
|
||||||
if (name) sentNames.add(name);
|
|
||||||
}
|
|
||||||
} else if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') {
|
|
||||||
// Parse "Set-Cookie: name=value; ..." - first part before ; is name=value
|
|
||||||
const name = e.value.split(';')[0]?.split('=')[0]?.trim();
|
|
||||||
if (name) receivedNames.add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sent: sentNames.size, received: receivedNames.size };
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user