Skip to content

Commit 37d6e88

Browse files
committed
poc: Artifact Visualization
1 parent f1350f0 commit 37d6e88

17 files changed

Lines changed: 631 additions & 101 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+
import type { InputSpec, OutputSpec } from "@/utils/componentSpec";
18+
import { TWENTY_FOUR_HOURS_IN_MS } from "@/utils/constants";
19+
20+
import CsvVisualizer from "./CsvVisualizer";
21+
import ImageVisualizer from "./ImageVisualizer";
22+
import JsonVisualizer from "./JsonVisualizer";
23+
import ParquetVisualizer from "./ParquetVisualizer";
24+
import TextVisualizer from "./TextVisualizer";
25+
26+
const VISUALIZABLE_TYPES = new Set([
27+
"text",
28+
"image",
29+
"jsonobject",
30+
"jsonarray",
31+
"csv",
32+
"tsv",
33+
"apacheparquet",
34+
]);
35+
36+
interface ArtifactVisualizerProps {
37+
artifactId: string;
38+
io: InputSpec | OutputSpec;
39+
}
40+
41+
const ArtifactVisualizer = ({ artifactId, io }: ArtifactVisualizerProps) => {
42+
const { backendUrl } = useBackend();
43+
44+
const typeName = io.type as string | undefined;
45+
const name = io.name;
46+
47+
const normalizedType = typeName?.toLowerCase().replace(/\s/g, "") ?? "text";
48+
49+
if (!VISUALIZABLE_TYPES.has(normalizedType)) return null;
50+
51+
return (
52+
<Dialog>
53+
<DialogTrigger asChild>
54+
<Button variant="ghost" size="xs">
55+
<Icon name="Eye" />
56+
Preview
57+
</Button>
58+
</DialogTrigger>
59+
<DialogContent className="max-w-5xl w-full max-h-[90vh] flex flex-col">
60+
<DialogHeader>
61+
<DialogTitle>
62+
{name}
63+
{typeName && (
64+
<span className="ml-2 text-xs font-normal text-muted-foreground">
65+
{typeName}
66+
</span>
67+
)}
68+
</DialogTitle>
69+
<DialogDescription className="hidden">
70+
Artifact visualization for {name}
71+
</DialogDescription>
72+
</DialogHeader>
73+
<div className="overflow-auto flex-1">
74+
<PreviewContent
75+
artifactId={artifactId}
76+
type={normalizedType}
77+
backendUrl={backendUrl}
78+
name={name}
79+
/>
80+
</div>
81+
</DialogContent>
82+
</Dialog>
83+
);
84+
};
85+
86+
interface PreviewContentProps {
87+
artifactId: string;
88+
type: string;
89+
backendUrl: string;
90+
name: string;
91+
}
92+
93+
const PreviewContent = ({
94+
artifactId,
95+
type,
96+
backendUrl,
97+
name,
98+
}: PreviewContentProps) => {
99+
const { data, isLoading, error } = useQuery({
100+
queryKey: ["artifact-signed-url", artifactId],
101+
queryFn: () => getArtifactSignedUrl(artifactId, backendUrl),
102+
staleTime: TWENTY_FOUR_HOURS_IN_MS,
103+
retry: false,
104+
});
105+
106+
if (isLoading) return <Spinner />;
107+
if (error)
108+
return (
109+
<Paragraph tone="critical" size="xs">
110+
Could not load preview: {(error as Error).message}
111+
</Paragraph>
112+
);
113+
114+
const signedUrl = data?.signed_url;
115+
if (!signedUrl) return null;
116+
117+
switch (type) {
118+
case "text":
119+
return <TextVisualizer signedUrl={signedUrl} />;
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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
import { TWENTY_FOUR_HOURS_IN_MS } from "@/utils/constants";
8+
9+
import ArtifactTable from "./ArtifactTable";
10+
11+
const MAX_PREVIEW_ROWS = 10;
12+
13+
function parseCsvPreview(
14+
text: string,
15+
delimiter: string,
16+
): { headers: string[]; rows: string[][] } {
17+
const lines = text.trim().split(/\r?\n/);
18+
if (lines.length === 0) return { headers: [], rows: [] };
19+
20+
const split = (line: string) =>
21+
line.split(delimiter).map((c) => c.replace(/^"|"$/g, "").trim());
22+
23+
const headers = split(lines[0]);
24+
const rows = lines.slice(1, MAX_PREVIEW_ROWS + 1).map(split);
25+
return { headers, rows };
26+
}
27+
28+
interface CsvVisualizerProps {
29+
signedUrl: string;
30+
delimiter: string;
31+
}
32+
33+
const CsvVisualizer = ({ signedUrl, delimiter }: CsvVisualizerProps) => {
34+
const { data, isLoading, error } = useQuery({
35+
queryKey: ["artifact-csv", signedUrl, delimiter],
36+
queryFn: async () => {
37+
const response = await fetch(signedUrl);
38+
if (!response.ok)
39+
throw new Error(`Failed to fetch artifact: ${response.statusText}`);
40+
const text = await response.text();
41+
return parseCsvPreview(text, delimiter);
42+
},
43+
staleTime: TWENTY_FOUR_HOURS_IN_MS,
44+
retry: false,
45+
});
46+
47+
if (isLoading) return <Spinner />;
48+
if (error)
49+
return (
50+
<Paragraph tone="critical" size="xs">
51+
Failed to load preview: {(error as Error).message}
52+
</Paragraph>
53+
);
54+
if (!data || data.headers.length === 0)
55+
return (
56+
<Paragraph tone="subdued" size="xs">
57+
No data
58+
</Paragraph>
59+
);
60+
61+
return (
62+
<>
63+
<ArtifactTable headers={data.headers} rows={data.rows} />
64+
<InlineStack gap="4" className="mt-2">
65+
<Paragraph tone="subdued" size="xs">
66+
Showing first {data.rows.length} rows
67+
</Paragraph>
68+
<Link
69+
href={signedUrl}
70+
target="_blank"
71+
rel="noopener"
72+
className="text-xs"
73+
>
74+
See all
75+
</Link>
76+
</InlineStack>
77+
</>
78+
);
79+
};
80+
81+
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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { Spinner } from "@/components/ui/spinner";
4+
import { Paragraph } from "@/components/ui/typography";
5+
import { TWENTY_FOUR_HOURS_IN_MS } from "@/utils/constants";
6+
7+
import IOCodeViewer from "../IOCodeViewer";
8+
9+
interface JsonVisualizerProps {
10+
signedUrl: string;
11+
name: string;
12+
}
13+
14+
const JsonVisualizer = ({ signedUrl, name }: JsonVisualizerProps) => {
15+
const { data, isLoading, error } = useQuery({
16+
queryKey: ["artifact-json", signedUrl],
17+
queryFn: async () => {
18+
const response = await fetch(signedUrl);
19+
if (!response.ok)
20+
throw new Error(`Failed to fetch artifact: ${response.statusText}`);
21+
return response.text();
22+
},
23+
staleTime: TWENTY_FOUR_HOURS_IN_MS,
24+
retry: false,
25+
});
26+
27+
if (isLoading) return <Spinner />;
28+
if (error)
29+
return (
30+
<Paragraph tone="critical" size="xs">
31+
Failed to load preview: {(error as Error).message}
32+
</Paragraph>
33+
);
34+
if (!data) return null;
35+
36+
return <IOCodeViewer title={name} value={data} />;
37+
};
38+
39+
export default JsonVisualizer;

0 commit comments

Comments
 (0)