Skip to content

Commit a718e1c

Browse files
committed
poc: Artifact Visualization
1 parent 3f1a821 commit a718e1c

16 files changed

Lines changed: 563 additions & 101 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"eslint-plugin-simple-import-sort": "^12.1.1",
9292
"fast-deep-equal": "^3.1.3",
9393
"gh-pages": "^6.3.0",
94+
"hyparquet": "^1.25.1",
9495
"js-yaml": "^4.1.1",
9596
"localforage": "^1.10.0",
9697
"lucide-react": "^0.577.0",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Table,
3+
TableBody,
4+
TableCell,
5+
TableHead,
6+
TableHeader,
7+
TableRow,
8+
} from "@/components/ui/table";
9+
10+
interface ArtifactTableProps {
11+
headers: string[];
12+
rows: unknown[][];
13+
}
14+
15+
const ArtifactTable = ({ headers, rows }: ArtifactTableProps) => (
16+
<Table>
17+
<TableHeader>
18+
<TableRow>
19+
{headers.map((h) => (
20+
<TableHead key={h} className="text-xs">
21+
{h}
22+
</TableHead>
23+
))}
24+
</TableRow>
25+
</TableHeader>
26+
<TableBody>
27+
{rows.map((row, i) => (
28+
<TableRow key={i}>
29+
{row.map((cell, j) => (
30+
<TableCell
31+
key={j}
32+
className="font-mono text-xs max-w-64 truncate"
33+
title={String(cell ?? "")}
34+
>
35+
{String(cell ?? "")}
36+
</TableCell>
37+
))}
38+
</TableRow>
39+
))}
40+
</TableBody>
41+
</Table>
42+
);
43+
44+
export default ArtifactTable;
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogTrigger,
11+
} from "@/components/ui/dialog";
12+
import { Icon } from "@/components/ui/icon";
13+
import { Spinner } from "@/components/ui/spinner";
14+
import { Paragraph } from "@/components/ui/typography";
15+
import { useBackend } from "@/providers/BackendProvider";
16+
import { getArtifactSignedUrl } from "@/services/executionService";
17+
import type { InputSpec, OutputSpec } from "@/utils/componentSpec";
18+
19+
import CsvVisualizer from "./CsvVisualizer";
20+
import ImageVisualizer from "./ImageVisualizer";
21+
import JsonVisualizer from "./JsonVisualizer";
22+
import ParquetVisualizer from "./ParquetVisualizer";
23+
24+
const VISUALIZABLE_TYPES = new Set([
25+
"image",
26+
"jsonobject",
27+
"jsonarray",
28+
"csv",
29+
"tsv",
30+
"apacheparquet",
31+
]);
32+
33+
interface ArtifactVisualizerProps {
34+
artifactId: string;
35+
io: InputSpec | OutputSpec;
36+
}
37+
38+
const ArtifactVisualizer = ({ artifactId, io }: ArtifactVisualizerProps) => {
39+
const { backendUrl } = useBackend();
40+
41+
const typeName = io.type as string | undefined;
42+
const name = io.name;
43+
44+
const normalizedType = typeName?.toLowerCase().replace(/\s/g, "");
45+
46+
if (!normalizedType || !VISUALIZABLE_TYPES.has(normalizedType)) return null;
47+
48+
return (
49+
<Dialog>
50+
<DialogTrigger asChild>
51+
<Button variant="ghost" size="xs">
52+
<Icon name="Eye" />
53+
Preview
54+
</Button>
55+
</DialogTrigger>
56+
<DialogContent className="max-w-5xl w-full max-h-[90vh] flex flex-col">
57+
<DialogHeader>
58+
<DialogTitle>
59+
{name}
60+
{typeName && (
61+
<span className="ml-2 text-xs font-normal text-muted-foreground">
62+
{typeName}
63+
</span>
64+
)}
65+
</DialogTitle>
66+
<DialogDescription className="hidden">
67+
Artifact visualization for {name}
68+
</DialogDescription>
69+
</DialogHeader>
70+
<div className="overflow-auto flex-1">
71+
<PreviewContent
72+
artifactId={artifactId}
73+
type={normalizedType}
74+
backendUrl={backendUrl}
75+
name={name}
76+
/>
77+
</div>
78+
</DialogContent>
79+
</Dialog>
80+
);
81+
};
82+
83+
interface PreviewContentProps {
84+
artifactId: string;
85+
type: string;
86+
backendUrl: string;
87+
name: string;
88+
}
89+
90+
const PreviewContent = ({
91+
artifactId,
92+
type,
93+
backendUrl,
94+
name,
95+
}: PreviewContentProps) => {
96+
const { data, isLoading, error } = useQuery({
97+
queryKey: ["artifact-signed-url", artifactId],
98+
queryFn: () => getArtifactSignedUrl(artifactId, backendUrl),
99+
staleTime: 4 * 60 * 1000,
100+
retry: false,
101+
});
102+
103+
if (isLoading) return <Spinner />;
104+
if (error)
105+
return (
106+
<Paragraph tone="critical" size="xs">
107+
Could not load preview: {(error as Error).message}
108+
</Paragraph>
109+
);
110+
111+
const signedUrl = data?.signed_url;
112+
if (!signedUrl) return null;
113+
114+
switch (type) {
115+
case "image":
116+
return <ImageVisualizer src={signedUrl} name={name} />;
117+
case "csv":
118+
return <CsvVisualizer signedUrl={signedUrl} delimiter="," />;
119+
case "tsv":
120+
return <CsvVisualizer signedUrl={signedUrl} delimiter={"\t"} />;
121+
case "apacheparquet":
122+
return <ParquetVisualizer signedUrl={signedUrl} />;
123+
case "jsonobject":
124+
case "jsonarray":
125+
return <JsonVisualizer signedUrl={signedUrl} name={name} />;
126+
default:
127+
return null;
128+
}
129+
};
130+
131+
export default ArtifactVisualizer;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { InlineStack } from "@/components/ui/layout";
4+
import { Link } from "@/components/ui/link";
5+
import { Spinner } from "@/components/ui/spinner";
6+
import { Paragraph } from "@/components/ui/typography";
7+
8+
import ArtifactTable from "./ArtifactTable";
9+
10+
const MAX_PREVIEW_ROWS = 10;
11+
12+
function parseCsvPreview(
13+
text: string,
14+
delimiter: string,
15+
): { headers: string[]; rows: string[][] } {
16+
const lines = text.trim().split(/\r?\n/);
17+
if (lines.length === 0) return { headers: [], rows: [] };
18+
19+
const split = (line: string) =>
20+
line.split(delimiter).map((c) => c.replace(/^"|"$/g, "").trim());
21+
22+
const headers = split(lines[0]);
23+
const rows = lines.slice(1, MAX_PREVIEW_ROWS + 1).map(split);
24+
return { headers, rows };
25+
}
26+
27+
interface CsvVisualizerProps {
28+
signedUrl: string;
29+
delimiter: string;
30+
}
31+
32+
const CsvVisualizer = ({ signedUrl, delimiter }: CsvVisualizerProps) => {
33+
const { data, isLoading, error } = useQuery({
34+
queryKey: ["artifact-csv", signedUrl, delimiter],
35+
queryFn: async () => {
36+
const response = await fetch(signedUrl);
37+
if (!response.ok)
38+
throw new Error(`Failed to fetch artifact: ${response.statusText}`);
39+
const text = await response.text();
40+
return parseCsvPreview(text, delimiter);
41+
},
42+
staleTime: 4 * 60 * 1000,
43+
retry: false,
44+
});
45+
46+
if (isLoading) return <Spinner />;
47+
if (error)
48+
return (
49+
<Paragraph tone="critical" size="xs">
50+
Failed to load preview: {(error as Error).message}
51+
</Paragraph>
52+
);
53+
if (!data || data.headers.length === 0)
54+
return (
55+
<Paragraph tone="subdued" size="xs">
56+
No data
57+
</Paragraph>
58+
);
59+
60+
return (
61+
<>
62+
<ArtifactTable headers={data.headers} rows={data.rows} />
63+
<InlineStack gap="4" className="mt-2">
64+
<Paragraph tone="subdued" size="xs">
65+
Showing first {data.rows.length} rows
66+
</Paragraph>
67+
<Link
68+
href={signedUrl}
69+
target="_blank"
70+
rel="noopener"
71+
className="text-xs"
72+
>
73+
See all
74+
</Link>
75+
</InlineStack>
76+
</>
77+
);
78+
};
79+
80+
export default CsvVisualizer;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
interface ImageVisualizerProps {
2+
src: string;
3+
name: string;
4+
}
5+
6+
const ImageVisualizer = ({ src, name }: ImageVisualizerProps) => (
7+
<img src={src} alt={name} className="max-w-full rounded-md object-contain" />
8+
);
9+
10+
export default ImageVisualizer;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { Spinner } from "@/components/ui/spinner";
4+
import { Paragraph } from "@/components/ui/typography";
5+
6+
import IOCodeViewer from "../IOCodeViewer";
7+
8+
interface JsonVisualizerProps {
9+
signedUrl: string;
10+
name: string;
11+
}
12+
13+
const JsonVisualizer = ({ signedUrl, name }: JsonVisualizerProps) => {
14+
const { data, isLoading, error } = useQuery({
15+
queryKey: ["artifact-json", signedUrl],
16+
queryFn: async () => {
17+
const response = await fetch(signedUrl);
18+
if (!response.ok)
19+
throw new Error(`Failed to fetch artifact: ${response.statusText}`);
20+
return response.text();
21+
},
22+
staleTime: 4 * 60 * 1000,
23+
retry: false,
24+
});
25+
26+
if (isLoading) return <Spinner />;
27+
if (error)
28+
return (
29+
<Paragraph tone="critical" size="xs">
30+
Failed to load preview: {(error as Error).message}
31+
</Paragraph>
32+
);
33+
if (!data) return null;
34+
35+
return <IOCodeViewer title={name} value={data} />;
36+
};
37+
38+
export default JsonVisualizer;

0 commit comments

Comments
 (0)