mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-17 22:39:42 +02:00
Add redirect drop metadata and response warning UI
This commit is contained in:
@@ -47,8 +47,7 @@ impl CliContext {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let encryption_manager =
|
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||||
Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
data_dir,
|
data_dir,
|
||||||
|
|||||||
@@ -162,8 +162,7 @@ fn resolve_send_execution_context(
|
|||||||
AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),
|
AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),
|
||||||
AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id),
|
AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id),
|
||||||
};
|
};
|
||||||
let cookie_jar_id =
|
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
|
||||||
resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
|
|
||||||
return Ok(CliExecutionContext {
|
return Ok(CliExecutionContext {
|
||||||
request_id,
|
request_id,
|
||||||
workspace_id: Some(workspace_id),
|
workspace_id: Some(workspace_id),
|
||||||
@@ -184,8 +183,7 @@ fn resolve_send_execution_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(workspace) = context.db().get_workspace(id) {
|
if let Ok(workspace) = context.db().get_workspace(id) {
|
||||||
let cookie_jar_id =
|
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
|
||||||
resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
|
|
||||||
return Ok(CliExecutionContext {
|
return Ok(CliExecutionContext {
|
||||||
request_id: None,
|
request_id: None,
|
||||||
workspace_id: Some(workspace.id),
|
workspace_id: Some(workspace.id),
|
||||||
@@ -213,8 +211,7 @@ fn resolve_request_execution_context(
|
|||||||
AnyRequest::GrpcRequest(r) => r.workspace_id,
|
AnyRequest::GrpcRequest(r) => r.workspace_id,
|
||||||
AnyRequest::WebsocketRequest(r) => r.workspace_id,
|
AnyRequest::WebsocketRequest(r) => r.workspace_id,
|
||||||
};
|
};
|
||||||
let cookie_jar_id =
|
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
|
||||||
resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
|
|
||||||
|
|
||||||
Ok(CliExecutionContext {
|
Ok(CliExecutionContext {
|
||||||
request_id: Some(request_id.to_string()),
|
request_id: Some(request_id.to_string()),
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub enum HttpResponseEvent {
|
|||||||
url: String,
|
url: String,
|
||||||
status: u16,
|
status: u16,
|
||||||
behavior: RedirectBehavior,
|
behavior: RedirectBehavior,
|
||||||
|
dropped_body: bool,
|
||||||
|
dropped_headers: Vec<String>,
|
||||||
},
|
},
|
||||||
SendUrl {
|
SendUrl {
|
||||||
method: String,
|
method: String,
|
||||||
@@ -67,12 +69,28 @@ impl Display for HttpResponseEvent {
|
|||||||
match self {
|
match self {
|
||||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
||||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
||||||
HttpResponseEvent::Redirect { url, status, behavior } => {
|
HttpResponseEvent::Redirect {
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
behavior,
|
||||||
|
dropped_body,
|
||||||
|
dropped_headers,
|
||||||
|
} => {
|
||||||
let behavior_str = match behavior {
|
let behavior_str = match behavior {
|
||||||
RedirectBehavior::Preserve => "preserve",
|
RedirectBehavior::Preserve => "preserve",
|
||||||
RedirectBehavior::DropBody => "drop body",
|
RedirectBehavior::DropBody => "drop body",
|
||||||
};
|
};
|
||||||
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
|
let body_str = if *dropped_body { ", body dropped" } else { "" };
|
||||||
|
let headers_str = if dropped_headers.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(", headers dropped: {}", dropped_headers.join(", "))
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"* Redirect {} -> {} ({}{}{})",
|
||||||
|
status, url, behavior_str, body_str, headers_str
|
||||||
|
)
|
||||||
}
|
}
|
||||||
HttpResponseEvent::SendUrl {
|
HttpResponseEvent::SendUrl {
|
||||||
method,
|
method,
|
||||||
@@ -130,13 +148,21 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
|||||||
match event {
|
match event {
|
||||||
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
||||||
HttpResponseEvent::Info(message) => D::Info { message },
|
HttpResponseEvent::Info(message) => D::Info { message },
|
||||||
HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect {
|
HttpResponseEvent::Redirect {
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
behavior,
|
||||||
|
dropped_body,
|
||||||
|
dropped_headers,
|
||||||
|
} => D::Redirect {
|
||||||
url,
|
url,
|
||||||
status,
|
status,
|
||||||
behavior: match behavior {
|
behavior: match behavior {
|
||||||
RedirectBehavior::Preserve => "preserve".to_string(),
|
RedirectBehavior::Preserve => "preserve".to_string(),
|
||||||
RedirectBehavior::DropBody => "drop_body".to_string(),
|
RedirectBehavior::DropBody => "drop_body".to_string(),
|
||||||
},
|
},
|
||||||
|
dropped_body,
|
||||||
|
dropped_headers,
|
||||||
},
|
},
|
||||||
HttpResponseEvent::SendUrl {
|
HttpResponseEvent::SendUrl {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build request for this iteration
|
// Build request for this iteration
|
||||||
|
let request_had_body = current_body.is_some();
|
||||||
let req = SendableHttpRequest {
|
let req = SendableHttpRequest {
|
||||||
url: current_url.clone(),
|
url: current_url.clone(),
|
||||||
method: current_method.clone(),
|
method: current_method.clone(),
|
||||||
@@ -182,8 +183,6 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
format!("{}/{}", base_path, location)
|
format!("{}/{}", base_path, location)
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
|
||||||
|
|
||||||
// Determine redirect behavior based on status code and method
|
// Determine redirect behavior based on status code and method
|
||||||
let behavior = if status == 303 {
|
let behavior = if status == 303 {
|
||||||
// 303 See Other always changes to GET
|
// 303 See Other always changes to GET
|
||||||
@@ -197,11 +196,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
RedirectBehavior::Preserve
|
RedirectBehavior::Preserve
|
||||||
};
|
};
|
||||||
|
|
||||||
send_event(HttpResponseEvent::Redirect {
|
let mut dropped_headers =
|
||||||
url: current_url.clone(),
|
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
||||||
status,
|
|
||||||
behavior: behavior.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle method changes for certain redirect codes
|
// Handle method changes for certain redirect codes
|
||||||
if matches!(behavior, RedirectBehavior::DropBody) {
|
if matches!(behavior, RedirectBehavior::DropBody) {
|
||||||
@@ -211,10 +207,25 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
// Remove content-related headers
|
// Remove content-related headers
|
||||||
current_headers.retain(|h| {
|
current_headers.retain(|h| {
|
||||||
let name_lower = h.0.to_lowercase();
|
let name_lower = h.0.to_lowercase();
|
||||||
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
|
let should_drop =
|
||||||
|
name_lower.starts_with("content-") || name_lower == "transfer-encoding";
|
||||||
|
if should_drop {
|
||||||
|
Self::push_header_if_missing(&mut dropped_headers, &h.0);
|
||||||
|
}
|
||||||
|
!should_drop
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dropped_body = matches!(behavior, RedirectBehavior::DropBody) && request_had_body;
|
||||||
|
|
||||||
|
send_event(HttpResponseEvent::Redirect {
|
||||||
|
url: current_url.clone(),
|
||||||
|
status,
|
||||||
|
behavior: behavior.clone(),
|
||||||
|
dropped_body,
|
||||||
|
dropped_headers,
|
||||||
|
});
|
||||||
|
|
||||||
// Reset body for next iteration (since it was moved in the send call)
|
// Reset body for next iteration (since it was moved in the send call)
|
||||||
// For redirects that change method to GET or for all redirects since body was consumed
|
// For redirects that change method to GET or for all redirects since body was consumed
|
||||||
current_body = None;
|
current_body = None;
|
||||||
@@ -231,7 +242,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
headers: &mut Vec<(String, String)>,
|
headers: &mut Vec<(String, String)>,
|
||||||
previous_url: &str,
|
previous_url: &str,
|
||||||
next_url: &str,
|
next_url: &str,
|
||||||
) {
|
) -> Vec<String> {
|
||||||
|
let mut dropped_headers = Vec::new();
|
||||||
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
|
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
|
||||||
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
||||||
});
|
});
|
||||||
@@ -241,13 +253,24 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
if previous_host != next_host {
|
if previous_host != next_host {
|
||||||
headers.retain(|h| {
|
headers.retain(|h| {
|
||||||
let name_lower = h.0.to_lowercase();
|
let name_lower = h.0.to_lowercase();
|
||||||
name_lower != "authorization"
|
let should_drop = name_lower == "authorization"
|
||||||
&& name_lower != "cookie"
|
|| name_lower == "cookie"
|
||||||
&& name_lower != "cookie2"
|
|| name_lower == "cookie2"
|
||||||
&& name_lower != "proxy-authorization"
|
|| name_lower == "proxy-authorization"
|
||||||
&& name_lower != "www-authenticate"
|
|| name_lower == "www-authenticate";
|
||||||
|
if should_drop {
|
||||||
|
Self::push_header_if_missing(&mut dropped_headers, &h.0);
|
||||||
|
}
|
||||||
|
!should_drop
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
dropped_headers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_header_if_missing(headers: &mut Vec<String>, name: &str) {
|
||||||
|
if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
|
||||||
|
headers.push(name.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a status code indicates a redirect
|
/// Check if a status code indicates a redirect
|
||||||
|
|||||||
2
crates/yaak-models/bindings/gen_models.ts
generated
2
crates/yaak-models/bindings/gen_models.ts
generated
@@ -49,7 +49,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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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 HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1499,6 +1499,10 @@ pub enum HttpResponseEventData {
|
|||||||
url: String,
|
url: String,
|
||||||
status: u16,
|
status: u16,
|
||||||
behavior: String,
|
behavior: String,
|
||||||
|
#[serde(default)]
|
||||||
|
dropped_body: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
dropped_headers: Vec<String>,
|
||||||
},
|
},
|
||||||
SendUrl {
|
SendUrl {
|
||||||
method: String,
|
method: String,
|
||||||
|
|||||||
2
crates/yaak-plugins/bindings/gen_models.ts
generated
2
crates/yaak-plugins/bindings/gen_models.ts
generated
@@ -62,7 +62,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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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 HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,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, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: 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 HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ComponentType, CSSProperties } from 'react';
|
import type { ComponentType, CSSProperties } from 'react';
|
||||||
import { lazy, Suspense, useMemo } from 'react';
|
import { lazy, Suspense, useMemo } from 'react';
|
||||||
@@ -18,11 +18,14 @@ import { CountBadge } from './core/CountBadge';
|
|||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { PillButton } from './core/PillButton';
|
||||||
import { SizeTag } from './core/SizeTag';
|
import { SizeTag } from './core/SizeTag';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import type { TabItem } from './core/Tabs/Tabs';
|
import type { TabItem } from './core/Tabs/Tabs';
|
||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
|
import { Tooltip } from './core/Tooltip';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
||||||
@@ -57,6 +60,11 @@ const TAB_TIMELINE = 'timeline';
|
|||||||
|
|
||||||
export type TimelineViewMode = 'timeline' | 'text';
|
export type TimelineViewMode = 'timeline' | 'text';
|
||||||
|
|
||||||
|
interface RedirectDropWarning {
|
||||||
|
droppedBodyCount: number;
|
||||||
|
droppedHeaders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||||
@@ -65,6 +73,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
const redirectDropWarning = useMemo(
|
||||||
|
() => getRedirectDropWarning(responseEvents.data),
|
||||||
|
[responseEvents.data],
|
||||||
|
);
|
||||||
|
const shouldShowRedirectDropWarning =
|
||||||
|
activeResponse?.state === 'closed' && redirectDropWarning != null;
|
||||||
|
|
||||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||||
|
|
||||||
@@ -162,32 +176,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeResponse && (
|
{activeResponse && (
|
||||||
<HStack
|
<div
|
||||||
space={2}
|
|
||||||
alignItems="center"
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
|
||||||
'cursor-default select-none',
|
'cursor-default select-none',
|
||||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
<HStack space={2} className="w-full flex-shrink-0">
|
||||||
<HttpStatusTag showReason response={activeResponse} />
|
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||||
<span>•</span>
|
<HttpStatusTag showReason response={activeResponse} />
|
||||||
<HttpResponseDurationTag response={activeResponse} />
|
<span>•</span>
|
||||||
<span>•</span>
|
<HttpResponseDurationTag response={activeResponse} />
|
||||||
<SizeTag
|
<span>•</span>
|
||||||
contentLength={activeResponse.contentLength ?? 0}
|
<SizeTag
|
||||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
contentLength={activeResponse.contentLength ?? 0}
|
||||||
/>
|
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||||
|
/>
|
||||||
<div className="ml-auto">
|
</HStack>
|
||||||
|
{shouldShowRedirectDropWarning ? (
|
||||||
|
<Tooltip
|
||||||
|
tabIndex={0}
|
||||||
|
className="pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||||
|
content={
|
||||||
|
<VStack alignItems="start" space={1} className="text-xs">
|
||||||
|
<span className="font-medium text-warning">
|
||||||
|
Redirect changed this request
|
||||||
|
</span>
|
||||||
|
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||||
|
<span>
|
||||||
|
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
|
||||||
|
{redirectDropWarning.droppedBodyCount === 1
|
||||||
|
? 'redirect hop'
|
||||||
|
: 'redirect hops'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||||
|
<span>
|
||||||
|
Headers dropped:{' '}
|
||||||
|
<span className="font-mono">
|
||||||
|
{redirectDropWarning.droppedHeaders.join(', ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-subtle">See Timeline for details.</span>
|
||||||
|
</VStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="inline-flex min-w-0">
|
||||||
|
<PillButton
|
||||||
|
color="warning"
|
||||||
|
className="font-sans text-sm !flex-shrink max-w-full"
|
||||||
|
innerClassName="flex items-center"
|
||||||
|
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{getRedirectWarningLabel(redirectDropWarning)}
|
||||||
|
</span>
|
||||||
|
</PillButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="justify-self-end flex-shrink-0">
|
||||||
<RecentHttpResponsesDropdown
|
<RecentHttpResponsesDropdown
|
||||||
responses={responses}
|
responses={responses}
|
||||||
activeResponse={activeResponse}
|
activeResponse={activeResponse}
|
||||||
onPinnedResponseId={setPinnedResponseId}
|
onPinnedResponseId={setPinnedResponseId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</div>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@@ -274,6 +333,54 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRedirectDropWarning(
|
||||||
|
events: HttpResponseEvent[] | undefined,
|
||||||
|
): RedirectDropWarning | null {
|
||||||
|
if (events == null || events.length === 0) return null;
|
||||||
|
|
||||||
|
let droppedBodyCount = 0;
|
||||||
|
const droppedHeaders = new Set<string>();
|
||||||
|
for (const e of events) {
|
||||||
|
const event = e.event;
|
||||||
|
if (event.type !== 'redirect') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.dropped_body) {
|
||||||
|
droppedBodyCount += 1;
|
||||||
|
}
|
||||||
|
for (const headerName of event.dropped_headers ?? []) {
|
||||||
|
pushHeaderName(droppedHeaders, headerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
droppedBodyCount,
|
||||||
|
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||||
|
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||||
|
if (existing == null) {
|
||||||
|
headers.add(headerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||||
|
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||||
|
return 'Dropped body and headers';
|
||||||
|
}
|
||||||
|
if (warning.droppedBodyCount > 0) {
|
||||||
|
return 'Dropped body';
|
||||||
|
}
|
||||||
|
return 'Dropped headers';
|
||||||
|
}
|
||||||
|
|
||||||
function EnsureCompleteResponse({
|
function EnsureCompleteResponse({
|
||||||
response,
|
response,
|
||||||
Component,
|
Component,
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function EventDetails({
|
|||||||
|
|
||||||
// Redirect - show status, URL, and behavior
|
// Redirect - show status, URL, and behavior
|
||||||
if (e.type === 'redirect') {
|
if (e.type === 'redirect') {
|
||||||
|
const droppedHeaders = e.dropped_headers ?? [];
|
||||||
return (
|
return (
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
@@ -196,6 +197,10 @@ function EventDetails({
|
|||||||
<KeyValueRow label="Behavior">
|
<KeyValueRow label="Behavior">
|
||||||
{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>
|
||||||
|
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
|
||||||
|
<KeyValueRow label="Headers Dropped">
|
||||||
|
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
|
||||||
|
</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +273,17 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
|||||||
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
||||||
case 'redirect': {
|
case 'redirect': {
|
||||||
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
||||||
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
|
const droppedHeaders = event.dropped_headers ?? [];
|
||||||
|
const dropped = [
|
||||||
|
event.dropped_body ? 'body dropped' : null,
|
||||||
|
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return {
|
||||||
|
prefix: '*',
|
||||||
|
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
||||||
@@ -323,13 +338,23 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|||||||
label: 'Info',
|
label: 'Info',
|
||||||
summary: event.message,
|
summary: event.message,
|
||||||
};
|
};
|
||||||
case 'redirect':
|
case 'redirect': {
|
||||||
|
const droppedHeaders = event.dropped_headers ?? [];
|
||||||
|
const dropped = [
|
||||||
|
event.dropped_body ? 'drop body' : null,
|
||||||
|
droppedHeaders.length > 0
|
||||||
|
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
return {
|
return {
|
||||||
icon: 'arrow_big_right_dash',
|
icon: 'arrow_big_right_dash',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
label: 'Redirect',
|
label: 'Redirect',
|
||||||
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
|
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'send_url':
|
case 'send_url':
|
||||||
return {
|
return {
|
||||||
icon: 'arrow_big_up_dash',
|
icon: 'arrow_big_up_dash',
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ const hiddenStyles: CSSProperties = {
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TooltipPosition = 'top' | 'bottom';
|
||||||
|
|
||||||
|
interface TooltipOpenState {
|
||||||
|
styles: CSSProperties;
|
||||||
|
position: TooltipPosition;
|
||||||
|
}
|
||||||
|
|
||||||
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
|
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
|
||||||
const [isOpen, setIsOpen] = useState<CSSProperties>();
|
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
@@ -29,16 +36,25 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
|
|||||||
const handleOpenImmediate = () => {
|
const handleOpenImmediate = () => {
|
||||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||||
clearTimeout(showTimeout.current);
|
clearTimeout(showTimeout.current);
|
||||||
setIsOpen(undefined);
|
|
||||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||||
const docRect = document.documentElement.getBoundingClientRect();
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
const margin = 8;
|
||||||
|
const spaceAbove = Math.max(0, triggerRect.top - margin);
|
||||||
|
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
|
||||||
|
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
|
||||||
|
const position: TooltipPosition = preferBottom ? 'bottom' : 'top';
|
||||||
|
|
||||||
const styles: CSSProperties = {
|
const styles: CSSProperties = {
|
||||||
bottom: docRect.height - triggerRect.top,
|
|
||||||
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
||||||
maxHeight: triggerRect.top,
|
maxHeight: position === 'top' ? spaceAbove : spaceBelow,
|
||||||
|
...(position === 'top'
|
||||||
|
? { bottom: viewportHeight - triggerRect.top }
|
||||||
|
: { top: triggerRect.bottom }),
|
||||||
};
|
};
|
||||||
setIsOpen(styles);
|
|
||||||
|
setOpenState({ styles, position });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
@@ -48,16 +64,16 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
clearTimeout(showTimeout.current);
|
clearTimeout(showTimeout.current);
|
||||||
setIsOpen(undefined);
|
setOpenState(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleImmediate = () => {
|
const handleToggleImmediate = () => {
|
||||||
if (isOpen) handleClose();
|
if (openState) handleClose();
|
||||||
else handleOpenImmediate();
|
else handleOpenImmediate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
if (isOpen && e.key === 'Escape') {
|
if (openState && e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -71,10 +87,10 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
|
|||||||
<Portal name="tooltip">
|
<Portal name="tooltip">
|
||||||
<div
|
<div
|
||||||
ref={tooltipRef}
|
ref={tooltipRef}
|
||||||
style={isOpen ?? hiddenStyles}
|
style={openState?.styles ?? hiddenStyles}
|
||||||
id={id.current}
|
id={id.current}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={openState == null}
|
||||||
onMouseEnter={handleOpenImmediate}
|
onMouseEnter={handleOpenImmediate}
|
||||||
onMouseLeave={handleClose}
|
onMouseLeave={handleClose}
|
||||||
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
||||||
@@ -88,14 +104,17 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<Triangle className="text-border mb-2" />
|
<Triangle
|
||||||
|
className="text-border"
|
||||||
|
position={openState?.position === 'bottom' ? 'top' : 'bottom'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
{/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */}
|
{/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */}
|
||||||
<span
|
<span
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
role="button"
|
role="button"
|
||||||
aria-describedby={isOpen ? id.current : undefined}
|
aria-describedby={openState ? id.current : undefined}
|
||||||
tabIndex={tabIndex ?? -1}
|
tabIndex={tabIndex ?? -1}
|
||||||
className={classNames(className, 'flex-grow-0 flex items-center')}
|
className={classNames(className, 'flex-grow-0 flex items-center')}
|
||||||
onClick={handleToggleImmediate}
|
onClick={handleToggleImmediate}
|
||||||
@@ -111,7 +130,9 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Triangle({ className }: { className?: string }) {
|
function Triangle({ className, position }: { className?: string; position: 'top' | 'bottom' }) {
|
||||||
|
const isBottom = position === 'bottom';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -120,15 +141,19 @@ function Triangle({ className }: { className?: string }) {
|
|||||||
shapeRendering="crispEdges"
|
shapeRendering="crispEdges"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'absolute z-50 border-t-[2px] border-surface-highlight',
|
'absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]',
|
||||||
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]',
|
isBottom
|
||||||
'h-[0.5rem] w-[0.8rem]',
|
? 'border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2'
|
||||||
|
: 'border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<title>Triangle</title>
|
<title>Triangle</title>
|
||||||
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" />
|
<polygon
|
||||||
|
className="fill-surface-highlight"
|
||||||
|
points={isBottom ? '0,0 30,0 15,10' : '0,10 30,10 15,0'}
|
||||||
|
/>
|
||||||
<path
|
<path
|
||||||
d="M0 0 L15 9 L30 0"
|
d={isBottom ? 'M0 0 L15 9 L30 0' : 'M0 10 L15 1 L30 10'}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
|
|||||||
Reference in New Issue
Block a user