mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 03:12:36 +03:00
* 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>
909 lines
33 KiB
TypeScript
909 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
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";
|
|
|
|
export type { TimelineLog } from "@/shared/components/RequestTimeline.utils";
|
|
export { allocateLanes } from "@/shared/components/RequestTimeline.utils";
|
|
|
|
export default function RequestTimeline({
|
|
initialSelectedId,
|
|
}: {
|
|
initialSelectedId?: string | null;
|
|
} = {}) {
|
|
const router = useRouter();
|
|
const t = useTranslations("requestTimeline");
|
|
const [logs, setLogs] = useState<TimelineLog[]>([]);
|
|
const [mode, setMode] = useState<ViewMode>("follow");
|
|
const [zoom, setZoom] = useState(1);
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
|
const [nowMs, setNowMs] = useState(0);
|
|
const [canvasWidth, setCanvasWidth] = useState(1200);
|
|
const [panOffsetMs, setPanOffsetMs] = useState(0);
|
|
const [panFrozenMs, setPanFrozenMs] = useState(0);
|
|
const [liveBaseMs, setLiveBaseMs] = useState(0);
|
|
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
|
|
// open/close so a stale `initialSelectedId` (App Router's router.replace()
|
|
// commits the URL after the re-render it triggers, so the prop can briefly
|
|
// still reflect the just-closed id on the very next render) can never
|
|
// 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")
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
.then((data) => {
|
|
if (!cancelled) setLogs(data);
|
|
})
|
|
.catch(() => {});
|
|
const id = setInterval(() => {
|
|
// #8354: skip the poll while the tab is backgrounded (Page Visibility
|
|
// API), matching the pause-when-hidden pattern in RequestLoggerV2 and
|
|
// UsageStats so a background tab doesn't keep hammering the API.
|
|
if (document.visibilityState !== "visible") return;
|
|
fetch("/api/usage/call-logs?limit=200")
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
.then((data) => setLogs(data))
|
|
.catch(() => {});
|
|
}, listPollSeconds * 1000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(id);
|
|
};
|
|
}, [listPollSeconds]);
|
|
|
|
useEffect(() => {
|
|
if (!canvasRef.current) return undefined;
|
|
const observer = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
setCanvasWidth(entry.contentRect.width);
|
|
}
|
|
});
|
|
observer.observe(canvasRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let lastUpdate = 0;
|
|
const THROTTLE_MS = mode === "follow" ? 0 : 50;
|
|
const onFrame = (frame: number) => {
|
|
if (frame - lastUpdate >= THROTTLE_MS) {
|
|
lastUpdate = frame;
|
|
setNowMs(Date.now());
|
|
}
|
|
animRef.current = requestAnimationFrame(onFrame);
|
|
};
|
|
animRef.current = requestAnimationFrame(onFrame);
|
|
return () => cancelAnimationFrame(animRef.current);
|
|
}, [mode]);
|
|
|
|
const windowMs = VISIBLE_WINDOW_MS / zoom;
|
|
|
|
const timeRange = useMemo(() => {
|
|
if (mode === "follow") {
|
|
const end = nowMs + windowMs * (1 - FOLLOW_LINE_X);
|
|
const start = end - windowMs;
|
|
return { start, end };
|
|
}
|
|
if (mode === "live") {
|
|
const base = liveBaseMs || nowMs;
|
|
const elapsed = nowMs - base;
|
|
const snapWindow = windowMs * LIVE_LINE_FRACTION;
|
|
const slot = elapsed % snapWindow;
|
|
const start = nowMs - slot - windowMs * (1 - LIVE_LINE_FRACTION);
|
|
return { start, end: start + windowMs };
|
|
}
|
|
const base = panFrozenMs || nowMs;
|
|
const start = base - windowMs * 0.5 + panOffsetMs;
|
|
return { start, end: start + windowMs };
|
|
}, [mode, nowMs, windowMs, liveBaseMs, panFrozenMs, panOffsetMs]);
|
|
|
|
const { start: timeStart, end: timeEnd } = timeRange;
|
|
|
|
const nowLineX = useMemo(() => {
|
|
if (mode === "follow") {
|
|
return FOLLOW_LINE_X * 100;
|
|
}
|
|
const totalMs = timeEnd - timeStart;
|
|
if (totalMs <= 0) return 50;
|
|
return Math.max(0, Math.min(100, ((nowMs - timeStart) / totalMs) * 100));
|
|
}, [mode, nowMs, timeStart, timeEnd]);
|
|
|
|
const visibleLogs = useMemo(() => {
|
|
return logs.filter((log) => {
|
|
const { startMs, endMs } = computeBarRange(log, nowMs);
|
|
return endMs >= timeStart && startMs <= timeEnd;
|
|
});
|
|
}, [logs, timeStart, timeEnd, 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(() => {
|
|
const totalMs = timeEnd - timeStart;
|
|
return visibleLogs.map((log) => {
|
|
const { startMs, endMs } = computeBarRange(log, nowMs);
|
|
|
|
const leftPct = ((startMs - timeStart) / totalMs) * 100;
|
|
const rightPct = ((endMs - timeStart) / totalMs) * 100;
|
|
const widthPct = Math.max(rightPct - leftPct, (MIN_BAR_WIDTH / canvasWidth) * 100);
|
|
|
|
const lane = laneMap.get(log.id) ?? 0;
|
|
const topPx = lane * LANE_HEIGHT;
|
|
const color = getStatusColor(log.status, log.active);
|
|
const opacity = log.active ? 0.9 : 0.7;
|
|
|
|
return { log, leftPct, widthPct, topPx, color, opacity };
|
|
});
|
|
}, [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 [];
|
|
|
|
const MS_MIN = 60 * 1000;
|
|
const MS_10MIN = 10 * MS_MIN;
|
|
const MS_HOUR = 60 * MS_MIN;
|
|
const MS_DAY = 24 * MS_HOUR;
|
|
|
|
const startOfHour = (ms: number) => {
|
|
const d = new Date(ms);
|
|
d.setMinutes(0, 0, 0);
|
|
return d.getTime();
|
|
};
|
|
|
|
const startOfDay = (ms: number) => {
|
|
const d = new Date(ms);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d.getTime();
|
|
};
|
|
|
|
const ticks: { pct: number; label: string; kind: "day" | "hour" | "minor" }[] = [];
|
|
|
|
const addTick = (ms: number, label: string, kind: "day" | "hour" | "minor") => {
|
|
const pct = ((ms - timeStart) / totalMs) * 100;
|
|
if (pct >= 0 && pct <= 100) {
|
|
ticks.push({ pct, label, kind });
|
|
}
|
|
};
|
|
|
|
if (totalMs <= MS_HOUR * 4) {
|
|
const interval = totalMs <= MS_10MIN ? MS_MIN : MS_10MIN;
|
|
const first = Math.ceil(timeStart / interval) * interval;
|
|
for (let ms = first; ms <= timeEnd; ms += interval) {
|
|
const isDay = startOfDay(ms) === ms;
|
|
const isHour = startOfHour(ms) === ms;
|
|
addTick(
|
|
ms,
|
|
isDay ? formatDateLabel(ms) : formatTimeAxis(ms),
|
|
isDay ? "day" : isHour ? "hour" : "minor"
|
|
);
|
|
}
|
|
} else if (totalMs <= MS_DAY) {
|
|
const interval = totalMs <= MS_HOUR * 6 ? MS_10MIN : MS_HOUR;
|
|
const first = Math.ceil(timeStart / interval) * interval;
|
|
for (let ms = first; ms <= timeEnd; ms += interval) {
|
|
const isDay = startOfDay(ms) === ms;
|
|
addTick(ms, isDay ? formatDateLabel(ms) : formatTimeAxis(ms), isDay ? "day" : "hour");
|
|
}
|
|
} else {
|
|
const firstDay = startOfDay(timeStart);
|
|
const start = firstDay < timeStart ? firstDay + MS_DAY : firstDay;
|
|
for (let ms = start; ms <= timeEnd; ms += MS_DAY) {
|
|
addTick(ms, formatDateLabel(ms), "day");
|
|
}
|
|
}
|
|
|
|
return ticks;
|
|
}, [timeStart, timeEnd]);
|
|
|
|
const contentHeight = Math.max((maxLane + 1) * LANE_HEIGHT + 20, 200);
|
|
|
|
const handleBarClick = useCallback(
|
|
(log: TimelineLog) => {
|
|
initialOpenedRef.current = true;
|
|
setSelectedLog(log);
|
|
setDetailData(null);
|
|
setDetailLoading(true);
|
|
try {
|
|
const url = new URL(globalThis.location.href);
|
|
url.searchParams.set("id", log.id);
|
|
router.replace(url.pathname + url.search);
|
|
} catch {
|
|
// ignore navigation errors
|
|
}
|
|
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data) => {
|
|
if (data) setDetailData(data);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setDetailLoading(false));
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const closeDetail = useCallback(() => {
|
|
initialOpenedRef.current = true;
|
|
setSelectedLog(null);
|
|
setDetailData(null);
|
|
try {
|
|
const url = new URL(globalThis.location.href);
|
|
url.searchParams.delete("id");
|
|
router.replace(url.pathname + url.search);
|
|
} catch {
|
|
// ignore navigation errors
|
|
}
|
|
}, [router]);
|
|
|
|
// 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) => {
|
|
try {
|
|
const url = new URL(globalThis.location.href);
|
|
url.searchParams.set("id", id);
|
|
router.replace(url.pathname + url.search);
|
|
} catch {
|
|
// ignore navigation errors
|
|
}
|
|
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;
|
|
initialOpenedRef.current = true;
|
|
openById(initialSelectedId)
|
|
.then((r) => r)
|
|
.catch(() => {});
|
|
}, [initialSelectedId, openById]);
|
|
|
|
const handleBarHover = (log: TimelineLog, e: React.MouseEvent) => {
|
|
setHoveredId(log.id);
|
|
setTooltipPos({ x: e.clientX, y: e.clientY });
|
|
};
|
|
|
|
const handleBarLeave = () => {
|
|
setHoveredId(null);
|
|
setTooltipPos(null);
|
|
};
|
|
|
|
const handleReset = useCallback(() => {
|
|
setPanOffsetMs(0);
|
|
setLiveBaseMs(nowMs);
|
|
}, [nowMs]);
|
|
|
|
const handleModeChange = useCallback(
|
|
(newMode: ViewMode) => {
|
|
setMode(newMode);
|
|
setPanOffsetMs(0);
|
|
if (newMode === "pan") setPanFrozenMs(nowMs);
|
|
setLiveBaseMs(nowMs);
|
|
},
|
|
[nowMs]
|
|
);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (e.button !== 0) return;
|
|
if (mode !== "pan") {
|
|
setMode("pan");
|
|
setPanFrozenMs(nowMs);
|
|
setLiveBaseMs(nowMs);
|
|
}
|
|
setIsDragging(true);
|
|
setDragStartX(e.clientX);
|
|
setDragStartOffset(mode === "pan" ? panOffsetMs : 0);
|
|
};
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!isDragging) return;
|
|
const dx = e.clientX - dragStartX;
|
|
const msPerPx = windowMs / canvasWidth;
|
|
setPanOffsetMs(dragStartOffset - dx * msPerPx);
|
|
},
|
|
[isDragging, dragStartX, dragStartOffset, windowMs, canvasWidth]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
// --- Touch handlers (mobile panning + pinch-to-zoom) ---
|
|
// Track touch start position to distinguish taps from drags.
|
|
// A tap (< 10px movement) passes through to onClick for bar selection;
|
|
// a drag triggers pan mode.
|
|
const touchStartRef = useRef<{
|
|
x: number;
|
|
y: number;
|
|
dist: number;
|
|
offset: number;
|
|
moved: boolean;
|
|
} | null>(null);
|
|
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
if (e.touches.length === 1) {
|
|
const touch = e.touches[0];
|
|
touchStartRef.current = {
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
dist: 0,
|
|
offset: mode === "pan" ? panOffsetMs : 0,
|
|
moved: false,
|
|
};
|
|
} else if (e.touches.length === 2) {
|
|
// Pinch-to-zoom: record initial distance between two fingers
|
|
const [a, b] = [e.touches[0], e.touches[1]];
|
|
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
|
|
touchStartRef.current = { x: 0, y: 0, dist, offset: zoom, moved: false };
|
|
setIsDragging(false);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (!touchStartRef.current) return;
|
|
if (e.touches.length === 1) {
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - touchStartRef.current.x;
|
|
const dy = touch.clientY - touchStartRef.current.y;
|
|
// Only start panning after a 10px threshold to avoid stealing taps
|
|
if (!touchStartRef.current.moved && Math.hypot(dx, dy) < 10) return;
|
|
if (!touchStartRef.current.moved) {
|
|
touchStartRef.current.moved = true;
|
|
if (mode !== "pan") {
|
|
setMode("pan");
|
|
setPanFrozenMs(nowMs);
|
|
setLiveBaseMs(nowMs);
|
|
}
|
|
setIsDragging(true);
|
|
}
|
|
const msPerPx = windowMs / canvasWidth;
|
|
setPanOffsetMs(touchStartRef.current.offset - dx * msPerPx);
|
|
} else if (e.touches.length === 2 && touchStartRef.current.dist > 0) {
|
|
// Pinch-to-zoom
|
|
e.preventDefault();
|
|
const [a, b] = [e.touches[0], e.touches[1]];
|
|
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
|
|
const scale = dist / touchStartRef.current.dist;
|
|
setZoom(Math.max(0.001, Math.min(8, touchStartRef.current.offset * scale)));
|
|
}
|
|
},
|
|
[mode, nowMs, windowMs, canvasWidth]
|
|
);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
setIsDragging(false);
|
|
touchStartRef.current = null;
|
|
}, []);
|
|
|
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
if (e.ctrlKey || e.metaKey) return;
|
|
e.preventDefault();
|
|
const factor = e.deltaY < 0 ? 1.25 : 0.8;
|
|
setZoom((z) => Math.max(0.001, Math.min(8, z * factor)));
|
|
}, []);
|
|
|
|
const hoveredLog = hoveredId ? logs.find((l) => l.id === hoveredId) : null;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0 select-none">
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0"
|
|
style={{ height: HEADER_HEIGHT }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-sm font-semibold text-text-main">{t("title")}</h2>
|
|
<span className="text-[10px] text-text-muted font-mono">
|
|
{visibleLogs.length} visible / {logs.length} total
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Mode selector */}
|
|
<div className="flex items-center rounded-lg border border-border overflow-hidden">
|
|
{(["follow", "live", "pan"] as ViewMode[]).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => handleModeChange(m)}
|
|
title={t(`modes.${MODE_META[m].descriptionKey}`)}
|
|
className={`px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
|
mode === m
|
|
? "bg-primary text-white"
|
|
: "bg-bg-subtle text-text-muted hover:text-text-main"
|
|
}`}
|
|
>
|
|
{t(`modes.${MODE_META[m].labelKey}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={handleReset}
|
|
title={`Jump to current time, NOW line resets to position ${Math.round(nowLineX)}%`}
|
|
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle rounded-md border border-border transition-colors"
|
|
>
|
|
{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
|
|
onClick={() => setZoom((z) => Math.max(0.001, z * 0.5))}
|
|
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
|
|
>
|
|
-
|
|
</button>
|
|
<span className="px-1 text-[10px] text-text-muted font-mono min-w-[36px] text-center">
|
|
{zoom >= 1 ? `${zoom}x` : `${zoom * 100}%`}
|
|
</span>
|
|
<button
|
|
onClick={() => setZoom((z) => Math.min(8, z * 2))}
|
|
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Canvas */}
|
|
<div
|
|
ref={canvasRef}
|
|
className={`flex-1 min-h-0 overflow-hidden relative bg-surface ${
|
|
isDragging ? "cursor-grabbing" : mode === "pan" ? "cursor-grab" : ""
|
|
}`}
|
|
style={{ touchAction: "none" }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchEnd}
|
|
>
|
|
{/* Scrollable content area */}
|
|
<div className="absolute left-0 right-0" style={{ height: contentHeight }}>
|
|
{/* Time axis */}
|
|
<div
|
|
className="absolute left-0 right-0 border-b border-border/50 bg-surface/90 backdrop-blur-sm z-5"
|
|
style={{ top: 0, height: AXIS_HEIGHT }}
|
|
>
|
|
{axisTicks.map((axisTick, i) => (
|
|
<div key={i} className="absolute" style={{ left: `${axisTick.pct}%`, top: 0 }}>
|
|
<div className="w-px h-3 bg-border -translate-x-1/2" />
|
|
<span className="block text-[9px] text-text-muted font-mono mt-0.5 whitespace-nowrap -translate-x-1/2 text-center">
|
|
{axisTick.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Horizontal grid lines */}
|
|
{Array.from({ length: maxLane + 1 }).map((_, i) => (
|
|
<div
|
|
key={`grid-${i}`}
|
|
className="absolute left-0 right-0 border-b border-border/20"
|
|
style={{ top: AXIS_HEIGHT + i * LANE_HEIGHT + BAR_HEIGHT }}
|
|
/>
|
|
))}
|
|
|
|
{/* Request bars */}
|
|
{barElements.map(({ log, leftPct, widthPct, topPx, color, opacity }) => (
|
|
<div
|
|
key={log.id}
|
|
data-testid={`timeline-bar-${log.id}`}
|
|
className="absolute rounded-sm cursor-pointer transition-opacity duration-100"
|
|
style={{
|
|
left: `${leftPct}%`,
|
|
width: `${widthPct}%`,
|
|
top: AXIS_HEIGHT + topPx,
|
|
height: BAR_HEIGHT,
|
|
backgroundColor: color,
|
|
opacity,
|
|
minWidth: MIN_BAR_WIDTH,
|
|
zIndex: 2,
|
|
}}
|
|
onClick={() => handleBarClick(log)}
|
|
onMouseEnter={(e) => handleBarHover(log, e)}
|
|
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
|
|
onMouseLeave={handleBarLeave}
|
|
>
|
|
<div className="w-full h-full flex items-center px-1.5 overflow-hidden">
|
|
<span className="text-[11px] font-mono text-white/90 truncate whitespace-nowrap">
|
|
{truncateModel(log.model)}
|
|
</span>
|
|
</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 */}
|
|
<div
|
|
className="absolute z-10 pointer-events-none"
|
|
style={{
|
|
left: `${nowLineX}%`,
|
|
top: 0,
|
|
bottom: 0,
|
|
}}
|
|
>
|
|
<div className="w-px h-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)] -translate-x-1/2" />
|
|
<div className="absolute top-[2px] -translate-x-1/2 bg-red-500 text-white text-[8px] font-mono px-1.5 py-0.5 rounded-b-sm font-bold tracking-wider">
|
|
NOW
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vertical lines — full height of the canvas, same position as axis ticks */}
|
|
{axisTicks.map((axisTick) => (
|
|
<div
|
|
key={`vl-${axisTick.pct}`}
|
|
className="absolute top-0 bottom-0 pointer-events-none -translate-x-1/2"
|
|
style={{
|
|
left: `${axisTick.pct}%`,
|
|
width: axisTick.kind === "day" ? 2 : 1,
|
|
backgroundColor:
|
|
axisTick.kind === "day"
|
|
? "rgba(99,102,241,0.5)"
|
|
: axisTick.kind === "hour"
|
|
? "rgba(148,163,184,0.45)"
|
|
: "rgba(148,163,184,0.15)",
|
|
zIndex: 1,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tooltip */}
|
|
{hoveredLog && tooltipPos && (
|
|
<div
|
|
className="fixed z-50 pointer-events-none bg-surface border border-border rounded-lg shadow-elevated p-3 max-w-[280px]"
|
|
style={{
|
|
left: tooltipPos.x + 12,
|
|
top: tooltipPos.y - 8,
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<div
|
|
className="w-2.5 h-2.5 rounded-full"
|
|
style={{ backgroundColor: getStatusColor(hoveredLog.status, hoveredLog.active) }}
|
|
/>
|
|
<span className="text-[11px] font-semibold text-text-main">
|
|
{hoveredLog.model || t("unknownModel")}
|
|
</span>
|
|
{hoveredLog.active && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-400 font-medium">
|
|
{t("active")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="space-y-0.5 text-[10px] text-text-muted font-mono">
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("started")}</span>
|
|
<span>
|
|
{(() => {
|
|
const ts = new Date(hoveredLog.timestamp).getTime();
|
|
const startMs =
|
|
hoveredLog.active || hoveredLog.completed
|
|
? ts
|
|
: ts - (hoveredLog.duration || 0);
|
|
return new Date(startMs).toLocaleTimeString();
|
|
})()}
|
|
</span>
|
|
</div>
|
|
{!hoveredLog.active && (
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("ended")}</span>
|
|
<span>
|
|
{new Date(
|
|
hoveredLog.completed
|
|
? new Date(hoveredLog.timestamp).getTime() + (hoveredLog.duration || 0)
|
|
: new Date(hoveredLog.timestamp).getTime()
|
|
).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("duration")}</span>
|
|
<span>
|
|
{hoveredLog.active
|
|
? `~${Math.round((nowMs - computeBarRange(hoveredLog, nowMs).startMs) / 1000).toLocaleString()}s`
|
|
: `${hoveredLog.duration.toLocaleString()}ms`}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("status")}</span>
|
|
<span>{hoveredLog.status || t("pending")}</span>
|
|
</div>
|
|
{hoveredLog.provider && (
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("provider")}</span>
|
|
<span>{hoveredLog.provider}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between gap-4">
|
|
<span>{t("tokens")}</span>
|
|
<span>
|
|
{hoveredLog.tokens.in.toLocaleString()} / {hoveredLog.tokens.out.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{hoveredLog.error && (
|
|
<div className="mt-1 text-red-400 text-[9px] break-all">{hoveredLog.error}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="flex items-center gap-3 px-4 py-1.5 border-t border-border text-[9px] shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#059669" }} />
|
|
<span className="text-text-muted">2xx</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#D97706" }} />
|
|
<span className="text-text-muted">4xx</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#DC2626" }} />
|
|
<span className="text-text-muted">5xx</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6366F1" }} />
|
|
<span className="text-text-muted">{t("active")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6B7280" }} />
|
|
<span className="text-text-muted">{t("other")}</span>
|
|
</div>
|
|
<div className="ml-4 flex items-center gap-1.5 text-text-muted">
|
|
<div className="w-3 border-t border-dashed border-slate-400/40" />
|
|
<span>{t("tenMinutes")}</span>
|
|
<div className="w-3 border-t border-slate-400/60 ml-2" />
|
|
<span>{t("hour")}</span>
|
|
<div className="w-3 border-t-2 border-accent/40 ml-2" />
|
|
<span>{t("day")}</span>
|
|
</div>
|
|
<div
|
|
className="ml-auto text-text-muted italic"
|
|
title={t(`modes.${MODE_META[mode].descriptionKey}`)}
|
|
>
|
|
{t(`modes.${MODE_META[mode].descriptionKey}`)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedLog && (
|
|
<RequestLoggerDetail
|
|
log={selectedLog as any}
|
|
detail={detailData}
|
|
loading={detailLoading}
|
|
debugEnabled={selectedLog?.active ? true : detailLoggingEnabled}
|
|
emailsVisible={emailsVisible}
|
|
onClose={closeDetail}
|
|
onCopy={copyToClipboard}
|
|
onPrevious={undefined}
|
|
onNext={undefined}
|
|
relatedLogs={[]}
|
|
onSelectRelated={undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|