Skip to content

Commit 2f2ceb1

Browse files
committed
feat: initial upload ui
1 parent 3f59476 commit 2f2ceb1

7 files changed

Lines changed: 463 additions & 9 deletions

File tree

tools/trace-ui/.prettierrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"@trivago/prettier-plugin-sort-imports",
44
"prettier-plugin-tailwindcss"
55
],
6-
"importOrder": ["^[./]"],
6+
"importOrder": ["^@/", "^[./]"],
77
"importOrderSeparation": true,
88
"importOrderSortSpecifiers": true
99
}

tools/trace-ui/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>trace-ui</title>
7+
<title>Duron Trace UI</title>
88
</head>
99
<body>
1010
<div id="root"></div>

tools/trace-ui/src/App.tsx

Lines changed: 285 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,293 @@
1-
import { useState } from "react";
1+
import { AlertCircle, FileText, Loader2, Upload, X } from "lucide-react";
2+
import { useCallback, useState } from "react";
23

3-
import { Button } from "./components/ui/button";
4+
import { Alert, AlertDescription } from "@/components/ui/alert";
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from "@/components/ui/card";
13+
import { parseTraceLog } from "@/lib/trace";
14+
15+
interface TraceFile {
16+
name: string;
17+
size: number;
18+
content: string;
19+
entries: unknown[];
20+
}
421

