mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)
* feat(responses): virtualize previous_response_id continuation regardless of upstream support OmniRoute now exposes OpenAI-compatible previous_response_id/store continuation to clients unconditionally, even when the selected upstream provider has no native Responses-API state support. Reconstruction happens server-side in handleChatImplementation, before any downstream validation or provider translation: OmniRoute resolves the response id back to the full input/output it previously produced, prepends it to the client's delta, and forwards the full reconstructed history upstream exactly as it does today. Client<->OmniRoute traffic shrinks to the new delta only; OmniRoute<->provider traffic is unchanged. Storage reuses the existing call-log pipeline artifact (already gated by call_log_pipeline_enabled, already retained/cleaned up by the existing call-log lifecycle) instead of duplicating conversation content into a second store -- only a lightweight call_logs.response_id index is new. Every lookup is scoped by api_key_id so one client can never resolve another client's stored conversation, and any unresolvable/missing/ size-limit-omitted state fails closed with OpenAI's own previous_response_not_found contract. Stacked on feat/openai-responses-store-toggle (#10121). * feat(dashboard): agentic conversation tracking with live transcript view Every agentic chat request now gets a conversation id (X-ConversationId response header). OmniRoute detects when a follow-up request continues the same conversation via fingerprint + bounded prefix-hash matching, with a strict-growth invariant to prevent false merges between independent single-shot requests that happen to share identical opening content. Continuation detection excludes the system message from the identity anchor, since real coding-agent CLIs commonly regenerate it every request with live context (timestamp, cwd, git status) — without this, that volatility alone broke every continuation check against real traffic. - `/dashboard/logs`: new toggleable Conversation column. - `/dashboard/logs/timeline`: requests sharing a conversation id share a timeline lane, connected by an arrow, with a configurable lane-reuse window. - Request detail panel: new Full Conversation transcript above the raw SSE event stream — Markdown rendering, per-turn timestamps, turn-relative view, click-any-turn navigation, live auto-refresh building the transcript in real time from the in-flight SSE chunk buffer while a request is still streaming, auto-scroll-to-bottom as the live turn grows. - New `/dashboard/conversations` page listing conversations with 2+ turns, no-forking model (an edited/duplicated mid-history turn mints its own independent conversation instead of merging), pagination, duplicate- anchor fix. - Configurable auto-refresh intervals on both the timeline and conversations list pages. - Responses API tool-call gap fix: turnsFromOpenAiMessages only handled role-based Chat Completions messages, so bare {type:"function_call"} / {type:"function_call_output"} / {type:"reasoning"} items (real Responses API traffic) silently vanished from the Conversation Context panel. - truncateForLog now counts input[] (Responses API), not just messages[] (Chat Completions), so a truncated /v1/responses request still shows a placeholder instead of nothing. - RequestTimeline.tsx now reads the same debugEnabled/emailsVisible settings RequestLoggerV2.tsx already used, instead of hardcoding both false — the timeline view never showed SSE/stream-chunk events or respected email-masking, regardless of the actual setting. Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135 and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this uses the next free slot past upstream's current highest. Test plan: - npm run typecheck:core — clean - npm run lint — clean - node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions - 109 unit tests across the conversation-tracking, migration-renumber, and dashboard-wiring surface — 0 failures * refactor(dashboard): reuse call-log artifacts for conversation transcript content conversation_turn_nodes no longer stores turn text/tool-call content (text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/ content_hash), matching agentic_conversations' existing lightweight-index shape. Every node's originating request is already fully captured by the call-log pipeline artifact its last_correlation_id points at, so the /dashboard/conversations tree view resolves each node's actual display content on demand from there (open-sse/services/conversationTurnContent.ts), re-running the same extractCanonicalTurns/hashTurnContent the write path used and matching by content_hash, instead of duplicating conversation content into a second store under a separate retention/gating policy. This also drops the old 8000-char text_preview truncation entirely -- resolved content is always full and untruncated. The frontend contract is unchanged (tree API still returns {textPreview, blockKind, toolName} per node), so the dashboard UI itself (page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no changes. Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now collides with 147_api_keys_model_access_mode.sql, which landed on release/v3.8.50 after this work was originally built. Also includes a standalone, unrelated fix carried along from this rebase: close isProviderModelHidden's missing function-body brace in modelSelectModalHelpers.ts (separately landed as #10206). Stacked on feat/responses-previous-response-id-virtualization (#3), which is itself stacked on feat/openai-responses-store-toggle (#10121). * fix(dashboard): resync conversation list on open so the live-text poll starts immediately openConversation() seeded activeConversation (and therefore activeCallLogId, which gates the live-partial-text poll effect) from whatever row snapshot the list's own fixed-interval poll last produced. A conversation opened right after a reply started streaming -- after that tick, before the next -- had activeCallLogId still null, so the live-text poll never started; only a subsequent background list-poll resync (already existed) picked it up, which is why closing and reopening the same conversation "just worked". loadConversations() is now a shared callback so openConversation can force one immediately on open instead of waiting on pollSeconds. Live-verified against omniroute-dev: opening a conversation mid-stream now shows live reasoning on the first open. * style: prettier formatting for conversationTurnContent.test.ts * fix(db): close migration numbering gap left by decoupling from #3/#10262 153/154 (originally 154/155) were chosen back when this branch stacked on top of the previous_response_id migration (153_call_logs_response_id.sql). Decoupling removed that migration from this branch's history, leaving an unused 153 slot that check-migration-numbering.test.ts correctly flags as a gap. * refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap Both files exceeded check-file-size's new-file cap after this PR's own additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure non-component logic (types, constants, allocateLanes and its helpers) out of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two self-contained presentational sub-components (PayloadSection, ConversationContextSection + its private helper) out of RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior change; existing external imports (default exports, allocateLanes, TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the original file paths. * fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs The refresh-merge of release/v3.8.50 exposed that the feature's three migrations collided at slot 153 with the base's radar_local_model_state (153) and its own call_logs_response_id. Migration runner enforces unique numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and the DB-backed quality gates. Renumber the feature's pair to 155_agentic_conversations / 156_conversation_turn_nodes and move call_logs_response_id to 154 (keeps 153_radar base-owned, preserves agentic-before-turn_nodes ordering). Update SQL headers and the 154/156 references in feature code + tests. Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync the doc counts to clear the docs-accuracy gate. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline Knip 6.32 (baseline 415) flags the public re-export of CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no external consumer imports it through that re-export (it is imported and used directly from RequestTimeline.utils.ts inside the component). Removed the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415, back to the frozen baseline. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export - Wrap resolveConversationId() in try/catch in chat.ts, matching the defensive pattern used by every other best-effort side call nearby, so a DB hiccup in conversation tracking can't turn a working chat request into a hard failure. - Remove getConversationTurnTree: knip's project scope excludes tests/**, so an export used only by tests can never register as used there. Swap its 8 test call sites to the paginated getConversationTurnPage (already the dashboard's canonical query) with a generous limit, collapsing to one query path instead of keeping a second whole-chain export alive solely for test convenience. - Regenerate i18n llm.txt mirrors from root (pre-existing drift on this branch, unrelated to the above, caught by the docs-sync pre-commit gate). Addresses PR review feedback. * fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift - fr.json, vi.json were missing requestLogger.columns.conversation (added in the conversation-tracking feature), failing i18n-vi-completeness.test.ts. - docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an earlier rebase fixed the migration count but missed this companion number, failing check-docs-sync.mjs across all 42 locales. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red) .env.example and docs/reference/ENVIRONMENT.md were both missing the PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads (confirmed present at this branch's merge-base too, so this predates the conversation-tracking work and is unrelated to it) -- the entry was added on release/v3.8.50 after this branch's last sync and this branch never picked it up. That gap red-lines tests/unit/check-env-doc-sync.test.ts and tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests fast-path 2/4 in CI). Restore both entries verbatim from the current release/v3.8.50 tip -- no feature-code change. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: hartmark <hartmark@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
242
src/shared/components/RequestLoggerDetail.sections.tsx
Normal file
242
src/shared/components/RequestLoggerDetail.sections.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
|
||||
import { buildRequestTurns, buildResponseTurns } from "@/mitm/inspector/conversationNormalizer";
|
||||
import type { InterceptedRequest, NormalizedTurn } from "@/mitm/inspector/types";
|
||||
|
||||
// ─── Payload Code Block ─────────────────────────────────────────────────────
|
||||
|
||||
export function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
|
||||
const t = useTranslations("requestLogger.detail");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await onCopy();
|
||||
if (success !== false) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={open ? t("collapse", { title }) : t("expand", { title })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={t("copyTitle", { title })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{json}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Conversation context section ───────────────────────────────────────────
|
||||
// Renders THIS request's own context (its request body's messages/input, plus
|
||||
// its response) — a plain single-request normalization, same shape as the
|
||||
// traffic-inspector's ConversationTab, no cross-request reconstruction. While
|
||||
// the request is still generating (detail.active === true) the response side
|
||||
// shows the partial text captured so far, refreshed on a short poll scoped to
|
||||
// just this section.
|
||||
const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200;
|
||||
|
||||
function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest {
|
||||
return {
|
||||
id: "",
|
||||
source: "custom-host",
|
||||
timestamp: "",
|
||||
method: "POST",
|
||||
host: "",
|
||||
path: "",
|
||||
requestHeaders: {},
|
||||
requestBody: null,
|
||||
requestSize: 0,
|
||||
responseHeaders: {},
|
||||
responseBody: responseBody != null ? JSON.stringify(responseBody) : null,
|
||||
responseSize: 0,
|
||||
status: 0,
|
||||
detectedKind: "llm",
|
||||
};
|
||||
}
|
||||
|
||||
export function ConversationContextSection({ log, detail }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [liveDetail, setLiveDetail] = useState(detail);
|
||||
const [liveRefresh, setLiveRefresh] = useState(() => {
|
||||
try {
|
||||
const v = localStorage.getItem("pref:conversationContext:liveRefresh");
|
||||
return v == null ? true : v === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const turnsBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveDetail(detail);
|
||||
}, [detail]);
|
||||
|
||||
// Same live-poll pattern as the SSE Events section (StreamSection below),
|
||||
// but gated on liveRefresh too: an active request keeps generating either
|
||||
// way, this toggle only controls whether THIS panel keeps fetching/
|
||||
// redrawing while the user reads it.
|
||||
useEffect(() => {
|
||||
if (!liveDetail?.active || !liveRefresh) return;
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
if (document.visibilityState !== "visible") {
|
||||
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
setLiveDetail(data);
|
||||
if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
|
||||
})
|
||||
.catch(() => {
|
||||
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
|
||||
});
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [liveDetail?.active, liveRefresh, log.id]);
|
||||
|
||||
const toggleLiveRefresh = () => {
|
||||
const next = !liveRefresh;
|
||||
setLiveRefresh(next);
|
||||
try {
|
||||
localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = turnsBoxRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} catch {}
|
||||
});
|
||||
};
|
||||
|
||||
const requestBody =
|
||||
liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null;
|
||||
const requestTurns = buildRequestTurns(requestBody) ?? [];
|
||||
|
||||
const responseBody = liveDetail?.responseBody ?? null;
|
||||
const responseTurns: NormalizedTurn[] =
|
||||
responseBody != null
|
||||
? buildResponseTurns(asInterceptedResponseBody(responseBody))
|
||||
: liveDetail?.partialAssistantText
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
blocks: [{ type: "text", text: liveDetail.partialAssistantText }],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns];
|
||||
|
||||
// Follow new content as it streams in — same idea as StreamSection's
|
||||
// autoscroll effect, tied to the same liveRefresh toggle.
|
||||
useEffect(() => {
|
||||
if (!liveRefresh || !open) return;
|
||||
scrollToBottom();
|
||||
}, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]);
|
||||
|
||||
if (allTurns.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
|
||||
Conversation Context
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={open ? "Collapse Conversation Context" : "Expand Conversation Context"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="flex items-center gap-1">
|
||||
{liveDetail?.active && (
|
||||
<button
|
||||
onClick={toggleLiveRefresh}
|
||||
title={liveRefresh ? "Live refresh: on" : "Live refresh: off"}
|
||||
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${liveRefresh ? "text-primary" : ""}`}
|
||||
aria-pressed={liveRefresh}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{liveRefresh ? "sync" : "sync_disabled"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
title="Go to bottom"
|
||||
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label="Go to bottom"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
ref={turnsBoxRef}
|
||||
className="rounded-xl bg-black/5 dark:bg-black/30 border border-border max-h-150 overflow-y-auto p-3 space-y-2"
|
||||
>
|
||||
{allTurns.map((turn, i) => (
|
||||
<ChatBubble key={i} turn={turn} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,60 +9,10 @@ import {
|
||||
} from "@/shared/constants/colors";
|
||||
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
|
||||
import { formatErrorForDisplay } from "@/shared/utils/formatting";
|
||||
|
||||
// ─── Payload Code Block ─────────────────────────────────────────────────────
|
||||
|
||||
function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
|
||||
const t = useTranslations("requestLogger.detail");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await onCopy();
|
||||
if (success !== false) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={open ? t("collapse", { title }) : t("expand", { title })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={t("copyTitle", { title })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{json}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import {
|
||||
PayloadSection,
|
||||
ConversationContextSection,
|
||||
} from "@/shared/components/RequestLoggerDetail.sections";
|
||||
|
||||
// ─── Stream section + Detail Modal ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -354,7 +304,7 @@ export default function RequestLoggerDetail({
|
||||
const codexAccountRotation = getCodexAccountRotation(detail);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[5vh]"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center px-2 pt-[5vh] sm:px-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -362,12 +312,12 @@ export default function RequestLoggerDetail({
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-225 max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
className="relative w-full max-w-225 max-h-[90vh] overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-bg-primary shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl sm:px-6 sm:py-4">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 sm:gap-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{log.active ? (
|
||||
@@ -414,23 +364,31 @@ export default function RequestLoggerDetail({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label={t("previousRequest")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label={t("nextRequest")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Only rendered when a caller actually wires up navigation (RequestLoggerV2's
|
||||
list view) — a caller with no ordered-list context to navigate through
|
||||
(conversations page, RequestTimeline) passes neither, so there's nothing
|
||||
to show instead of a permanently-disabled dead button. */}
|
||||
{(onPrevious || onNext) && (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label={t("previousRequest")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label={t("nextRequest")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
@@ -441,7 +399,7 @@ export default function RequestLoggerDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<div className="p-4 flex flex-col gap-6 sm:p-6">
|
||||
{/* Metadata Grid */}
|
||||
{log.active ? (
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
|
||||
@@ -868,6 +826,8 @@ export default function RequestLoggerDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConversationContextSection key={log.id} log={log} detail={detail} />
|
||||
|
||||
{streamChunks && streamChunks.provider && (
|
||||
<StreamSection
|
||||
title={t("providerEventStream")}
|
||||
|
||||
@@ -125,6 +125,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
{ key: "tps", label: t("columns.tps") },
|
||||
{ key: "duration", label: t("columns.duration") },
|
||||
{ key: "time", label: t("columns.time") },
|
||||
{ key: "conversation", label: t("columns.conversation") },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
@@ -188,6 +189,12 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
const hasScrolledRef = useRef(false);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const visibleRef = useRef(true);
|
||||
// Set when handlePrev/handleNext hits the edge of the (possibly stale —
|
||||
// list polling pauses while a detail modal is open) in-memory list, so we
|
||||
// can tell a genuine "no more items" from "more items landed in the
|
||||
// background while the modal was open and we just haven't fetched them
|
||||
// yet" before giving up and closing the modal.
|
||||
const pendingBoundaryNavRef = useRef<null | "prev" | "next">(null);
|
||||
|
||||
const [visibleColumns, setVisibleColumns] = useState(() => {
|
||||
const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true]));
|
||||
@@ -750,9 +757,14 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
console.error("Failed to open previous log id:", error_);
|
||||
});
|
||||
} else {
|
||||
closeDetail();
|
||||
// List polling pauses while the modal is open (#background list can
|
||||
// go stale), so hitting the edge of the in-memory array doesn't mean
|
||||
// there's really nothing newer — resync once and let the effect below
|
||||
// decide, instead of assuming this is the last item and closing.
|
||||
pendingBoundaryNavRef.current = "prev";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav]);
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const idx = currentLogIndex;
|
||||
@@ -764,10 +776,44 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
.catch((error_) => {
|
||||
console.error("Failed to open previous log id:", error_);
|
||||
});
|
||||
} else {
|
||||
pendingBoundaryNavRef.current = "next";
|
||||
fetchLogs(false);
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
|
||||
|
||||
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
|
||||
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires
|
||||
// when a boundary nav is actually pending, so this is a no-op on the
|
||||
// normal (paused-while-modal-open) list-update cadence.
|
||||
useEffect(() => {
|
||||
const direction = pendingBoundaryNavRef.current;
|
||||
if (!direction || !selectedLog) return;
|
||||
pendingBoundaryNavRef.current = null;
|
||||
const idx = sortedLogsForNav.findIndex((l) => l.id === selectedLog.id);
|
||||
const target =
|
||||
direction === "prev"
|
||||
? idx > 0
|
||||
? sortedLogsForNav[idx - 1]
|
||||
: null
|
||||
: idx >= 0 && idx < sortedLogsForNav.length - 1
|
||||
? sortedLogsForNav[idx + 1]
|
||||
: null;
|
||||
if (target?.id) {
|
||||
openDetail(target)
|
||||
.then((r) => r)
|
||||
.catch((error_) => {
|
||||
console.error("Failed to open adjacent log id:", error_);
|
||||
});
|
||||
} else {
|
||||
closeDetail();
|
||||
}
|
||||
}, [currentLogIndex, sortedLogsForNav]);
|
||||
// openDetail/closeDetail are plain functions re-created every render
|
||||
// (same as handlePrev/handleNext above and the rest of this file) —
|
||||
// listing them would re-fire this effect on every render instead of
|
||||
// only when sortedLogsForNav/selectedLog actually change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortedLogsForNav, selectedLog]);
|
||||
|
||||
const toggleDetailLogging = async () => {
|
||||
setDetailLoggingLoading(true);
|
||||
@@ -1241,6 +1287,9 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
{getSortIndicator("time")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.conversation && (
|
||||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.conversation")}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
@@ -1588,6 +1637,15 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
{formatTime(log.timestamp)}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.conversation && (
|
||||
<td className="px-3 py-2 font-mono text-[10px] text-text-muted">
|
||||
{log.sessionTag ? (
|
||||
<span title={log.sessionTag}>{log.sessionTag.slice(0, 12)}…</span>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,134 +3,35 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getHttpStatusStyle } from "@/shared/constants/colors";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import {
|
||||
type TimelineLog,
|
||||
type ViewMode,
|
||||
VISIBLE_WINDOW_MS,
|
||||
BAR_HEIGHT,
|
||||
LANE_GAP,
|
||||
LANE_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
AXIS_HEIGHT,
|
||||
MIN_BAR_WIDTH,
|
||||
DEFAULT_LIST_POLL_SECONDS,
|
||||
TIMELINE_LIST_POLL_STORAGE_KEY,
|
||||
FOLLOW_LINE_X,
|
||||
LIVE_LINE_FRACTION,
|
||||
computeBarRange,
|
||||
MODE_META,
|
||||
formatTimeAxis,
|
||||
getStatusColor,
|
||||
CONVERSATION_LANE_REUSE_STORAGE_KEY,
|
||||
allocateLanes,
|
||||
truncateModel,
|
||||
formatDateLabel,
|
||||
} from "@/shared/components/RequestTimeline.utils";
|
||||
|
||||
interface TimelineLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
status: number;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
account: string | null;
|
||||
duration: number;
|
||||
tokens: { in: number; out: number };
|
||||
active?: boolean;
|
||||
completed?: boolean;
|
||||
error?: string | null;
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
interface Lane {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
type ViewMode = "follow" | "live" | "pan";
|
||||
|
||||
const VISIBLE_WINDOW_MS = 5 * 60 * 1000;
|
||||
const BAR_HEIGHT = 28;
|
||||
const LANE_GAP = 4;
|
||||
const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP;
|
||||
const HEADER_HEIGHT = 48;
|
||||
const AXIS_HEIGHT = 32;
|
||||
const MIN_BAR_WIDTH = 3;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const FOLLOW_LINE_X = 0.75;
|
||||
const LIVE_LINE_FRACTION = 0.9;
|
||||
|
||||
function computeBarRange(log: TimelineLog, nowMs: number): { startMs: number; endMs: number } {
|
||||
const ts = new Date(log.timestamp).getTime();
|
||||
if (log.active) return { startMs: ts, endMs: nowMs };
|
||||
if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) };
|
||||
return { startMs: ts - (log.duration || 0), endMs: ts };
|
||||
}
|
||||
|
||||
const MODE_META: Record<ViewMode, { labelKey: string; descriptionKey: string }> = {
|
||||
follow: {
|
||||
labelKey: "follow",
|
||||
descriptionKey: "followDescription",
|
||||
},
|
||||
live: {
|
||||
labelKey: "now",
|
||||
descriptionKey: "nowDescription",
|
||||
},
|
||||
pan: {
|
||||
labelKey: "pan",
|
||||
descriptionKey: "panDescription",
|
||||
},
|
||||
};
|
||||
|
||||
function formatTimeAxis(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const h = d.getHours().toString().padStart(2, "0");
|
||||
const m = d.getMinutes().toString().padStart(2, "0");
|
||||
const s = d.getSeconds().toString().padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status: number, active: boolean | undefined): string {
|
||||
if (active) return "#6366F1";
|
||||
return getHttpStatusStyle(status).bg;
|
||||
}
|
||||
|
||||
function allocateLanes(items: TimelineLog[], nowMs: number): Map<string, number> {
|
||||
const lanes: Lane[] = [];
|
||||
const laneMap = new Map<string, number>();
|
||||
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aStart = new Date(a.timestamp).getTime();
|
||||
const bStart = new Date(b.timestamp).getTime();
|
||||
return aStart - bStart;
|
||||
});
|
||||
|
||||
for (const item of sorted) {
|
||||
const { startMs, endMs } = computeBarRange(item, nowMs);
|
||||
|
||||
let placed = false;
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (lanes[i].endMs < startMs) {
|
||||
lanes[i] = { startMs, endMs };
|
||||
laneMap.set(item.id, i);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
laneMap.set(item.id, lanes.length);
|
||||
lanes.push({ startMs, endMs });
|
||||
}
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
function truncateModel(model: string | null): string {
|
||||
if (!model) return "";
|
||||
const parts = model.split("/");
|
||||
const short = parts[parts.length - 1];
|
||||
return short.length > 16 ? short.slice(0, 15) + "\u2026" : short;
|
||||
}
|
||||
|
||||
function formatDateLabel(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${months[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
export type { TimelineLog } from "@/shared/components/RequestTimeline.utils";
|
||||
export { allocateLanes } from "@/shared/components/RequestTimeline.utils";
|
||||
|
||||
export default function RequestTimeline({
|
||||
initialSelectedId,
|
||||
@@ -152,9 +53,31 @@ export default function RequestTimeline({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartOffset, setDragStartOffset] = useState(0);
|
||||
const { emailsVisible } = useEmailPrivacyStore();
|
||||
const [selectedLog, setSelectedLog] = useState<TimelineLog | null>(null);
|
||||
const [detailData, setDetailData] = useState<any>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false);
|
||||
const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => {
|
||||
if (globalThis.window === undefined) return 2;
|
||||
try {
|
||||
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
|
||||
const parsed = saved ? Number(saved) : 2;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
|
||||
} catch {
|
||||
return 2;
|
||||
}
|
||||
});
|
||||
const [listPollSeconds, setListPollSeconds] = useState(() => {
|
||||
if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS;
|
||||
try {
|
||||
const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY);
|
||||
const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS;
|
||||
} catch {
|
||||
return DEFAULT_LIST_POLL_SECONDS;
|
||||
}
|
||||
});
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
// Guards the ?id= deep-link mount effect below. Also armed by any manual
|
||||
@@ -164,6 +87,16 @@ export default function RequestTimeline({
|
||||
// reopen the modal right after the user closed it.
|
||||
const initialOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/logs/detail?limit=1")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
setDetailLoggingEnabled(data.enabled === true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/usage/call-logs?limit=200")
|
||||
@@ -181,12 +114,12 @@ export default function RequestTimeline({
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((data) => setLogs(data))
|
||||
.catch(() => {});
|
||||
}, POLL_INTERVAL_MS);
|
||||
}, listPollSeconds * 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
}, [listPollSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return undefined;
|
||||
@@ -252,7 +185,10 @@ export default function RequestTimeline({
|
||||
});
|
||||
}, [logs, timeStart, timeEnd, nowMs]);
|
||||
|
||||
const laneMap = useMemo(() => allocateLanes(logs, nowMs), [logs, nowMs]);
|
||||
const laneMap = useMemo(
|
||||
() => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000),
|
||||
[logs, nowMs, conversationLaneReuseMinutes]
|
||||
);
|
||||
const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]);
|
||||
|
||||
const barElements = useMemo(() => {
|
||||
@@ -273,6 +209,39 @@ export default function RequestTimeline({
|
||||
});
|
||||
}, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]);
|
||||
|
||||
// One connector per consecutive pair of bars sharing a conversation id AND
|
||||
// lane (i.e. allocateLanes actually treated them as one continuous
|
||||
// conversation, not two bars that just happen to be adjacent).
|
||||
const connectorElements = useMemo(() => {
|
||||
const byConversation = new Map<string, typeof barElements>();
|
||||
for (const el of barElements) {
|
||||
const cid = el.log.sessionTag;
|
||||
if (!cid) continue;
|
||||
const list = byConversation.get(cid);
|
||||
if (list) list.push(el);
|
||||
else byConversation.set(cid, [el]);
|
||||
}
|
||||
|
||||
const connectors: { id: string; x1: number; x2: number; y: number }[] = [];
|
||||
for (const els of byConversation.values()) {
|
||||
const sorted = [...els].sort(
|
||||
(a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime()
|
||||
);
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed
|
||||
connectors.push({
|
||||
id: `${a.log.id}-${b.log.id}`,
|
||||
x1: a.leftPct + a.widthPct,
|
||||
x2: b.leftPct,
|
||||
y: a.topPx + BAR_HEIGHT / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
return connectors;
|
||||
}, [barElements]);
|
||||
|
||||
const axisTicks = useMemo(() => {
|
||||
const totalMs = timeEnd - timeStart;
|
||||
if (totalMs <= 0) return [];
|
||||
@@ -374,33 +343,43 @@ export default function RequestTimeline({
|
||||
|
||||
// Deep-link support: open the request from ?id= on mount without waiting for
|
||||
// it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail).
|
||||
const openById = useCallback(async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${id}`, { cache: "no-store" });
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data) {
|
||||
setSelectedLog({
|
||||
id: data.id ?? id,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status ?? 0,
|
||||
model: data.model ?? null,
|
||||
provider: data.provider ?? null,
|
||||
account: data.account ?? null,
|
||||
duration: data.duration ?? 0,
|
||||
tokens: data.tokens ?? { in: 0, out: 0 },
|
||||
active: data.active,
|
||||
error: data.error ?? null,
|
||||
path: data.path ?? null,
|
||||
});
|
||||
setDetailData(data);
|
||||
const openById = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.set("id", id);
|
||||
router.replace(url.pathname + url.search);
|
||||
} catch {
|
||||
// ignore navigation errors
|
||||
}
|
||||
} catch {
|
||||
// ignore fetch errors
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/logs/${id}`, { cache: "no-store" });
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data) {
|
||||
setSelectedLog({
|
||||
id: data.id ?? id,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status ?? 0,
|
||||
model: data.model ?? null,
|
||||
provider: data.provider ?? null,
|
||||
account: data.account ?? null,
|
||||
duration: data.duration ?? 0,
|
||||
tokens: data.tokens ?? { in: 0, out: 0 },
|
||||
active: data.active,
|
||||
error: data.error ?? null,
|
||||
path: data.path ?? null,
|
||||
});
|
||||
setDetailData(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore fetch errors
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSelectedId || initialOpenedRef.current) return;
|
||||
@@ -576,6 +555,51 @@ export default function RequestTimeline({
|
||||
>
|
||||
{t("reset")}
|
||||
</button>
|
||||
{/* Conversation lane-reuse window: how long a lane stays reserved
|
||||
for its conversation before falling back to normal packing. */}
|
||||
<label
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] text-text-muted bg-bg-subtle rounded-md border border-border"
|
||||
title="Requests sharing a conversation id stay on the same timeline row as long as the gap between them is under this many minutes."
|
||||
>
|
||||
<span>Lane reuse</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={conversationLaneReuseMinutes}
|
||||
onChange={(e) => {
|
||||
const next = Math.max(1, Number(e.target.value) || 1);
|
||||
setConversationLaneReuseMinutes(next);
|
||||
try {
|
||||
localStorage.setItem(CONVERSATION_LANE_REUSE_STORAGE_KEY, String(next));
|
||||
} catch {}
|
||||
}}
|
||||
className="w-10 bg-transparent text-center font-mono focus:outline-none"
|
||||
/>
|
||||
<span>min</span>
|
||||
</label>
|
||||
{/* How often the timeline re-polls /api/usage/call-logs for new rows. */}
|
||||
<label
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] text-text-muted bg-bg-subtle rounded-md border border-border"
|
||||
title="How often the timeline re-fetches the request list from the server."
|
||||
>
|
||||
<span>Auto-refresh</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={listPollSeconds}
|
||||
onChange={(e) => {
|
||||
const next = Math.max(1, Number(e.target.value) || 1);
|
||||
setListPollSeconds(next);
|
||||
try {
|
||||
localStorage.setItem(TIMELINE_LIST_POLL_STORAGE_KEY, String(next));
|
||||
} catch {}
|
||||
}}
|
||||
className="w-10 bg-transparent text-center font-mono focus:outline-none"
|
||||
/>
|
||||
<span>s</span>
|
||||
</label>
|
||||
{/* Zoom */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border overflow-hidden">
|
||||
<button
|
||||
@@ -668,6 +692,47 @@ export default function RequestTimeline({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Conversation connectors — one arrow per consecutive same-conversation
|
||||
bar pair sharing a lane. */}
|
||||
<svg
|
||||
className="absolute left-0 right-0 pointer-events-none text-primary/60"
|
||||
style={{
|
||||
top: AXIS_HEIGHT,
|
||||
height: (maxLane + 1) * LANE_HEIGHT,
|
||||
width: "100%",
|
||||
zIndex: 1,
|
||||
}}
|
||||
viewBox={`0 0 100 ${(maxLane + 1) * LANE_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="conversation-connector-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connectorElements.map(({ id, x1, x2, y }) => (
|
||||
<line
|
||||
key={id}
|
||||
x1={x1}
|
||||
y1={y}
|
||||
x2={x2}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
markerEnd="url(#conversation-connector-arrow)"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* NOW line — full height of the canvas, outside content div */}
|
||||
@@ -828,8 +893,8 @@ export default function RequestTimeline({
|
||||
log={selectedLog as any}
|
||||
detail={detailData}
|
||||
loading={detailLoading}
|
||||
debugEnabled={false}
|
||||
emailsVisible={false}
|
||||
debugEnabled={selectedLog?.active ? true : detailLoggingEnabled}
|
||||
emailsVisible={emailsVisible}
|
||||
onClose={closeDetail}
|
||||
onCopy={copyToClipboard}
|
||||
onPrevious={undefined}
|
||||
|
||||
169
src/shared/components/RequestTimeline.utils.ts
Normal file
169
src/shared/components/RequestTimeline.utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { getHttpStatusStyle } from "@/shared/constants/colors";
|
||||
|
||||
export interface TimelineLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
status: number;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
account: string | null;
|
||||
duration: number;
|
||||
tokens: { in: number; out: number };
|
||||
active?: boolean;
|
||||
completed?: boolean;
|
||||
error?: string | null;
|
||||
path?: string | null;
|
||||
/** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */
|
||||
sessionTag?: string | null;
|
||||
}
|
||||
|
||||
export interface Lane {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type ViewMode = "follow" | "live" | "pan";
|
||||
|
||||
export const VISIBLE_WINDOW_MS = 5 * 60 * 1000;
|
||||
export const BAR_HEIGHT = 28;
|
||||
export const LANE_GAP = 4;
|
||||
export const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP;
|
||||
export const HEADER_HEIGHT = 48;
|
||||
export const AXIS_HEIGHT = 32;
|
||||
export const MIN_BAR_WIDTH = 3;
|
||||
export const DEFAULT_LIST_POLL_SECONDS = 2;
|
||||
export const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds";
|
||||
export const FOLLOW_LINE_X = 0.75;
|
||||
export const LIVE_LINE_FRACTION = 0.9;
|
||||
|
||||
export function computeBarRange(
|
||||
log: TimelineLog,
|
||||
nowMs: number
|
||||
): { startMs: number; endMs: number } {
|
||||
const ts = new Date(log.timestamp).getTime();
|
||||
if (log.active) return { startMs: ts, endMs: nowMs };
|
||||
if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) };
|
||||
return { startMs: ts - (log.duration || 0), endMs: ts };
|
||||
}
|
||||
|
||||
export const MODE_META: Record<ViewMode, { labelKey: string; descriptionKey: string }> = {
|
||||
follow: {
|
||||
labelKey: "follow",
|
||||
descriptionKey: "followDescription",
|
||||
},
|
||||
live: {
|
||||
labelKey: "now",
|
||||
descriptionKey: "nowDescription",
|
||||
},
|
||||
pan: {
|
||||
labelKey: "pan",
|
||||
descriptionKey: "panDescription",
|
||||
},
|
||||
};
|
||||
|
||||
export function formatTimeAxis(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const h = d.getHours().toString().padStart(2, "0");
|
||||
const m = d.getMinutes().toString().padStart(2, "0");
|
||||
const s = d.getSeconds().toString().padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
export function getStatusColor(status: number, active: boolean | undefined): string {
|
||||
if (active) return "#6366F1";
|
||||
return getHttpStatusStyle(status).bg;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS = 2 * 60 * 1000;
|
||||
|
||||
// Exported so other components (e.g. the "Full Conversation" transcript panel
|
||||
// in RequestLoggerDetail.tsx) can decide "is this conversation still in
|
||||
// progress" using the SAME setting as the timeline's lane-reuse window,
|
||||
// rather than a separate, potentially-inconsistent one.
|
||||
export const CONVERSATION_LANE_REUSE_STORAGE_KEY = "timelineConversationLaneReuseMinutes";
|
||||
|
||||
/**
|
||||
* Assigns each item a lane (row) index. Items sharing a `sessionTag`
|
||||
* (conversation id) are forced onto the same lane as long as the gap since
|
||||
* that lane's last item is within `reuseWindowMs` — after that, the lane is
|
||||
* free again and falls back to the normal greedy overlap-avoidance packing
|
||||
* below (unrelated to any conversation).
|
||||
*/
|
||||
export function allocateLanes(
|
||||
items: TimelineLog[],
|
||||
nowMs: number,
|
||||
reuseWindowMs: number = DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS
|
||||
): Map<string, number> {
|
||||
const lanes: Lane[] = [];
|
||||
const laneConversation: (string | null)[] = [];
|
||||
const laneMap = new Map<string, number>();
|
||||
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aStart = new Date(a.timestamp).getTime();
|
||||
const bStart = new Date(b.timestamp).getTime();
|
||||
return aStart - bStart;
|
||||
});
|
||||
|
||||
for (const item of sorted) {
|
||||
const { startMs, endMs } = computeBarRange(item, nowMs);
|
||||
const conversationId = item.sessionTag || null;
|
||||
|
||||
let placed = false;
|
||||
|
||||
if (conversationId) {
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (laneConversation[i] === conversationId && startMs - lanes[i].endMs <= reuseWindowMs) {
|
||||
lanes[i] = { startMs, endMs };
|
||||
laneMap.set(item.id, i);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (lanes[i].endMs < startMs) {
|
||||
lanes[i] = { startMs, endMs };
|
||||
laneConversation[i] = conversationId;
|
||||
laneMap.set(item.id, i);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
laneMap.set(item.id, lanes.length);
|
||||
laneConversation.push(conversationId);
|
||||
lanes.push({ startMs, endMs });
|
||||
}
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
export function truncateModel(model: string | null): string {
|
||||
if (!model) return "";
|
||||
const parts = model.split("/");
|
||||
const short = parts[parts.length - 1];
|
||||
return short.length > 16 ? short.slice(0, 15) + "…" : short;
|
||||
}
|
||||
|
||||
export function formatDateLabel(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${months[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
@@ -410,6 +410,13 @@ const LOGS_GROUP: SidebarItemGroup = {
|
||||
subtitleKey: "logsTimelineSubtitle",
|
||||
icon: "view_timeline",
|
||||
},
|
||||
{
|
||||
id: "conversations",
|
||||
href: "/dashboard/conversations",
|
||||
i18nKey: "conversations",
|
||||
subtitleKey: "conversationsSubtitle",
|
||||
icon: "forum",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"logs-proxy",
|
||||
"logs-console",
|
||||
"logs-timeline",
|
||||
"conversations",
|
||||
"logs-activity",
|
||||
"health",
|
||||
"runtime",
|
||||
|
||||
Reference in New Issue
Block a user