mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Offload large network log body formatting to a web worker
This commit is contained in:
+2
-1
@@ -1,4 +1,4 @@
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
@@ -14,6 +14,7 @@ await Promise.all([
|
||||
buildMain({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildRequestLogBodyWorker({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
|
||||
+15
-1
@@ -17,6 +17,7 @@ import {
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
createRequestLogBodyWorkerBuildOptions,
|
||||
createBrowserRendererBuildOptions,
|
||||
createCliBuildOptions,
|
||||
createMainBuildOptions,
|
||||
@@ -51,6 +52,7 @@ let queuedStyleBuildReason = null;
|
||||
const ready = {
|
||||
browser: false,
|
||||
cli: false,
|
||||
logWorker: false,
|
||||
main: false,
|
||||
renderer: false,
|
||||
tray: false,
|
||||
@@ -66,6 +68,7 @@ const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
|
||||
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
|
||||
const activeReadyNames = new Set([
|
||||
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
|
||||
...(enabled.ui ? ["logWorker"] : []),
|
||||
...(enabled.cli ? ["cli"] : []),
|
||||
...(enabled.electron ? ["main"] : [])
|
||||
]);
|
||||
@@ -295,7 +298,7 @@ function pollSourceWatchTargets() {
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
|
||||
if (name === "browser" || name === "cli" || name === "logWorker" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
|
||||
ready[name] = true;
|
||||
}
|
||||
logDev(`build ready: ${reason}; ${readyState()}`);
|
||||
@@ -509,6 +512,17 @@ if (enabled.ui) {
|
||||
})
|
||||
]
|
||||
})
|
||||
),
|
||||
await esbuild.context(
|
||||
createRequestLogBodyWorkerBuildOptions({
|
||||
mode: "development",
|
||||
plugins: [
|
||||
watchPlugin("logWorker", (name) => {
|
||||
syncUiRendererToRuntimeDists();
|
||||
markReady(name);
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray",
|
||||
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
|
||||
export const cssOutput = path.join(rendererAssetsDir, "main.css");
|
||||
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
|
||||
export const requestLogBodyWorkerOutput = path.join(rendererAssetsDir, "log-body.worker.js");
|
||||
export const requestLogBodyWorkerInput = path.join(rendererRoot, "pages", "home", "shared", "log-body.worker.ts");
|
||||
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
|
||||
export const localAgentAuthProviderHookInput = path.join(coreSourceRoot, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts");
|
||||
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts");
|
||||
@@ -379,6 +381,26 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins
|
||||
};
|
||||
}
|
||||
|
||||
export function createRequestLogBodyWorkerBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(mode)
|
||||
},
|
||||
entryPoints: [requestLogBodyWorkerInput],
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
minify: mode === "production",
|
||||
outfile: requestLogBodyWorkerOutput,
|
||||
platform: "browser",
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
|
||||
sourcemap: mode !== "production",
|
||||
target: "chrome120"
|
||||
};
|
||||
}
|
||||
|
||||
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
|
||||
return {
|
||||
absWorkingDir: projectRoot,
|
||||
@@ -464,6 +486,10 @@ export async function buildWebClientBridge(options = {}) {
|
||||
await esbuild.build(createWebClientBridgeBuildOptions(options));
|
||||
}
|
||||
|
||||
export async function buildRequestLogBodyWorker(options = {}) {
|
||||
await esbuild.build(createRequestLogBodyWorkerBuildOptions(options));
|
||||
}
|
||||
|
||||
export function copyCliRuntimeToElectronDist() {
|
||||
ensureDist();
|
||||
const cliRuntime = path.join(cliMainOutDir, "cli.js");
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { RequestRouteTrace, RequestRouteTraceChange, RequestRouteTraceHop }
|
||||
import {
|
||||
AnimatedIconSwap, Check, ChevronDown, ChevronLeft,
|
||||
ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard,
|
||||
Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, filterLogText, formatBytes, formatCompactNumber, formatDuration,
|
||||
formatLogBodyView, formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
|
||||
isJsonContainer, jsonChildPath, logRequestModel,
|
||||
createLogBodyPreviewText, Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, formatBytes, formatCompactNumber, formatDuration,
|
||||
formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
|
||||
FormattedLogBody,
|
||||
isJsonContainer, isLargeLogBody, jsonChildPath, logRequestModel,
|
||||
LogBodyFormatMode, logBodyLargeTextThreshold, logBodyPreviewTextLimit, LogBodyWorkerResponse,
|
||||
logResponseModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel,
|
||||
networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows,
|
||||
Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus,
|
||||
@@ -19,11 +21,10 @@ import { TooltipPortal } from "@/components/ui/tooltip";
|
||||
type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary";
|
||||
type NetworkResponseTab = "body" | "header" | "raw";
|
||||
|
||||
const logBodyViewCacheLimit = 12;
|
||||
const logJsonAutoExpandEntryLimit = 60;
|
||||
const logJsonContainerPreviewLimit = 80;
|
||||
const logJsonAutoExpandTextLimit = 160 * 1024;
|
||||
const logBodyViewCache = new Map<string, ReturnType<typeof formatLogBodyView>>();
|
||||
const logBodyWorkerFilterDebounceMs = 180;
|
||||
type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration";
|
||||
type LogTableColumn = {
|
||||
id: LogTableColumnId;
|
||||
@@ -1496,6 +1497,247 @@ function LogStreamCell({ entry }: { entry: RequestLogEntry }) {
|
||||
|
||||
type LogPayloadTab = "body" | "header";
|
||||
|
||||
type LogBodyPanelView = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
error: string;
|
||||
formattedTextLength: number;
|
||||
large: boolean;
|
||||
loading: boolean;
|
||||
mode: LogBodyFormatMode;
|
||||
preview: boolean;
|
||||
query: string;
|
||||
sourceSizeBytes: number;
|
||||
visible: string;
|
||||
};
|
||||
|
||||
function useLogBodyWorkerView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string
|
||||
): LogBodyPanelView {
|
||||
const debouncedQuery = useDebouncedValue(query, logBodyWorkerFilterDebounceMs);
|
||||
const latestQueryRef = useRef(debouncedQuery);
|
||||
const workerRef = useRef<Worker>();
|
||||
const formatRequestIdRef = useRef(0);
|
||||
const filterRequestIdRef = useRef(0);
|
||||
const [bodyView, setBodyView] = useState<LogBodyPanelView>(() => createInitialLogBodyPanelView(body, bodyKey, mode, query));
|
||||
|
||||
useEffect(() => {
|
||||
latestQueryRef.current = debouncedQuery;
|
||||
}, [debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = createInitialLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current);
|
||||
setBodyView(initial);
|
||||
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = undefined;
|
||||
|
||||
if (isStaticLogBody(body)) {
|
||||
setBodyView(createStaticLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
setBodyView({
|
||||
...initial,
|
||||
error: "Body formatter worker is unavailable.",
|
||||
loading: false,
|
||||
visible: initial.visible || "Body formatter worker is unavailable."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = createLogBodyFormatterWorker();
|
||||
const formatRequestId = formatRequestIdRef.current + 1;
|
||||
formatRequestIdRef.current = formatRequestId;
|
||||
filterRequestIdRef.current += 1;
|
||||
workerRef.current = worker;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<LogBodyWorkerResponse>) => {
|
||||
const response = event.data;
|
||||
if (response.kind === "format-result") {
|
||||
if (response.id !== formatRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
|
||||
return;
|
||||
}
|
||||
setBodyView(logBodyPanelViewFromWorkerResult(response));
|
||||
if (response.query !== latestQueryRef.current) {
|
||||
postLogBodyFilter(worker, bodyKey, mode, latestQueryRef.current, filterRequestIdRef);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.kind === "filter-result") {
|
||||
if (response.id !== filterRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
|
||||
return;
|
||||
}
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, query: response.query, visible: response.visible }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(response.operation === "format" && response.id === formatRequestIdRef.current) ||
|
||||
(response.operation === "filter" && response.id === filterRequestIdRef.current)
|
||||
) {
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, error: response.message, loading: false, visible: current.visible || response.message }
|
||||
: current);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, error: event.message || "Body formatter worker failed.", loading: false }
|
||||
: current);
|
||||
};
|
||||
|
||||
worker.postMessage({
|
||||
body,
|
||||
bodyKey,
|
||||
id: formatRequestId,
|
||||
kind: "format",
|
||||
largeTextThreshold: logBodyLargeTextThreshold,
|
||||
mode,
|
||||
previewTextLimit: logBodyPreviewTextLimit,
|
||||
query: latestQueryRef.current
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (workerRef.current === worker) {
|
||||
workerRef.current = undefined;
|
||||
}
|
||||
worker.terminate();
|
||||
};
|
||||
}, [body, bodyKey, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const worker = workerRef.current;
|
||||
if (!worker || bodyView.loading || bodyView.bodyKey !== bodyKey || bodyView.mode !== mode || bodyView.query === debouncedQuery) {
|
||||
return;
|
||||
}
|
||||
postLogBodyFilter(worker, bodyKey, mode, debouncedQuery, filterRequestIdRef);
|
||||
}, [bodyKey, bodyView.bodyKey, bodyView.loading, bodyView.mode, bodyView.query, debouncedQuery, mode]);
|
||||
|
||||
return bodyView;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delayMs, value]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function createLogBodyFormatterWorker(): Worker {
|
||||
return new Worker(new URL("../../assets/log-body.worker.js", window.location.href), { type: "module" });
|
||||
}
|
||||
|
||||
function postLogBodyFilter(
|
||||
worker: Worker,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string,
|
||||
idRef: { current: number }
|
||||
) {
|
||||
const id = idRef.current + 1;
|
||||
idRef.current = id;
|
||||
worker.postMessage({
|
||||
bodyKey,
|
||||
id,
|
||||
kind: "filter",
|
||||
mode,
|
||||
query
|
||||
});
|
||||
}
|
||||
|
||||
function createInitialLogBodyPanelView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string
|
||||
): LogBodyPanelView {
|
||||
if (isStaticLogBody(body)) {
|
||||
return createStaticLogBodyPanelView(body, bodyKey, mode, query);
|
||||
}
|
||||
|
||||
const large = isLargeLogBody(body, logBodyLargeTextThreshold);
|
||||
const preview = large && mode !== "full";
|
||||
const text = preview
|
||||
? createLogBodyPreviewText(body, logBodyPreviewTextLimit)
|
||||
: "Loading body...";
|
||||
return {
|
||||
bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: text.length,
|
||||
large,
|
||||
loading: true,
|
||||
mode,
|
||||
preview,
|
||||
query,
|
||||
sourceSizeBytes: body?.sizeBytes ?? 0,
|
||||
text,
|
||||
visible: text
|
||||
};
|
||||
}
|
||||
|
||||
function createStaticLogBodyPanelView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string
|
||||
): LogBodyPanelView {
|
||||
const text = body?.text || "No body";
|
||||
return {
|
||||
bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: text.length,
|
||||
large: false,
|
||||
loading: false,
|
||||
mode,
|
||||
preview: false,
|
||||
query,
|
||||
sourceSizeBytes: body?.sizeBytes ?? 0,
|
||||
text,
|
||||
visible: filterStaticLogBodyText(text, query)
|
||||
};
|
||||
}
|
||||
|
||||
function logBodyPanelViewFromWorkerResult(result: Extract<LogBodyWorkerResponse, { kind: "format-result" }>): LogBodyPanelView {
|
||||
return {
|
||||
bodyKey: result.bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: result.formattedTextLength,
|
||||
json: result.json,
|
||||
large: result.large,
|
||||
loading: false,
|
||||
mode: result.mode,
|
||||
preview: result.preview,
|
||||
query: result.query,
|
||||
sourceSizeBytes: result.sourceSizeBytes,
|
||||
text: result.text,
|
||||
visible: result.visible
|
||||
};
|
||||
}
|
||||
|
||||
function isStaticLogBody(body: RequestLogBody | undefined): boolean {
|
||||
return !body || (!body.text && body.sizeBytes === 0);
|
||||
}
|
||||
|
||||
function filterStaticLogBodyText(text: string, query: string): string {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return text;
|
||||
}
|
||||
return text.toLowerCase().includes(normalized) ? text : "No matching lines";
|
||||
}
|
||||
|
||||
function LogJsonPanel({
|
||||
body,
|
||||
className,
|
||||
@@ -1514,20 +1756,25 @@ function LogJsonPanel({
|
||||
const t = useAppText();
|
||||
const [selectedTab, setSelectedTab] = useState<LogPayloadTab>("body");
|
||||
const [preferTextBody, setPreferTextBody] = useState(false);
|
||||
const [bodyMode, setBodyMode] = useState<LogBodyFormatMode>("preview");
|
||||
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const bodyKey = logBodyCacheKey(body);
|
||||
const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]);
|
||||
const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query);
|
||||
const formatted = bodyView.text;
|
||||
const visible = useMemo(() => filterLogText(formatted, query), [formatted, query]);
|
||||
const visible = bodyView.visible;
|
||||
const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]);
|
||||
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView));
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody;
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview;
|
||||
|
||||
useEffect(() => {
|
||||
setPreferTextBody(false);
|
||||
setBodyMode("preview");
|
||||
}, [bodyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView));
|
||||
setPreferTextBody(false);
|
||||
}, [bodyKey]);
|
||||
}, [bodyView.bodyKey, bodyView.json, bodyView.text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenOpen) {
|
||||
@@ -1582,6 +1829,7 @@ function LogJsonPanel({
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
onQueryChange={setQuery}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -1610,6 +1858,7 @@ function LogJsonPanel({
|
||||
copyText={formatted}
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onClose={() => setFullscreenOpen(false)}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
onQueryChange={setQuery}
|
||||
onToggleJsonPath={toggleJsonPath}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
@@ -1636,6 +1885,7 @@ function LogJsonPanel({
|
||||
function LogJsonBodyToolbar({
|
||||
body,
|
||||
bodyView,
|
||||
onLoadFullBody,
|
||||
onQueryChange,
|
||||
onToggleTextBody,
|
||||
preferTextBody,
|
||||
@@ -1643,7 +1893,8 @@ function LogJsonBodyToolbar({
|
||||
title
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: ReturnType<typeof formatLogBodyView>;
|
||||
bodyView: LogBodyPanelView;
|
||||
onLoadFullBody: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
preferTextBody: boolean;
|
||||
@@ -1651,6 +1902,12 @@ function LogJsonBodyToolbar({
|
||||
title: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === "";
|
||||
const canToggleJsonText = bodyView.json !== undefined && query.trim() === "";
|
||||
const showToggleButton = canLoadFullBody || canToggleJsonText;
|
||||
const toggleLabel = canLoadFullBody
|
||||
? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content")
|
||||
: preferTextBody ? "JSON" : t("Show full content");
|
||||
|
||||
return (
|
||||
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
||||
@@ -1664,15 +1921,20 @@ function LogJsonBodyToolbar({
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{bodyView.json !== undefined && query.trim() === "" ? (
|
||||
{showToggleButton ? (
|
||||
<button
|
||||
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
|
||||
onClick={onToggleTextBody}
|
||||
disabled={bodyView.loading && bodyView.mode === "full"}
|
||||
onClick={canLoadFullBody ? onLoadFullBody : onToggleTextBody}
|
||||
type="button"
|
||||
>
|
||||
{preferTextBody ? "JSON" : t("Show full content")}
|
||||
{toggleLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{bodyView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
|
||||
{bodyView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{bodyView.error}</span> : null}
|
||||
{bodyView.preview ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("preview")}</span> : null}
|
||||
{bodyView.sourceSizeBytes > 0 ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{formatBytes(bodyView.sourceSizeBytes)}</span> : null}
|
||||
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
|
||||
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
|
||||
</div>
|
||||
@@ -1706,6 +1968,7 @@ function LogJsonFullscreenViewer({
|
||||
copyText,
|
||||
expandedJsonPaths,
|
||||
onClose,
|
||||
onLoadFullBody,
|
||||
onQueryChange,
|
||||
onToggleJsonPath,
|
||||
onToggleTextBody,
|
||||
@@ -1718,11 +1981,12 @@ function LogJsonFullscreenViewer({
|
||||
visible
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: ReturnType<typeof formatLogBodyView>;
|
||||
bodyView: LogBodyPanelView;
|
||||
copyLabel: string;
|
||||
copyText: string;
|
||||
expandedJsonPaths: Set<string>;
|
||||
onClose: () => void;
|
||||
onLoadFullBody: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleJsonPath: (path: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
@@ -1760,6 +2024,7 @@ function LogJsonFullscreenViewer({
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={onLoadFullBody}
|
||||
onQueryChange={onQueryChange}
|
||||
onToggleTextBody={onToggleTextBody}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -1798,27 +2063,7 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function cachedFormatLogBodyView(key: string, body: RequestLogBody | undefined): ReturnType<typeof formatLogBodyView> {
|
||||
const cached = logBodyViewCache.get(key);
|
||||
if (cached) {
|
||||
logBodyViewCache.delete(key);
|
||||
logBodyViewCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = formatLogBodyView(body);
|
||||
logBodyViewCache.set(key, value);
|
||||
while (logBodyViewCache.size > logBodyViewCacheLimit) {
|
||||
const oldest = logBodyViewCache.keys().next().value;
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
logBodyViewCache.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createInitialVisibleJsonPaths(bodyView: ReturnType<typeof formatLogBodyView>): Set<string> {
|
||||
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set<string> {
|
||||
if (!isJsonContainer(bodyView.json)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./profiles";
|
||||
export * from "./services";
|
||||
export * from "./provider-accounts";
|
||||
export * from "./logs";
|
||||
export * from "./log-body-worker-protocol";
|
||||
export * from "./common";
|
||||
export * from "./config";
|
||||
export * from "./api-keys";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { RequestLogBody } from "@ccr/core/contracts/app";
|
||||
import type { FormattedLogBody } from "./logs";
|
||||
|
||||
export const logBodyLargeTextThreshold = 256 * 1024;
|
||||
export const logBodyPreviewTextLimit = 160 * 1024;
|
||||
|
||||
export type LogBodyFormatMode = "full" | "preview";
|
||||
|
||||
export type LogBodyFormatRequest = {
|
||||
body?: RequestLogBody;
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "format";
|
||||
largeTextThreshold?: number;
|
||||
mode: LogBodyFormatMode;
|
||||
previewTextLimit?: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type LogBodyFilterRequest = {
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "filter";
|
||||
mode: LogBodyFormatMode;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type LogBodyWorkerRequest = LogBodyFilterRequest | LogBodyFormatRequest;
|
||||
|
||||
export type LogBodyFormatResult = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
formattedTextLength: number;
|
||||
id: number;
|
||||
kind: "format-result";
|
||||
large: boolean;
|
||||
mode: LogBodyFormatMode;
|
||||
ok: true;
|
||||
preview: boolean;
|
||||
query: string;
|
||||
sourceSizeBytes: number;
|
||||
visible: string;
|
||||
};
|
||||
|
||||
export type LogBodyFilterResult = {
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "filter-result";
|
||||
mode: LogBodyFormatMode;
|
||||
ok: true;
|
||||
query: string;
|
||||
visible: string;
|
||||
};
|
||||
|
||||
export type LogBodyWorkerError = {
|
||||
bodyKey?: string;
|
||||
id: number;
|
||||
kind: "error";
|
||||
message: string;
|
||||
mode?: LogBodyFormatMode;
|
||||
operation: LogBodyWorkerRequest["kind"];
|
||||
};
|
||||
|
||||
export type LogBodyWorkerResponse = LogBodyFilterResult | LogBodyFormatResult | LogBodyWorkerError;
|
||||
|
||||
export function isLargeLogBody(
|
||||
body: RequestLogBody | undefined,
|
||||
threshold = logBodyLargeTextThreshold
|
||||
): boolean {
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
return Math.max(body.sizeBytes, body.text.length) > threshold;
|
||||
}
|
||||
|
||||
export function createLogBodyPreviewText(
|
||||
body: RequestLogBody | undefined,
|
||||
limit = logBodyPreviewTextLimit
|
||||
): string {
|
||||
if (!body || (!body.text && body.sizeBytes === 0)) {
|
||||
return "No body";
|
||||
}
|
||||
|
||||
const text = body.text || "";
|
||||
if (!text) {
|
||||
return "Body text is not loaded.";
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const headLength = Math.max(0, Math.floor(limit * 0.65));
|
||||
const tailLength = Math.max(0, limit - headLength);
|
||||
const omitted = Math.max(0, text.length - headLength - tailLength);
|
||||
const head = text.slice(0, headLength);
|
||||
const tail = tailLength > 0 ? text.slice(-tailLength) : "";
|
||||
return [
|
||||
head,
|
||||
"",
|
||||
`... ${omitted} characters omitted from preview ...`,
|
||||
"",
|
||||
tail
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
createLogBodyPreviewText,
|
||||
isLargeLogBody,
|
||||
logBodyLargeTextThreshold,
|
||||
logBodyPreviewTextLimit,
|
||||
type LogBodyFilterRequest,
|
||||
type LogBodyFormatRequest,
|
||||
type LogBodyFormatResult,
|
||||
type LogBodyWorkerRequest,
|
||||
type LogBodyWorkerResponse
|
||||
} from "./log-body-worker-protocol";
|
||||
import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs";
|
||||
|
||||
type CachedFormattedBody = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
large: boolean;
|
||||
mode: LogBodyFormatRequest["mode"];
|
||||
preview: boolean;
|
||||
sourceSizeBytes: number;
|
||||
};
|
||||
|
||||
type LogBodyWorkerGlobal = {
|
||||
onmessage: ((event: MessageEvent<LogBodyWorkerRequest>) => void) | null;
|
||||
postMessage: (message: LogBodyWorkerResponse) => void;
|
||||
};
|
||||
|
||||
const worker = self as unknown as LogBodyWorkerGlobal;
|
||||
let cachedBody: CachedFormattedBody | undefined;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<LogBodyWorkerRequest>) => {
|
||||
const request = event.data;
|
||||
try {
|
||||
if (request.kind === "format") {
|
||||
worker.postMessage(formatBody(request) satisfies LogBodyWorkerResponse);
|
||||
return;
|
||||
}
|
||||
worker.postMessage(filterBody(request) satisfies LogBodyWorkerResponse);
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
bodyKey: "bodyKey" in request ? request.bodyKey : undefined,
|
||||
id: request.id,
|
||||
kind: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
mode: "mode" in request ? request.mode : undefined,
|
||||
operation: request.kind
|
||||
} satisfies LogBodyWorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult {
|
||||
const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold;
|
||||
const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit;
|
||||
const large = isLargeLogBody(request.body, threshold);
|
||||
const preview = large && request.mode !== "full";
|
||||
const bodyView = preview
|
||||
? { text: createLogBodyPreviewText(request.body, previewLimit) }
|
||||
: formatLogBodyView(request.body);
|
||||
const sourceSizeBytes = request.body?.sizeBytes ?? 0;
|
||||
const visible = filterLogText(bodyView.text, request.query);
|
||||
|
||||
cachedBody = {
|
||||
...bodyView,
|
||||
bodyKey: request.bodyKey,
|
||||
large,
|
||||
mode: request.mode,
|
||||
preview,
|
||||
sourceSizeBytes
|
||||
};
|
||||
|
||||
return {
|
||||
...bodyView,
|
||||
bodyKey: request.bodyKey,
|
||||
formattedTextLength: bodyView.text.length,
|
||||
id: request.id,
|
||||
kind: "format-result",
|
||||
large,
|
||||
mode: request.mode,
|
||||
ok: true,
|
||||
preview,
|
||||
query: request.query,
|
||||
sourceSizeBytes,
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
function filterBody(request: LogBodyFilterRequest): LogBodyWorkerResponse {
|
||||
if (!cachedBody || cachedBody.bodyKey !== request.bodyKey || cachedBody.mode !== request.mode) {
|
||||
throw new Error("Formatted body cache is not available.");
|
||||
}
|
||||
|
||||
return {
|
||||
bodyKey: request.bodyKey,
|
||||
id: request.id,
|
||||
kind: "filter-result",
|
||||
mode: request.mode,
|
||||
ok: true,
|
||||
query: request.query,
|
||||
visible: filterLogText(cachedBody.text, request.query)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user