Skip to content

Commit fbe8e88

Browse files
committed
poc: Artifact Visualization
1 parent 4ba8370 commit fbe8e88

17 files changed

Lines changed: 569 additions & 113 deletions

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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
18+
import CsvVisualizer from "./CsvVisualizer";
19+
import ImageVisualizer from "./ImageVisualizer";
20+
import JsonVisualizer from "./JsonVisualizer";
21+
import ParquetVisualizer from "./ParquetVisualizer";
22+
23+
const VISUALIZABLE_TYPES = new Set([
24+
"image",
25+
"jsonobject",
26+
"jsonarray",
27+
"csv",
28+
"tsv",
29+
"apacheparquet",
30+
]);
31+
32+
interface ArtifactVisualizerProps {
33+
artifactId: string;
34+
typeName: string | null | undefined;
35+
hasInlineValue: boolean;
36+
name: string;
37+
}
38+
39+
const ArtifactVisualizer = ({
40+
artifactId,
41+
typeName,
42+
hasInlineValue,
43+
name,
44+
}: ArtifactVisualizerProps) => {
45+
const { backendUrl } = useBackend();
46+
47+
const normalized = typeName?.toLowerCase().replace(/\s/g, "");
48+
49+
if (hasInlineValue) return null;
50+
if (!normalized || !VISUALIZABLE_TYPES.has(normalized)) return null;
51+
52+
// todo: allow dialog to be fullscreen
53+
return (
54+
<Dialog>
55+
<DialogTrigger asChild>
56+
<Button variant="ghost" size="xs">
57+
<Icon name="Eye" />
58+
Preview
59+
</Button>
60+
</DialogTrigger>
61+
<DialogContent className="max-w-5xl w-full max-h-[90vh] flex flex-col">
62+
<DialogHeader>
63+
<DialogTitle>
64+
{name}
65+
{typeName && (
66+
<span className="ml-2 text-xs font-normal text-muted-foreground">
67+
{typeName}
68+
</span>
69+
)}
70+
</DialogTitle>
71+
<DialogDescription className="hidden">
72+
Artifact visualization for {name}
73+
</DialogDescription>
74+
</DialogHeader>
75+
<div className="overflow-auto flex-1">
76+
<PreviewContent
77+
artifactId={artifactId}
78+
normalized={normalized}
79+
backendUrl={backendUrl}
80+
name={name}
81+
/>
82+
</div>
83+
</DialogContent>
84+
</Dialog>
85+
);
86+
};
87+
88+
interface PreviewContentProps {
89+
artifactId: string;
90+
normalized: string;
91+
backendUrl: string;
92+
name: string;
93+
}
94+
95+
const PreviewContent = ({
96+
artifactId,
97+
normalized,
98+
backendUrl,
99+
name,
100+
}: PreviewContentProps) => {
101+
const { data, isLoading, error } = useQuery({
102+
queryKey: ["artifact-signed-url", artifactId],
103+
queryFn: () => getArtifactSignedUrl(artifactId, backendUrl),
104+
staleTime: 4 * 60 * 1000,
105+
retry: false,
106+
});
107+
108+
if (isLoading) return <Spinner />;
109+
if (error)
110+
return (
111+
<Paragraph tone="critical" size="xs">
112+
Could not load preview: {(error as Error).message}
113+
</Paragraph>
114+
);
115+
116+
const signedUrl = data?.signed_url;
117+
if (!signedUrl) return null;
118+
119+
switch (normalized) {
120+
case "image":
121+
return <ImageVisualizer src={signedUrl} name={name} />;
122+
case "csv":
123+
return <CsvVisualizer signedUrl={signedUrl} delimiter="," />;
124+
case "tsv":
125+
return <CsvVisualizer signedUrl={signedUrl} delimiter={"\t"} />;
126+
case "apacheparquet":
127+
return <ParquetVisualizer signedUrl={signedUrl} />;
128+
case "jsonobject":
129+
case "jsonarray":
130+
return <JsonVisualizer signedUrl={signedUrl} name={name} />;
131+
default:
132+
return null;
133+
}
134+
};
135+
136+
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)