From 8e551bc9a2790d9a54b23f65b6b0cd7ad8bcb2c6 Mon Sep 17 00:00:00 2001 From: "yinzhou.ma" Date: Wed, 29 Apr 2026 14:48:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=97=B6=E9=97=B4=E4=B8=BA10=E5=88=86?= =?UTF-8?q?=E9=92=9F=E3=80=81=E6=B0=B8=E4=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/proxy/request_detail.rs | 34 ++++++-- messages/en.json | 14 ++- messages/zh.json | 14 ++- src-tauri/src/commands/logs.rs | 7 +- src/features/dashboard/snapshot.tsx | 85 ++++++++++++++++--- src/features/logs/LogsPanel.tsx | 6 +- src/features/logs/api.ts | 4 +- src/features/logs/types.ts | 1 + 8 files changed, 135 insertions(+), 30 deletions(-) diff --git a/crates/token_proxy_core/src/proxy/request_detail.rs b/crates/token_proxy_core/src/proxy/request_detail.rs index 33ec3f40..e72fe7cd 100644 --- a/crates/token_proxy_core/src/proxy/request_detail.rs +++ b/crates/token_proxy_core/src/proxy/request_detail.rs @@ -7,7 +7,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::request_body::ReplayableBody; const BODY_TOO_LARGE_MESSAGE: &str = "[body omitted: too large]"; -const REQUEST_DETAIL_CAPTURE_WINDOW: Duration = Duration::from_secs(30); +const DEFAULT_CAPTURE_WINDOW_SECS: u64 = 600; // 10 minutes +const PERMANENT_WINDOW_SECS: u64 = 0; const DISARMED_AT_MS: u64 = 0; #[derive(Clone, Default)] @@ -21,6 +22,7 @@ pub struct RequestDetailSnapshot { pub struct RequestDetailCaptureState { pub enabled: bool, pub expires_at_ms: Option, + pub is_permanent: bool, } impl RequestDetailCaptureState { @@ -28,19 +30,23 @@ impl RequestDetailCaptureState { Self { enabled: false, expires_at_ms: None, + is_permanent: false, } } - fn active(expires_at_ms: u64) -> Self { + fn active(expires_at_ms: u64, is_permanent: bool) -> Self { Self { enabled: true, expires_at_ms: Some(expires_at_ms), + is_permanent, } } } pub struct RequestDetailCapture { + #[allow(dead_code)] expires_at_ms: AtomicU64, + #[allow(dead_code)] window_ms: u64, now_ms: Arc u64 + Send + Sync>, on_change: Option>, @@ -50,7 +56,7 @@ impl RequestDetailCapture { pub fn new(on_change: Option>) -> Self { Self { expires_at_ms: AtomicU64::new(DISARMED_AT_MS), - window_ms: duration_to_millis(REQUEST_DETAIL_CAPTURE_WINDOW), + window_ms: duration_to_millis(Duration::from_secs(DEFAULT_CAPTURE_WINDOW_SECS)), now_ms: Arc::new(current_time_millis), on_change, } @@ -71,8 +77,21 @@ impl RequestDetailCapture { } pub fn arm(&self) -> RequestDetailCaptureState { - let expires_at_ms = (self.now_ms)().saturating_add(self.window_ms); - let state = RequestDetailCaptureState::active(expires_at_ms); + self.arm_with_window_secs(DEFAULT_CAPTURE_WINDOW_SECS) + } + + pub fn arm_permanent(&self) -> RequestDetailCaptureState { + self.arm_with_window_secs(PERMANENT_WINDOW_SECS) + } + + fn arm_with_window_secs(&self, window_secs: u64) -> RequestDetailCaptureState { + let window_ms = if window_secs == 0 { + u64::MAX + } else { + duration_to_millis(Duration::from_secs(window_secs)) + }; + let expires_at_ms = (self.now_ms)().saturating_add(window_ms); + let state = RequestDetailCaptureState::active(expires_at_ms, window_secs == 0); self.expires_at_ms.store(expires_at_ms, Ordering::SeqCst); self.notify(state); state @@ -100,8 +119,9 @@ impl RequestDetailCapture { return RequestDetailCaptureState::idle(); } - if (self.now_ms)() <= expires_at_ms { - return RequestDetailCaptureState::active(expires_at_ms); + let is_permanent = expires_at_ms == u64::MAX; + if is_permanent || (self.now_ms)() <= expires_at_ms { + return RequestDetailCaptureState::active(expires_at_ms, is_permanent); } // 窗口过期后仅第一个观察者负责清空并广播关闭,避免并发重复通知。 diff --git a/messages/en.json b/messages/en.json index 12ab5de0..75596950 100644 --- a/messages/en.json +++ b/messages/en.json @@ -384,9 +384,17 @@ "logs_timing_upstream_first_body_chunk_ms": "Upstream first body chunk (ms)", "logs_timing_first_client_flush_ms": "First client flush (ms)", "logs_timing_first_output_ms": "First output (ms)", - "logs_capture_title": "Capture request details for 30 seconds", - "logs_capture_desc": "When enabled, captures request headers/bodies during a 30-second window; error responses are always recorded. Turns off automatically when the window ends.", - "logs_capture_status_ready": "Enabled: capture request details during the 30-second window", + "logs_capture_title": "Capture request details", + "logs_capture_desc": "When enabled, captures request headers/bodies for 10 minutes; error responses are always recorded.", + "logs_capture_idle_desc": "Select a mode (10 min or Permanent), then click the power button to start capturing.", + "logs_capture_permanent_desc": "Captures request headers/bodies permanently until disabled; error responses are always recorded.", + "logs_capture_mode_temporary": "10 min", + "logs_capture_mode_permanent": "Permanent", + "logs_capture_start": "Start capture", + "logs_capture_start_temporary": "Start capturing for 10 minutes", + "logs_capture_start_permanent": "Start permanent capture", + "logs_capture_stop": "Stop capture", + "logs_capture_status_ready": "Enabled: capture request details for 10 minutes", "logs_capture_status_idle": "Request details are not captured by default", "logs_capture_status_countdown": "{seconds}s left", "logs_detail_title": "Request details", diff --git a/messages/zh.json b/messages/zh.json index 0fd27f41..86f0d95a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -385,9 +385,17 @@ "logs_timing_upstream_first_body_chunk_ms": "上游首响应体 chunk (ms)", "logs_timing_first_client_flush_ms": "首次客户端 flush (ms)", "logs_timing_first_output_ms": "首输出 (ms)", - "logs_capture_title": "记录 30 秒内请求详情", - "logs_capture_desc": "开启后会在 30 秒窗口内记录请求的 header/body;错误响应始终记录,到时自动关闭。", - "logs_capture_status_ready": "已开启:30 秒窗口内记录详情", + "logs_capture_title": "记录请求详情", + "logs_capture_desc": "开启后记录 10 分钟内的请求 header/body;错误响应始终记录。", + "logs_capture_idle_desc": "选择一个模式(10 分钟或永久),然后点击电源按钮开始记录。", + "logs_capture_permanent_desc": "永久记录请求详情直到手动关闭;错误响应始终记录。", + "logs_capture_mode_temporary": "10 分钟", + "logs_capture_mode_permanent": "永久", + "logs_capture_start": "开始记录", + "logs_capture_start_temporary": "开始记录 10 分钟", + "logs_capture_start_permanent": "开始永久记录", + "logs_capture_stop": "停止记录", + "logs_capture_status_ready": "已开启:10 分钟窗口内记录详情", "logs_capture_status_idle": "默认不记录请求详情", "logs_capture_status_countdown": "剩余 {seconds}s", "logs_detail_title": "请求详情", diff --git a/src-tauri/src/commands/logs.rs b/src-tauri/src/commands/logs.rs index d1354f82..70222110 100644 --- a/src-tauri/src/commands/logs.rs +++ b/src-tauri/src/commands/logs.rs @@ -25,9 +25,14 @@ pub fn read_request_detail_capture( pub fn set_request_detail_capture( capture_state: tauri::State<'_, Arc>, enabled: bool, + permanent: bool, ) -> proxy::request_detail::RequestDetailCaptureState { if enabled { - capture_state.arm() + if permanent { + capture_state.arm_permanent() + } else { + capture_state.arm() + } } else { capture_state.disarm() } diff --git a/src/features/dashboard/snapshot.tsx b/src/features/dashboard/snapshot.tsx index 20816ae5..c2246366 100644 --- a/src/features/dashboard/snapshot.tsx +++ b/src/features/dashboard/snapshot.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { HelpCircle, RefreshCcw } from "lucide-react" +import { HelpCircle, Power, PowerOff, RefreshCcw } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" @@ -11,8 +11,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { Switch } from "@/components/ui/switch" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" import { readDashboardSnapshot, refreshDashboardModelDiscovery, @@ -30,7 +30,6 @@ import type { DashboardUpstreamOption, } from "@/features/dashboard/types" import { parseError } from "@/lib/error" -import { cn } from "@/lib/utils" import { m } from "@/paraglide/messages.js" export const RECENT_PAGE_SIZE = 50 @@ -276,9 +275,10 @@ type DashboardFiltersProps = { /** 请求详情捕获相关,仅 LogsPanel 使用 */ capture?: { enabled: boolean + isPermanent: boolean loading: boolean statusText?: string - onToggle: (enabled: boolean) => void + onToggle: (enabled: boolean, permanent: boolean) => void } } @@ -296,6 +296,10 @@ export function DashboardFilters({ onRefresh, capture, }: DashboardFiltersProps) { + const [captureMode, setCaptureMode] = useState<"temporary" | "permanent">( + capture?.isPermanent ? "permanent" : "temporary" + ); + return (