522
function App() {
6-
const [count, setCount] = useState(0);
23+
const [file, setFile] = useState<TraceFile | null>(null);
24+
const [isDragging, setIsDragging] = useState(false);
25+
const [error, setError] = useState<string | null>(null);
26+
const [urlInput, setUrlInput] = useState("");
27+
const [isLoadingUrl, setIsLoadingUrl] = useState(false);
28+
29+
const handleFile = useCallback((selectedFile: File) => {
30+
setError(null);
31+
32+
if (!selectedFile.name.endsWith(".jsonl")) {
33+
setError("Please upload a .jsonl file");
34+
return;
35+
}
36+
37+
const reader = new FileReader();
38+
reader.onload = (e) => {
39+
try {
40+
const content = e.target?.result as string;
41+
const entries = parseTraceLog(content);
42+
43+
setFile({
44+
name: selectedFile.name,
45+
size: selectedFile.size,
46+
content,
47+
entries,
48+
});
49+
} catch (err) {
50+
setError(err instanceof Error ? err.message : "Failed to parse file");
51+
}
52+
};
53+
reader.onerror = () => {
54+
setError("Failed to read file");
55+
};
56+
reader.readAsText(selectedFile);
57+
}, []);
58+
59+
const handleDrop = useCallback(
60+
(e: React.DragEvent) => {
61+
e.preventDefault();
62+
setIsDragging(false);
63+
64+
const droppedFile = e.dataTransfer.files[0];
65+
if (droppedFile) {
66+
handleFile(droppedFile);
67+
}
68+
},
69+
[handleFile],
70+
);
71+
72+
const handleDragOver = useCallback((e: React.DragEvent) => {
73+
e.preventDefault();
74+
setIsDragging(true);
75+
}, []);
76+
77+
const handleDragLeave = useCallback((e: React.DragEvent) => {
78+
e.preventDefault();
79+
setIsDragging(false);
80+
}, []);
81+
82+
const handleFileInput = useCallback(
83+
(e: React.ChangeEvent<HTMLInputElement>) => {
84+
const selectedFile = e.target.files?.[0];
85+
if (selectedFile) {
86+
handleFile(selectedFile);
87+
}
88+
},
89+
[handleFile],
90+
);
91+
92+
const clearFile = useCallback(() => {
93+
setFile(null);
94+
setError(null);
95+
setUrlInput("");
96+
}, []);
97+
98+
const handleLoadFromUrl = useCallback(async () => {
99+
setError(null);
100+
101+
const trimmedUrl = urlInput.trim();
102+
if (!trimmedUrl) {
103+
setError("Please enter a URL");
104+
return;
105+
}
106+
107+
let parsedUrl: URL;
108+
try {
109+
parsedUrl = new URL(trimmedUrl);
110+
} catch {
111+
setError("Please enter a valid URL");
112+
return;
113+
}
114+
115+
if (!parsedUrl.pathname.endsWith(".jsonl")) {
116+
setError("URL must point to a .jsonl file");
117+
return;
118+
}
119+
120+
setIsLoadingUrl(true);
121+
try {
122+
const response = await fetch(parsedUrl.toString());
123+
124+
if (!response.ok) {
125+
throw new Error(`Failed to fetch file (status ${response.status})`);
126+
}
127+
128+
const content = await response.text();
129+
const entries = parseTraceLog(content);
130+
const size = new TextEncoder().encode(content).length;
131+
const fileName =
132+
parsedUrl.pathname.split("/").filter(Boolean).pop() ?? "trace.jsonl";
133+
134+
setFile({
135+
name: fileName,
136+
size,
137+
content,
138+
entries,
139+
});
140+
} catch (err) {
141+
setError(
142+
err instanceof Error
143+
? err.message
144+
: "Failed to load trace file from URL",
145+
);
146+
} finally {
147+
setIsLoadingUrl(false);
148+
}
149+
}, [urlInput]);
150+
151+
const formatFileSize = (bytes: number): string => {
152+
if (bytes < 1024) return `${bytes} B`;
153+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
154+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
155+
};
7156

8157
return (
9-
<Button onClick={() => setCount((count) => count + 1)}>
10-
count is {count}
11-
</Button>
158+
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900">
159+
<div className="container mx-auto px-4 py-8">
160+
{/* Upload Section */}
161+
{!file ? (
162+
<Card className="mx-auto max-w-2xl">
163+
<CardHeader>
164+
<CardTitle>Upload Trace File</CardTitle>
165+
<CardDescription>
166+
Select or drag and drop a .jsonl trace file to visualize
167+
</CardDescription>
168+
</CardHeader>
169+
<CardContent>
170+
<div
171+
onDrop={handleDrop}
172+
onDragOver={handleDragOver}
173+
onDragLeave={handleDragLeave}
174+
className={`cursor-pointer rounded-lg border-2 border-dashed p-12 text-center transition-colors ${
175+
isDragging
176+
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
177+
: "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600"
178+
} `}
179+
>
180+
<input
181+
type="file"
182+
accept=".jsonl"
183+
onChange={handleFileInput}
184+
className="hidden"
185+
id="file-input"
186+
/>
187+
<label htmlFor="file-input" className="cursor-pointer">
188+
<Upload className="mx-auto mb-4 h-12 w-12 text-slate-400" />
189+
<p className="mb-2 text-lg font-medium text-slate-700 dark:text-slate-300">
190+
Drop your trace file here, or click to browse
191+
</p>
192+
<p className="text-sm text-slate-500 dark:text-slate-400">
193+
Supports .jsonl files
194+
</p>
195+
</label>
196+
</div>
197+
198+
<div className="mt-6">
199+
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
200+
Or load from a URL
201+
</p>
202+
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
203+
<input
204+
type="url"
205+
value={urlInput}
206+
onChange={(e) => setUrlInput(e.target.value)}
207+
onKeyDown={(e) => {
208+
if (e.key === "Enter") {
209+
e.preventDefault();
210+
if (!isLoadingUrl) {
211+
void handleLoadFromUrl();
212+
}
213+
}
214+
}}
215+
placeholder="https://example.com/trace.jsonl"
216+
className="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:border-blue-400 dark:focus:ring-blue-800"
217+
/>
218+
<Button
219+
onClick={() => void handleLoadFromUrl()}
220+
disabled={isLoadingUrl || !urlInput.trim()}
221+
className="flex items-center justify-center gap-2"
222+
>
223+
{isLoadingUrl && (
224+
<Loader2 className="h-4 w-4 animate-spin" />
225+
)}
226+
{isLoadingUrl ? "Loading..." : "Load"}
227+
</Button>
228+
</div>
229+
</div>
230+
231+
{error && (
232+
<Alert variant="destructive" className="mt-4">
233+
<AlertCircle className="h-4 w-4" />
234+
<AlertDescription>{error}</AlertDescription>
235+
</Alert>
236+
)}
237+
</CardContent>
238+
</Card>
239+
) : (
240+
<>
241+
{/* File Info Bar */}
242+
<Card className="mb-6">
243+
<CardContent className="py-4">
244+
<div className="flex items-center justify-between">
245+
<div className="flex items-center gap-3">
246+
<FileText className="h-5 w-5 text-blue-500" />
247+
<div>
248+
<p className="font-medium text-slate-900 dark:text-slate-50">
249+
{file.name}
250+
</p>
251+
<p className="text-sm text-slate-500 dark:text-slate-400">
252+
{formatFileSize(file.size)}{file.entries.length}{" "}
253+
entries
254+
</p>
255+
</div>
256+
</div>
257+
<Button
258+
variant="ghost"
259+
size="sm"
260+
onClick={clearFile}
261+
className="gap-2"
262+
>
263+
<X className="h-4 w-4" />
264+
Clear
265+
</Button>
266+
</div>
267+
</CardContent>
268+
</Card>
269+
270+
{/* Trace Visualization Area */}
271+
<Card>
272+
<CardHeader>
273+
<CardTitle>Trace Visualization</CardTitle>
274+
<CardDescription>
275+
Execution timeline and operation details
276+
</CardDescription>
277+
</CardHeader>
278+
<CardContent>
279+
<div className="py-12 text-center text-slate-500 dark:text-slate-400">
280+
<p className="mb-2">Trace visualization coming soon...</p>
281+
<p className="text-sm">
282+
Loaded {file.entries.length} log entries
283+
</p>
284+
</div>
285+
</CardContent>
286+
</Card>
287+
</>
288+
)}
289+
</div>
290+
</div>
12291
);
13292
}
14293

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { type VariantProps, cva } from "class-variance-authority";
2+
import * as React from "react";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
const alertVariants = cva(
7+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8+
{
9+
variants: {
10+
variant: {
11+
default: "bg-card text-card-foreground",
12+
destructive:
13+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14+
},
15+
},
16+
defaultVariants: {
17+
variant: "default",
18+
},
19+
},
20+
);
21+
22+
function Alert({
23+
className,
24+
variant,
25+
...props
26+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27+
return (
28+
<div
29+
data-slot="alert"
30+
role="alert"
31+
className={cn(alertVariants({ variant }), className)}
32+
{...props}
33+
/>
34+
);
35+
}
36+
37+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38+
return (
39+
<div
40+
data-slot="alert-title"
41+
className={cn(
42+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
43+
className,
44+
)}
45+
{...props}
46+
/>
47+
);
48+
}
49+
50+
function AlertDescription({
51+
className,
52+
...props
53+
}: React.ComponentProps<"div">) {
54+
return (
55+
<div
56+
data-slot="alert-description"
57+
className={cn(
58+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
59+
className,
60+
)}
61+
{...props}
62+
/>
63+
);
64+
}
65+
66+
export { Alert, AlertTitle, AlertDescription };

tools/trace-ui/src/components/ui/button.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { cn } from "@/lib/utils";
21
import { Slot } from "@radix-ui/react-slot";
32
import { type VariantProps, cva } from "class-variance-authority";
43
import * as React from "react";
54

5+
import { cn } from "@/lib/utils";
6+
67
const buttonVariants = cva(
78
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
89
{

0 commit comments

Comments
 (0)