lopata 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- package/runtime/worker-registry.ts +52 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from "preact/hooks";
|
|
2
|
+
import { useMutation } from "../rpc/hooks";
|
|
3
|
+
import type { TraceSummary, SpanData, SpanEventData, TraceEvent, TraceErrorSummary } from "../rpc/types";
|
|
4
|
+
import { rpc } from "../rpc/client";
|
|
5
|
+
import { TraceWaterfall, TraceStatusBadge, EventLevelBadge, formatDuration, formatTimestamp } from "./trace-waterfall";
|
|
6
|
+
|
|
7
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
type WsStatus = "connecting" | "live" | "error" | "disconnected";
|
|
10
|
+
|
|
11
|
+
interface AttributeFilter {
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
type: "include" | "exclude";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ViewTab = "traces" | "spans" | "logs";
|
|
18
|
+
|
|
19
|
+
// ─── Event bus for raw WS events (used by drawer for live updates) ───
|
|
20
|
+
|
|
21
|
+
type EventListener = (events: TraceEvent[]) => void;
|
|
22
|
+
const eventListeners = new Set<EventListener>();
|
|
23
|
+
function onTraceEvents(fn: EventListener): () => void {
|
|
24
|
+
eventListeners.add(fn);
|
|
25
|
+
return () => { eventListeners.delete(fn); };
|
|
26
|
+
}
|
|
27
|
+
function emitTraceEvents(events: TraceEvent[]): void {
|
|
28
|
+
for (const fn of eventListeners) {
|
|
29
|
+
try { fn(events); } catch {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── WebSocket hook ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
interface TraceFilter {
|
|
36
|
+
path?: string;
|
|
37
|
+
status?: string;
|
|
38
|
+
attributeFilters?: AttributeFilter[];
|
|
39
|
+
sinceMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TraceStreamState {
|
|
43
|
+
traces: Map<string, TraceSummary>;
|
|
44
|
+
filter: TraceFilter;
|
|
45
|
+
setFilter: (f: TraceFilter) => void;
|
|
46
|
+
wsStatus: WsStatus;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function useTraceStream(): TraceStreamState {
|
|
50
|
+
const [traces, setTraces] = useState<Map<string, TraceSummary>>(new Map());
|
|
51
|
+
const [wsStatus, setWsStatus] = useState<WsStatus>("connecting");
|
|
52
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
53
|
+
const filterRef = useRef<TraceFilter>({ sinceMs: 15 * 60 * 1000 });
|
|
54
|
+
const closedRef = useRef(false);
|
|
55
|
+
|
|
56
|
+
const connect = useCallback(() => {
|
|
57
|
+
if (closedRef.current) return;
|
|
58
|
+
setWsStatus("connecting");
|
|
59
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
60
|
+
const ws = new WebSocket(`${protocol}//${location.host}/__dashboard/api/traces/ws`);
|
|
61
|
+
wsRef.current = ws;
|
|
62
|
+
|
|
63
|
+
ws.onmessage = (ev) => {
|
|
64
|
+
const msg = JSON.parse(ev.data);
|
|
65
|
+
if (msg.type === "initial") {
|
|
66
|
+
const map = new Map<string, TraceSummary>();
|
|
67
|
+
for (const t of msg.traces as TraceSummary[]) {
|
|
68
|
+
map.set(t.traceId, t);
|
|
69
|
+
}
|
|
70
|
+
setTraces(map);
|
|
71
|
+
} else if (msg.type === "batch") {
|
|
72
|
+
const events = msg.events as TraceEvent[];
|
|
73
|
+
emitTraceEvents(events);
|
|
74
|
+
setTraces(prev => {
|
|
75
|
+
const next = new Map(prev);
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
if (event.type === "span.start" && event.span.parentSpanId === null) {
|
|
78
|
+
const s = event.span;
|
|
79
|
+
next.set(s.traceId, {
|
|
80
|
+
traceId: s.traceId,
|
|
81
|
+
rootSpanName: s.name,
|
|
82
|
+
workerName: s.workerName,
|
|
83
|
+
status: s.status,
|
|
84
|
+
statusMessage: s.statusMessage,
|
|
85
|
+
startTime: s.startTime,
|
|
86
|
+
durationMs: s.durationMs,
|
|
87
|
+
spanCount: 1,
|
|
88
|
+
errorCount: 0,
|
|
89
|
+
});
|
|
90
|
+
} else if (event.type === "span.end" && event.span.parentSpanId === null) {
|
|
91
|
+
const s = event.span;
|
|
92
|
+
const existing = next.get(s.traceId);
|
|
93
|
+
if (existing) {
|
|
94
|
+
next.set(s.traceId, { ...existing, status: s.status, statusMessage: s.statusMessage, durationMs: s.durationMs });
|
|
95
|
+
}
|
|
96
|
+
} else if (event.type === "span.start" && event.span.parentSpanId !== null) {
|
|
97
|
+
const s = event.span;
|
|
98
|
+
const existing = next.get(s.traceId);
|
|
99
|
+
if (existing) {
|
|
100
|
+
next.set(s.traceId, {
|
|
101
|
+
...existing,
|
|
102
|
+
spanCount: existing.spanCount + 1,
|
|
103
|
+
errorCount: existing.errorCount + (s.status === "error" ? 1 : 0),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} else if (event.type === "span.end" && event.span.parentSpanId !== null) {
|
|
107
|
+
const s = event.span;
|
|
108
|
+
const existing = next.get(s.traceId);
|
|
109
|
+
if (existing && s.status === "error") {
|
|
110
|
+
next.set(s.traceId, { ...existing, errorCount: existing.errorCount + 1 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
ws.onerror = () => {
|
|
120
|
+
setWsStatus("error");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
ws.onclose = () => {
|
|
124
|
+
wsRef.current = null;
|
|
125
|
+
if (!closedRef.current) {
|
|
126
|
+
setWsStatus("disconnected");
|
|
127
|
+
setTimeout(connect, 2000);
|
|
128
|
+
} else {
|
|
129
|
+
setWsStatus("disconnected");
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
ws.onopen = () => {
|
|
134
|
+
setWsStatus("live");
|
|
135
|
+
const f = filterRef.current;
|
|
136
|
+
// Always send filter on connect to sync time range with server
|
|
137
|
+
ws.send(JSON.stringify({ type: "filter", ...f }));
|
|
138
|
+
};
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
closedRef.current = false;
|
|
143
|
+
connect();
|
|
144
|
+
return () => {
|
|
145
|
+
closedRef.current = true;
|
|
146
|
+
wsRef.current?.close();
|
|
147
|
+
};
|
|
148
|
+
}, [connect]);
|
|
149
|
+
|
|
150
|
+
const setFilter = useCallback((f: TraceFilter) => {
|
|
151
|
+
filterRef.current = f;
|
|
152
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
153
|
+
wsRef.current.send(JSON.stringify({ type: "filter", ...f }));
|
|
154
|
+
}
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return { traces, filter: filterRef.current, setFilter, wsStatus };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Main View ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
const TIME_RANGE_OPTIONS = [
|
|
163
|
+
{ label: "5m", ms: 5 * 60 * 1000 },
|
|
164
|
+
{ label: "15m", ms: 15 * 60 * 1000 },
|
|
165
|
+
{ label: "30m", ms: 30 * 60 * 1000 },
|
|
166
|
+
{ label: "1h", ms: 60 * 60 * 1000 },
|
|
167
|
+
{ label: "6h", ms: 6 * 60 * 60 * 1000 },
|
|
168
|
+
{ label: "24h", ms: 24 * 60 * 60 * 1000 },
|
|
169
|
+
{ label: "All", ms: 0 },
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
export function TracesView() {
|
|
173
|
+
const { traces, setFilter, wsStatus } = useTraceStream();
|
|
174
|
+
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
|
175
|
+
const [pathFilter, setPathFilter] = useState("");
|
|
176
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
177
|
+
const [timeRangeMs, setTimeRangeMs] = useState(15 * 60 * 1000);
|
|
178
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
179
|
+
const [searchResults, setSearchResults] = useState<TraceSummary[] | null>(null);
|
|
180
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
181
|
+
const [attributeFilters, setAttributeFilters] = useState<AttributeFilter[]>([]);
|
|
182
|
+
const [activeTab, setActiveTab] = useState<ViewTab>("traces");
|
|
183
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
184
|
+
const clearTraces = useMutation("traces.clear");
|
|
185
|
+
|
|
186
|
+
const buildFilter = (path: string, status: string, attrs: AttributeFilter[], sinceMs: number): TraceFilter => ({
|
|
187
|
+
path: path || undefined,
|
|
188
|
+
status: status === "all" ? undefined : status,
|
|
189
|
+
attributeFilters: attrs,
|
|
190
|
+
sinceMs: sinceMs || undefined,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const handleFilterChange = (path: string, status: string) => {
|
|
194
|
+
setPathFilter(path);
|
|
195
|
+
setStatusFilter(status);
|
|
196
|
+
setFilter(buildFilter(path, status, attributeFilters, timeRangeMs));
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const handleTimeRangeChange = (ms: number) => {
|
|
200
|
+
setTimeRangeMs(ms);
|
|
201
|
+
setFilter(buildFilter(pathFilter, statusFilter, attributeFilters, ms));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Debounced search
|
|
205
|
+
const handleSearchChange = (query: string) => {
|
|
206
|
+
setSearchQuery(query);
|
|
207
|
+
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
208
|
+
if (!query.trim()) {
|
|
209
|
+
setSearchResults(null);
|
|
210
|
+
setIsSearching(false);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setIsSearching(true);
|
|
214
|
+
searchTimerRef.current = setTimeout(() => {
|
|
215
|
+
rpc("traces.search", { query: query.trim(), limit: 50 }).then(data => {
|
|
216
|
+
setSearchResults(data.items);
|
|
217
|
+
setIsSearching(false);
|
|
218
|
+
}).catch(() => setIsSearching(false));
|
|
219
|
+
}, 300);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const addAttributeFilter = (key: string, value: string, type: "include" | "exclude") => {
|
|
223
|
+
const next = [...attributeFilters, { key, value, type }];
|
|
224
|
+
setAttributeFilters(next);
|
|
225
|
+
setFilter(buildFilter(pathFilter, statusFilter, next, timeRangeMs));
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const removeAttributeFilter = (index: number) => {
|
|
229
|
+
const next = attributeFilters.filter((_, i) => i !== index);
|
|
230
|
+
setAttributeFilters(next);
|
|
231
|
+
setFilter(buildFilter(pathFilter, statusFilter, next, timeRangeMs));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const displayTraces = searchResults ?? Array.from(traces.values()).sort((a, b) => b.startTime - a.startTime);
|
|
235
|
+
const maxDuration = useMemo(
|
|
236
|
+
() => Math.max(...displayTraces.map(t => t.durationMs ?? 0), 1),
|
|
237
|
+
[displayTraces],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div class="p-8 h-full flex flex-col">
|
|
242
|
+
<div class="flex items-center justify-between mb-6">
|
|
243
|
+
<div class="flex items-center gap-3">
|
|
244
|
+
<div>
|
|
245
|
+
<h1 class="text-2xl font-bold text-ink">Traces</h1>
|
|
246
|
+
<p class="text-sm text-text-muted mt-1">{traces.size} trace(s)</p>
|
|
247
|
+
</div>
|
|
248
|
+
<ConnectionStatus status={wsStatus} />
|
|
249
|
+
</div>
|
|
250
|
+
<button
|
|
251
|
+
onClick={() => { clearTraces.mutate(); setSelectedTraceId(null); }}
|
|
252
|
+
class="rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all"
|
|
253
|
+
>
|
|
254
|
+
Clear all
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* Tabs */}
|
|
259
|
+
<div class="flex border-b border-border mb-5">
|
|
260
|
+
{(["traces", "spans", "logs"] as ViewTab[]).map(tab => (
|
|
261
|
+
<button
|
|
262
|
+
key={tab}
|
|
263
|
+
onClick={() => setActiveTab(tab)}
|
|
264
|
+
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
265
|
+
activeTab === tab
|
|
266
|
+
? "border-ink text-ink"
|
|
267
|
+
: "border-transparent text-text-muted hover:text-text-data"
|
|
268
|
+
}`}
|
|
269
|
+
>
|
|
270
|
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
271
|
+
</button>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{activeTab === "traces" && (
|
|
276
|
+
<>
|
|
277
|
+
{/* Filters */}
|
|
278
|
+
<div class="flex gap-3 mb-3 flex-wrap">
|
|
279
|
+
<input
|
|
280
|
+
type="text"
|
|
281
|
+
placeholder="Search traces..."
|
|
282
|
+
value={searchQuery}
|
|
283
|
+
onInput={e => handleSearchChange((e.target as HTMLInputElement).value)}
|
|
284
|
+
class="bg-panel border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-border focus:ring-1 focus:ring-border transition-all w-72"
|
|
285
|
+
/>
|
|
286
|
+
<input
|
|
287
|
+
type="text"
|
|
288
|
+
placeholder="Filter by path (e.g. /api/*)"
|
|
289
|
+
value={pathFilter}
|
|
290
|
+
onInput={e => handleFilterChange((e.target as HTMLInputElement).value, statusFilter)}
|
|
291
|
+
class="bg-panel border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-border focus:ring-1 focus:ring-border transition-all w-72"
|
|
292
|
+
/>
|
|
293
|
+
<select
|
|
294
|
+
value={statusFilter}
|
|
295
|
+
onChange={e => handleFilterChange(pathFilter, (e.target as HTMLSelectElement).value)}
|
|
296
|
+
class="bg-panel border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-border focus:ring-1 focus:ring-border transition-all"
|
|
297
|
+
>
|
|
298
|
+
<option value="all">All statuses</option>
|
|
299
|
+
<option value="ok">OK</option>
|
|
300
|
+
<option value="error">Error</option>
|
|
301
|
+
</select>
|
|
302
|
+
<div class="flex items-center bg-panel border border-border rounded-lg overflow-hidden">
|
|
303
|
+
{TIME_RANGE_OPTIONS.map(opt => (
|
|
304
|
+
<button
|
|
305
|
+
key={opt.label}
|
|
306
|
+
onClick={() => handleTimeRangeChange(opt.ms)}
|
|
307
|
+
class={`px-2.5 py-2 text-xs font-medium transition-colors ${
|
|
308
|
+
timeRangeMs === opt.ms
|
|
309
|
+
? "bg-gray-900 text-white"
|
|
310
|
+
: "text-text-secondary hover:bg-panel-hover hover:text-ink"
|
|
311
|
+
}`}
|
|
312
|
+
>
|
|
313
|
+
{opt.label}
|
|
314
|
+
</button>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Attribute filter pills */}
|
|
320
|
+
{attributeFilters.length > 0 && (
|
|
321
|
+
<div class="flex gap-2 mb-4 flex-wrap">
|
|
322
|
+
{attributeFilters.map((f, i) => (
|
|
323
|
+
<span
|
|
324
|
+
key={i}
|
|
325
|
+
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium"
|
|
326
|
+
style={{
|
|
327
|
+
background: f.type === "include" ? "var(--color-badge-emerald-bg)" : "var(--color-badge-red-bg)",
|
|
328
|
+
color: f.type === "include" ? "var(--color-badge-emerald-text)" : "var(--color-badge-red-text)",
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
{f.type === "include" ? "+" : "\u2212"} {f.key}={f.value}
|
|
332
|
+
<button
|
|
333
|
+
onClick={() => removeAttributeFilter(i)}
|
|
334
|
+
class="ml-1 hover:opacity-70"
|
|
335
|
+
>
|
|
336
|
+
×
|
|
337
|
+
</button>
|
|
338
|
+
</span>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
{/* Trace list */}
|
|
344
|
+
<div class="flex-1 overflow-y-auto scrollbar-thin">
|
|
345
|
+
{isSearching ? (
|
|
346
|
+
<div class="text-text-muted font-medium text-center py-12">Searching...</div>
|
|
347
|
+
) : displayTraces.length === 0 ? (
|
|
348
|
+
<div class="text-text-muted font-medium text-center py-12">
|
|
349
|
+
{searchQuery ? "No matching traces found." : "No traces yet. Make some requests to see them here."}
|
|
350
|
+
</div>
|
|
351
|
+
) : (
|
|
352
|
+
<div class="bg-panel rounded-lg border border-border overflow-hidden">
|
|
353
|
+
<table class="w-full text-sm">
|
|
354
|
+
<thead>
|
|
355
|
+
<tr class="border-b border-border-subtle">
|
|
356
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Status</th>
|
|
357
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Name</th>
|
|
358
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Worker</th>
|
|
359
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium" style={{ minWidth: "140px" }}>Duration</th>
|
|
360
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Spans</th>
|
|
361
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Time</th>
|
|
362
|
+
</tr>
|
|
363
|
+
</thead>
|
|
364
|
+
<tbody>
|
|
365
|
+
{displayTraces.map(trace => (
|
|
366
|
+
<tr
|
|
367
|
+
key={trace.traceId}
|
|
368
|
+
onClick={() => setSelectedTraceId(trace.traceId)}
|
|
369
|
+
class={`border-b border-border-row cursor-pointer transition-colors hover:bg-panel-hover/50 ${
|
|
370
|
+
selectedTraceId === trace.traceId ? "bg-panel-secondary" : ""
|
|
371
|
+
}`}
|
|
372
|
+
>
|
|
373
|
+
<td class="px-4 py-2.5">
|
|
374
|
+
<TraceStatusBadge status={trace.status} />
|
|
375
|
+
</td>
|
|
376
|
+
<td class="px-4 py-2.5">
|
|
377
|
+
<span class="font-medium text-ink">{trace.rootSpanName}</span>
|
|
378
|
+
{trace.status === "error" && trace.statusMessage && (
|
|
379
|
+
<span class="ml-2 text-xs text-red-400">{trace.statusMessage}</span>
|
|
380
|
+
)}
|
|
381
|
+
</td>
|
|
382
|
+
<td class="px-4 py-2.5">
|
|
383
|
+
{trace.workerName && (
|
|
384
|
+
<span class="inline-flex px-2 py-0.5 rounded-md text-xs font-medium bg-panel-hover text-text-secondary">
|
|
385
|
+
{trace.workerName}
|
|
386
|
+
</span>
|
|
387
|
+
)}
|
|
388
|
+
</td>
|
|
389
|
+
<td class="px-4 py-2.5">
|
|
390
|
+
<DurationBar durationMs={trace.durationMs} maxDuration={maxDuration} />
|
|
391
|
+
</td>
|
|
392
|
+
<td class="px-4 py-2.5 text-right text-text-secondary">
|
|
393
|
+
{trace.spanCount}
|
|
394
|
+
{trace.errorCount > 0 && (
|
|
395
|
+
<span class="ml-1 text-red-400">({trace.errorCount} err)</span>
|
|
396
|
+
)}
|
|
397
|
+
</td>
|
|
398
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-text-muted">
|
|
399
|
+
{formatTimestamp(trace.startTime)}
|
|
400
|
+
</td>
|
|
401
|
+
</tr>
|
|
402
|
+
))}
|
|
403
|
+
</tbody>
|
|
404
|
+
</table>
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
</>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
{activeTab === "spans" && <SpansListTab />}
|
|
412
|
+
{activeTab === "logs" && <LogsListTab />}
|
|
413
|
+
|
|
414
|
+
{/* Trace detail drawer */}
|
|
415
|
+
{selectedTraceId && (
|
|
416
|
+
<TraceDrawer
|
|
417
|
+
traceId={selectedTraceId}
|
|
418
|
+
onClose={() => setSelectedTraceId(null)}
|
|
419
|
+
onAddAttributeFilter={addAttributeFilter}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ─── Spans List Tab ──────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
interface SpanRow {
|
|
429
|
+
spanId: string;
|
|
430
|
+
traceId: string;
|
|
431
|
+
name: string;
|
|
432
|
+
status: string;
|
|
433
|
+
durationMs: number | null;
|
|
434
|
+
startTime: number;
|
|
435
|
+
workerName: string | null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function SpansListTab() {
|
|
439
|
+
const [spans, setSpans] = useState<SpanRow[]>([]);
|
|
440
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
441
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
442
|
+
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
|
443
|
+
|
|
444
|
+
const loadSpans = (cur?: string) => {
|
|
445
|
+
setIsLoading(true);
|
|
446
|
+
rpc("traces.listSpans", { limit: 50, cursor: cur }).then(data => {
|
|
447
|
+
if (cur) {
|
|
448
|
+
setSpans(prev => [...prev, ...data.items]);
|
|
449
|
+
} else {
|
|
450
|
+
setSpans(data.items);
|
|
451
|
+
}
|
|
452
|
+
setCursor(data.cursor);
|
|
453
|
+
setIsLoading(false);
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
useEffect(() => { loadSpans(); }, []);
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div class="flex-1 overflow-y-auto scrollbar-thin">
|
|
461
|
+
{spans.length === 0 && !isLoading ? (
|
|
462
|
+
<div class="text-text-muted font-medium text-center py-12">No spans recorded yet.</div>
|
|
463
|
+
) : (
|
|
464
|
+
<div class="bg-panel rounded-lg border border-border overflow-hidden">
|
|
465
|
+
<table class="w-full text-sm">
|
|
466
|
+
<thead>
|
|
467
|
+
<tr class="border-b border-border-subtle">
|
|
468
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Status</th>
|
|
469
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Name</th>
|
|
470
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Duration</th>
|
|
471
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Worker</th>
|
|
472
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Time</th>
|
|
473
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Trace</th>
|
|
474
|
+
</tr>
|
|
475
|
+
</thead>
|
|
476
|
+
<tbody>
|
|
477
|
+
{spans.map(span => (
|
|
478
|
+
<tr key={span.spanId} class="border-b border-border-row hover:bg-panel-hover/50 transition-colors">
|
|
479
|
+
<td class="px-4 py-2.5"><TraceStatusBadge status={span.status} /></td>
|
|
480
|
+
<td class="px-4 py-2.5 font-medium text-ink">{span.name}</td>
|
|
481
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-text-secondary">
|
|
482
|
+
{span.durationMs !== null ? formatDuration(span.durationMs) : "..."}
|
|
483
|
+
</td>
|
|
484
|
+
<td class="px-4 py-2.5">
|
|
485
|
+
{span.workerName && (
|
|
486
|
+
<span class="inline-flex px-2 py-0.5 rounded-md text-xs font-medium bg-panel-hover text-text-secondary">
|
|
487
|
+
{span.workerName}
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
</td>
|
|
491
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-text-muted">{formatTimestamp(span.startTime)}</td>
|
|
492
|
+
<td class="px-4 py-2.5 text-right">
|
|
493
|
+
<button
|
|
494
|
+
onClick={() => setSelectedTraceId(span.traceId)}
|
|
495
|
+
class="text-blue-500 hover:text-blue-700 text-xs font-mono"
|
|
496
|
+
>
|
|
497
|
+
{span.traceId.slice(0, 8)}...
|
|
498
|
+
</button>
|
|
499
|
+
</td>
|
|
500
|
+
</tr>
|
|
501
|
+
))}
|
|
502
|
+
</tbody>
|
|
503
|
+
</table>
|
|
504
|
+
{cursor && (
|
|
505
|
+
<div class="p-4 text-center border-t border-border-subtle">
|
|
506
|
+
<button
|
|
507
|
+
onClick={() => loadSpans(cursor)}
|
|
508
|
+
disabled={isLoading}
|
|
509
|
+
class="text-sm text-text-secondary hover:text-ink disabled:text-text-dim"
|
|
510
|
+
>
|
|
511
|
+
{isLoading ? "Loading..." : "Load more"}
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
{isLoading && spans.length === 0 && <div class="text-text-muted text-sm text-center py-12">Loading spans...</div>}
|
|
518
|
+
{selectedTraceId && (
|
|
519
|
+
<TraceDrawer traceId={selectedTraceId} onClose={() => setSelectedTraceId(null)} onAddAttributeFilter={() => {}} />
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ─── Logs List Tab ───────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
interface LogRow {
|
|
528
|
+
id: number;
|
|
529
|
+
spanId: string;
|
|
530
|
+
traceId: string;
|
|
531
|
+
timestamp: number;
|
|
532
|
+
name: string;
|
|
533
|
+
level: string | null;
|
|
534
|
+
message: string | null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function LogsListTab() {
|
|
538
|
+
const [logs, setLogs] = useState<LogRow[]>([]);
|
|
539
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
540
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
541
|
+
|
|
542
|
+
const loadLogs = (cur?: string) => {
|
|
543
|
+
setIsLoading(true);
|
|
544
|
+
rpc("traces.listLogs", { limit: 50, cursor: cur }).then(data => {
|
|
545
|
+
if (cur) {
|
|
546
|
+
setLogs(prev => [...prev, ...data.items]);
|
|
547
|
+
} else {
|
|
548
|
+
setLogs(data.items);
|
|
549
|
+
}
|
|
550
|
+
setCursor(data.cursor);
|
|
551
|
+
setIsLoading(false);
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
useEffect(() => { loadLogs(); }, []);
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
<div class="flex-1 overflow-y-auto scrollbar-thin">
|
|
559
|
+
{logs.length === 0 && !isLoading ? (
|
|
560
|
+
<div class="text-text-muted font-medium text-center py-12">No log events recorded yet.</div>
|
|
561
|
+
) : (
|
|
562
|
+
<div class="bg-panel rounded-lg border border-border overflow-hidden">
|
|
563
|
+
<table class="w-full text-sm">
|
|
564
|
+
<thead>
|
|
565
|
+
<tr class="border-b border-border-subtle">
|
|
566
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Level</th>
|
|
567
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Name</th>
|
|
568
|
+
<th class="text-left px-4 py-2.5 text-xs text-text-muted font-medium">Message</th>
|
|
569
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Time</th>
|
|
570
|
+
<th class="text-right px-4 py-2.5 text-xs text-text-muted font-medium">Span / Trace</th>
|
|
571
|
+
</tr>
|
|
572
|
+
</thead>
|
|
573
|
+
<tbody>
|
|
574
|
+
{logs.map(log => (
|
|
575
|
+
<tr key={log.id} class="border-b border-border-row hover:bg-panel-hover/50 transition-colors">
|
|
576
|
+
<td class="px-4 py-2.5">
|
|
577
|
+
{log.level ? <EventLevelBadge level={log.level} /> : <span class="text-text-dim">-</span>}
|
|
578
|
+
</td>
|
|
579
|
+
<td class="px-4 py-2.5 font-medium text-ink">{log.name}</td>
|
|
580
|
+
<td class="px-4 py-2.5 text-text-data font-mono text-xs truncate max-w-[300px]">{log.message ?? ""}</td>
|
|
581
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-text-muted">{formatTimestamp(log.timestamp)}</td>
|
|
582
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-text-muted">
|
|
583
|
+
{log.traceId.slice(0, 8)}...
|
|
584
|
+
</td>
|
|
585
|
+
</tr>
|
|
586
|
+
))}
|
|
587
|
+
</tbody>
|
|
588
|
+
</table>
|
|
589
|
+
{cursor && (
|
|
590
|
+
<div class="p-4 text-center border-t border-border-subtle">
|
|
591
|
+
<button
|
|
592
|
+
onClick={() => loadLogs(cursor)}
|
|
593
|
+
disabled={isLoading}
|
|
594
|
+
class="text-sm text-text-secondary hover:text-ink disabled:text-text-dim"
|
|
595
|
+
>
|
|
596
|
+
{isLoading ? "Loading..." : "Load more"}
|
|
597
|
+
</button>
|
|
598
|
+
</div>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
)}
|
|
602
|
+
{isLoading && logs.length === 0 && <div class="text-text-muted text-sm text-center py-12">Loading logs...</div>}
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ─── Trace Detail Drawer ─────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
const SOURCE_BADGE_STYLES: Record<string, { bg: string; color: string }> = {
|
|
610
|
+
fetch: { bg: "var(--color-badge-blue-bg)", color: "var(--color-badge-blue-text)" },
|
|
611
|
+
scheduled: { bg: "var(--color-badge-purple-bg)", color: "var(--color-badge-purple-text)" },
|
|
612
|
+
queue: { bg: "var(--color-badge-orange-bg)", color: "var(--color-badge-orange-text)" },
|
|
613
|
+
alarm: { bg: "var(--color-badge-yellow-bg)", color: "var(--color-badge-yellow-text)" },
|
|
614
|
+
workflow: { bg: "var(--color-badge-emerald-bg)", color: "var(--color-badge-emerald-text)" },
|
|
615
|
+
};
|
|
616
|
+
const DEFAULT_BADGE_STYLE = { bg: "var(--color-badge-red-bg)", color: "var(--color-badge-red-text)" };
|
|
617
|
+
|
|
618
|
+
function TraceDrawer({ traceId, onClose, onAddAttributeFilter }: {
|
|
619
|
+
traceId: string;
|
|
620
|
+
onClose: () => void;
|
|
621
|
+
onAddAttributeFilter: (key: string, value: string, type: "include" | "exclude") => void;
|
|
622
|
+
}) {
|
|
623
|
+
const [spans, setSpans] = useState<SpanData[]>([]);
|
|
624
|
+
const [events, setEvents] = useState<SpanEventData[]>([]);
|
|
625
|
+
const [traceErrors, setTraceErrors] = useState<TraceErrorSummary[]>([]);
|
|
626
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
627
|
+
|
|
628
|
+
// Initial load
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
setIsLoading(true);
|
|
631
|
+
rpc("traces.getTrace", { traceId }).then(data => {
|
|
632
|
+
setSpans(data.spans);
|
|
633
|
+
setEvents(data.events);
|
|
634
|
+
setIsLoading(false);
|
|
635
|
+
});
|
|
636
|
+
rpc("traces.errors", { traceId }).then(setTraceErrors).catch(() => {});
|
|
637
|
+
}, [traceId]);
|
|
638
|
+
|
|
639
|
+
// Live updates via WS event bus
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
return onTraceEvents((traceEvents) => {
|
|
642
|
+
for (const ev of traceEvents) {
|
|
643
|
+
if (ev.type === "span.start" && ev.span.traceId === traceId) {
|
|
644
|
+
setSpans(prev => {
|
|
645
|
+
if (prev.some(s => s.spanId === ev.span.spanId)) return prev;
|
|
646
|
+
return [...prev, ev.span];
|
|
647
|
+
});
|
|
648
|
+
} else if (ev.type === "span.end" && ev.span.traceId === traceId) {
|
|
649
|
+
setSpans(prev => prev.map(s => s.spanId === ev.span.spanId ? ev.span : s));
|
|
650
|
+
} else if (ev.type === "span.event" && ev.event.traceId === traceId) {
|
|
651
|
+
setEvents(prev => [...prev, ev.event as SpanEventData]);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
}, [traceId]);
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<>
|
|
659
|
+
{/* Backdrop */}
|
|
660
|
+
<div
|
|
661
|
+
class="fixed inset-0 bg-black/10 z-40"
|
|
662
|
+
onClick={onClose}
|
|
663
|
+
/>
|
|
664
|
+
{/* Drawer */}
|
|
665
|
+
<div class="fixed right-0 top-0 bottom-0 w-[960px] max-w-[90vw] bg-panel border-l border-border z-50 flex flex-col overflow-hidden animate-slide-in">
|
|
666
|
+
{/* Header */}
|
|
667
|
+
<div class="flex items-center justify-between px-5 py-3 border-b border-border">
|
|
668
|
+
<div>
|
|
669
|
+
<div class="text-xs text-text-muted font-mono">Trace {traceId.slice(0, 12)}...</div>
|
|
670
|
+
<div class="text-sm font-medium text-ink mt-0.5">
|
|
671
|
+
{spans.find(s => !s.parentSpanId)?.name ?? "Loading..."}
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
<button onClick={onClose} class="w-7 h-7 flex items-center justify-center rounded-md hover:bg-panel-hover transition-colors text-text-muted hover:text-ink">
|
|
675
|
+
×
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
{/* Content */}
|
|
680
|
+
<div class="flex-1 overflow-y-auto scrollbar-thin p-5">
|
|
681
|
+
{isLoading ? (
|
|
682
|
+
<div class="text-text-muted text-sm">Loading trace...</div>
|
|
683
|
+
) : (
|
|
684
|
+
<div>
|
|
685
|
+
{/* Linked errors */}
|
|
686
|
+
{traceErrors.length > 0 && (
|
|
687
|
+
<div class="mb-4">
|
|
688
|
+
<div class="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">Errors ({traceErrors.length})</div>
|
|
689
|
+
<div class="space-y-1">
|
|
690
|
+
{traceErrors.map(err => (
|
|
691
|
+
<a
|
|
692
|
+
key={err.id}
|
|
693
|
+
href={`#/errors/${err.id}`}
|
|
694
|
+
class="flex items-center gap-2 px-3 py-2 rounded-md text-xs no-underline transition-colors"
|
|
695
|
+
style={{ background: "var(--color-error-highlight)", borderColor: "var(--color-error-ring)" }}
|
|
696
|
+
>
|
|
697
|
+
{err.source && (() => {
|
|
698
|
+
const s = SOURCE_BADGE_STYLES[err.source] ?? DEFAULT_BADGE_STYLE;
|
|
699
|
+
return (
|
|
700
|
+
<span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium" style={{ background: s.bg, color: s.color }}>
|
|
701
|
+
{err.source}
|
|
702
|
+
</span>
|
|
703
|
+
);
|
|
704
|
+
})()}
|
|
705
|
+
<span class="font-medium" style={{ color: "var(--color-badge-red-text)" }}>{err.errorName}</span>
|
|
706
|
+
<span style={{ color: "var(--color-badge-red-text)" }} class="truncate">{err.errorMessage}</span>
|
|
707
|
+
<span style={{ color: "var(--color-badge-red-text)", opacity: 0.7 }} class="font-mono ml-auto flex-shrink-0">{formatTimestamp(err.timestamp)}</span>
|
|
708
|
+
</a>
|
|
709
|
+
))}
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
713
|
+
|
|
714
|
+
<TraceWaterfall
|
|
715
|
+
spans={spans}
|
|
716
|
+
events={events}
|
|
717
|
+
onAddAttributeFilter={onAddAttributeFilter}
|
|
718
|
+
/>
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
<style>{`
|
|
724
|
+
@keyframes slide-in {
|
|
725
|
+
from { transform: translateX(100%); }
|
|
726
|
+
to { transform: translateX(0); }
|
|
727
|
+
}
|
|
728
|
+
.animate-slide-in {
|
|
729
|
+
animation: slide-in 0.2s ease-out;
|
|
730
|
+
}
|
|
731
|
+
`}</style>
|
|
732
|
+
</>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
function ConnectionStatus({ status }: { status: WsStatus }) {
|
|
739
|
+
const config: Record<WsStatus, { color: string; label: string }> = {
|
|
740
|
+
live: { color: "bg-emerald-400", label: "Live" },
|
|
741
|
+
connecting: { color: "bg-yellow-400 animate-pulse", label: "Connecting..." },
|
|
742
|
+
error: { color: "bg-red-400", label: "Error" },
|
|
743
|
+
disconnected: { color: "bg-gray-400", label: "Disconnected" },
|
|
744
|
+
};
|
|
745
|
+
const { color, label } = config[status];
|
|
746
|
+
return (
|
|
747
|
+
<div class="flex items-center gap-1.5 ml-3">
|
|
748
|
+
<span class={`w-2 h-2 rounded-full ${color}`} />
|
|
749
|
+
<span class="text-xs text-text-secondary">{label}</span>
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function DurationBar({ durationMs, maxDuration }: { durationMs: number | null; maxDuration: number }) {
|
|
755
|
+
if (durationMs === null) {
|
|
756
|
+
return <span class="text-xs text-text-muted font-mono">...</span>;
|
|
757
|
+
}
|
|
758
|
+
const pct = Math.max((durationMs / maxDuration) * 100, 1);
|
|
759
|
+
return (
|
|
760
|
+
<div class="flex items-center gap-2">
|
|
761
|
+
<div class="flex-1 h-1.5 bg-panel-hover rounded-full overflow-hidden">
|
|
762
|
+
<div class="h-full bg-gray-400 rounded-full" style={{ width: `${pct}%` }} />
|
|
763
|
+
</div>
|
|
764
|
+
<span class="text-xs text-text-secondary font-mono whitespace-nowrap w-14 text-right">{formatDuration(durationMs)}</span>
|
|
765
|
+
</div>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|