Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions tests/timeline-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { Call } from "../ui/src/api.js";
import { callLabel, filterTimelineCalls } from "../ui/src/timeline-filter.js";

const call = (overrides: Partial<Call>): Call => ({
id: overrides.id ?? "1",
method: overrides.method ?? "tools/call",
request: overrides.request ?? {},
isError: overrides.isError ?? false,
startTs: overrides.startTs ?? 1,
requestTokens: overrides.requestTokens ?? 0,
responseTokens: overrides.responseTokens ?? 0,
...overrides,
});

describe("filterTimelineCalls", () => {
const calls = [
call({ id: "1", toolName: "github_search", method: "tools/call" }),
call({ id: "2", method: "resources/read", isError: true }),
call({ id: "3", toolName: "filesystem_write", method: "tools/call", isError: true }),
];

it("matches tool or method names case-insensitively", () => {
expect(filterTimelineCalls(calls, "GITHUB", false).map((entry) => entry.id)).toEqual(["1"]);
expect(filterTimelineCalls(calls, "resources", false).map((entry) => entry.id)).toEqual(["2"]);
expect(filterTimelineCalls(calls, "tools/call", false).map((entry) => entry.id)).toEqual([
"1",
"3",
]);
expect(
filterTimelineCalls(
[call({ id: "4", toolName: "", method: "resources/list" })],
"resources",
false,
).map((entry) => entry.id),
).toEqual(["4"]);
});

it("can show only errored calls", () => {
expect(filterTimelineCalls(calls, "", true).map((entry) => entry.id)).toEqual(["2", "3"]);
});

it("combines search and errors-only filters", () => {
expect(filterTimelineCalls(calls, "filesystem", true).map((entry) => entry.id)).toEqual(["3"]);
});
});

describe("callLabel", () => {
it("falls back to method for empty tool names", () => {
expect(callLabel(call({ toolName: "", method: "tools/call" }))).toBe("tools/call");
});
});
8 changes: 5 additions & 3 deletions ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ export interface SessionDetail {
totalTokens: number;
}

const readJson = <T>(response: Response): Promise<T> => response.json() as Promise<T>;

export const fetchSessions = (): Promise<SessionInfo[]> =>
fetch("/api/sessions").then((r) => r.json());
fetch("/api/sessions").then(readJson<SessionInfo[]>);

export const fetchSession = (file: string): Promise<SessionDetail> =>
fetch(`/api/session?file=${encodeURIComponent(file)}`).then((r) => r.json());
fetch(`/api/session?file=${encodeURIComponent(file)}`).then(readJson<SessionDetail>);

export interface ReplayResponse {
original: unknown;
Expand All @@ -57,7 +59,7 @@ export const postReplay = (
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file, id, startTs }),
}).then((r) => r.json());
}).then(readJson<ReplayResponse>);

export function subscribeLive(onChange: (file: string) => void): () => void {
const source = new EventSource("/api/live");
Expand Down
37 changes: 34 additions & 3 deletions ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SessionInfo,
subscribeLive,
} from "./api.js";
import { callLabel, filterTimelineCalls } from "./timeline-filter.js";

const time = (ts: number) => new Date(ts).toLocaleTimeString();
const day = (ts: number) => new Date(ts).toLocaleDateString();
Expand All @@ -18,6 +19,8 @@ export function App() {
const [selected, setSelected] = useState<string | null>(null);
const [detail, setDetail] = useState<SessionDetail | null>(null);
const [call, setCall] = useState<Call | null>(null);
const [timelineQuery, setTimelineQuery] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);

useEffect(() => {
fetchSessions().then((list) => {
Expand All @@ -42,6 +45,10 @@ export function App() {
}, [selected]);

const errorCount = useMemo(() => detail?.calls.filter((c) => c.isError).length ?? 0, [detail]);
const filteredCalls = useMemo(
() => (detail ? filterTimelineCalls(detail.calls, timelineQuery, errorsOnly) : []),
[detail, errorsOnly, timelineQuery],
);

return (
<div class="layout">
Expand Down Expand Up @@ -115,6 +122,23 @@ export function App() {

<div class="section-gap" />
<h2>Timeline</h2>
<div class="timeline-controls">
<input
aria-label="Filter timeline calls"
type="search"
placeholder="filter by tool or method"
value={timelineQuery}
onInput={(event) => setTimelineQuery(event.currentTarget.value)}
/>
<label>
<input
type="checkbox"
checked={errorsOnly}
onChange={(event) => setErrorsOnly(event.currentTarget.checked)}
/>
errors only
</label>
</div>
<table>
<thead>
<tr>
Expand All @@ -125,21 +149,28 @@ export function App() {
</tr>
</thead>
<tbody>
{detail.calls.map((c) => (
{filteredCalls.map((c) => (
<tr
key={`${c.startTs}-${String(c.id)}`}
class={`call ${call === c ? "selected" : ""}`}
onClick={() => setCall(c)}
>
<td>
<span class={`status ${c.isError ? "err" : c.response ? "" : "pending"}`} />
{c.toolName ?? c.method}
{callLabel(c)}
</td>
<td class="num">{time(c.startTs)}</td>
<td class="num">{c.latencyMs === undefined ? "…" : `${c.latencyMs}ms`}</td>
<td class="num">{(c.requestTokens + c.responseTokens).toLocaleString()}</td>
</tr>
))}
{!filteredCalls.length && (
<tr>
<td class="empty-row" colSpan={4}>
no matching calls
</td>
</tr>
)}
</tbody>
</table>
</>
Expand Down Expand Up @@ -182,7 +213,7 @@ function CallDetail({ call, file, onClose }: { call: Call; file: string; onClose
<button type="button" class="close" onClick={onClose}>
×
</button>
<h3>{call.toolName ?? call.method}</h3>
<h3>{callLabel(call)}</h3>
<div class="meta">
{call.isError ? "❌ error" : call.response ? "✓ ok" : "⏳ no response captured"}
{call.latencyMs !== undefined && ` · ${call.latencyMs}ms`}
Expand Down
40 changes: 40 additions & 0 deletions ui/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,46 @@ tr.selected td {
background: var(--warn);
}

.timeline-controls {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 8px;
}

.timeline-controls input[type="search"] {
min-width: 240px;
max-width: 360px;
flex: 1;
background: var(--bg-panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
font: inherit;
}

.timeline-controls input[type="search"]:focus {
outline: 1px solid var(--accent-dim);
border-color: var(--accent-dim);
}

.timeline-controls label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-dim);
font-size: 12px;
white-space: nowrap;
}

.empty-row {
color: var(--text-dim);
font-family: var(--sans);
text-align: center;
padding: 18px 10px;
}

.detail {
position: fixed;
right: 0;
Expand Down
24 changes: 24 additions & 0 deletions ui/src/timeline-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Call } from "./api.js";

const hasText = (value: string | undefined): value is string => Boolean(value?.trim());

export function callLabel(call: Call): string {
return hasText(call.toolName) ? call.toolName : call.method;
}

function callSearchText(call: Call): string {
return [call.toolName, call.method].filter(hasText).join(" ").toLowerCase();
}

export function filterTimelineCalls(calls: Call[], query: string, errorsOnly: boolean): Call[] {
const normalizedQuery = query.trim().toLowerCase();
return calls.filter((call) => {
if (errorsOnly && !call.isError) {
return false;
}
if (!normalizedQuery) {
return true;
}
return callSearchText(call).includes(normalizedQuery);
});
}
Loading