Skip to content

Commit 9ec7cc5

Browse files
committed
Artifact Visualization
1 parent e6fce6d commit 9ec7cc5

13 files changed

Lines changed: 695 additions & 8 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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useState } from "react";
3+
4+
import type { ArtifactNodeResponse } from "@/api/types.gen";
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
Dialog,
8+
DialogContent,
9+
DialogDescription,
10+
DialogHeader,
11+
DialogTitle,
12+
DialogTrigger,
13+
} from "@/components/ui/dialog";
14+
import { Icon } from "@/components/ui/icon";
15+
import { InlineStack } from "@/components/ui/layout";
16+
import { Spinner } from "@/components/ui/spinner";
17+
import { Paragraph, Text } from "@/components/ui/typography";
18+
import { cn } from "@/lib/utils";
19+
import { useBackend } from "@/providers/BackendProvider";
20+
import { getArtifactSignedUrl } from "@/services/executionService";
21+
import { TWENTY_FOUR_HOURS_IN_MS } from "@/utils/constants";
22+
23+
import ArtifactURI from "../ArtifactURI";
24+
import CsvVisualizer from "./CsvVisualizer";
25+
import ImageVisualizer from "./ImageVisualizer";
26+
import JsonVisualizer from "./JsonVisualizer";
27+
import ParquetVisualizer from "./ParquetVisualizer";
28+
import TextVisualizer from "./TextVisualizer";
29+
30+
const VISUALIZABLE_TYPES = new Set([
31+
"text",
32+
"image",
33+
"jsonobject",
34+
"jsonarray",
35+
"csv",
36+
"tsv",
37+
"apacheparquet",
38+
]);
39+
40+
type ArtifactVisualizerProps = {
41+
artifact: ArtifactNodeResponse;
42+
name: string;
43+
type: string;
44+
value?: string;
45+
};
46+
47+
const ArtifactVisualizer = ({
48+
artifact,
49+
name,
50+
type,
51+
value,
52+
}: ArtifactVisualizerProps) => {
53+
const [isFullscreen, setIsFullscreen] = useState(false);
54+
55+
const normalizedType = type?.toLowerCase().replace(/\s/g, "") ?? "text";
56+
57+
const handleOpenChange = (open: boolean) => {
58+
if (!open) setIsFullscreen(false);
59+
};
60+
61+
if (!VISUALIZABLE_TYPES.has(normalizedType) && !value) return null;
62+
63+
const artifactData = artifact.artifact_data;
64+
const isJson =
65+
normalizedType === "jsonobject" || normalizedType === "jsonarray";
66+
67+
return (
68+
<Dialog onOpenChange={handleOpenChange}>
69+
<DialogTrigger asChild>
70+
{value ? (
71+
<Button variant="ghost" size="xs">
72+
<Icon
73+
name="Maximize2"
74+
size="xs"
75+
className="text-muted-foreground"
76+
/>
77+
</Button>
78+
) : (
79+
<Button variant="ghost" size="xs">
80+
<Icon name="Eye" />
81+
Preview
82+
</Button>
83+
)}
84+
</DialogTrigger>
85+
<DialogContent
86+
className={cn(
87+
"flex flex-col",
88+
isFullscreen
89+
? "max-w-none! max-h-screen! w-screen h-screen rounded-none"
90+
: "max-w-5xl w-full max-h-[90vh]",
91+
)}
92+
>
93+
{!isJson && (
94+
<Button
95+
variant="ghost"
96+
onClick={() => setIsFullscreen((prev) => !prev)}
97+
className="absolute top-3 right-10 rounded-md"
98+
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
99+
size="xs"
100+
>
101+
<Icon name={isFullscreen ? "Minimize2" : "Maximize2"} size="xs" />
102+
</Button>
103+
)}
104+
<DialogHeader>
105+
<InlineStack gap="4" blockAlign="end">
106+
<DialogTitle>{name}</DialogTitle>
107+
{type && (
108+
<Text size="xs" tone="subdued" weight="light" className="-ml-2">
109+
{type}
110+
</Text>
111+
)}
112+
113+
{artifactData?.uri && (
114+
<ArtifactURI uri={artifactData.uri} isDir={artifactData.is_dir} />
115+
)}
116+
</InlineStack>
117+
118+
<DialogDescription className="hidden">
119+
Artifact visualization for {name}
120+
</DialogDescription>
121+
</DialogHeader>
122+
<div className="overflow-auto flex-1 min-h-0">
123+
{value ? (
124+
<InlineContent
125+
name={name}
126+
value={value}
127+
type={normalizedType}
128+
isFullscreen={isFullscreen}
129+
/>
130+
) : (
131+
<PreviewContent
132+
name={name}
133+
artifactId={artifact.id}
134+
type={normalizedType}
135+
isFullscreen={isFullscreen}
136+
/>
137+
)}
138+
</div>
139+
</DialogContent>
140+
</Dialog>
141+
);
142+
};
143+
144+
interface InlineContentProps {
145+
type: string;
146+
name: string;
147+
value: string;
148+
isFullscreen: boolean;
149+
}
150+
151+
const InlineContent = ({
152+
type,
153+
name,
154+
value,
155+
isFullscreen,
156+
}: InlineContentProps) => {
157+
switch (type) {
158+
case "csv":
159+
return (
160+
<CsvVisualizer
161+
value={value}
162+
delimiter=","
163+
isFullscreen={isFullscreen}
164+
/>
165+
);
166+
case "tsv":
167+
return (
168+
<CsvVisualizer
169+
value={value}
170+
delimiter={"\t"}
171+
isFullscreen={isFullscreen}
172+
/>
173+
);
174+
case "jsonobject":
175+
case "jsonarray":
176+
return <JsonVisualizer value={value} name={name} />;
177+
case "text":
178+
default:
179+
return <TextVisualizer value={value} />;
180+
}
181+
};
182+
183+
interface PreviewContentProps {
184+
artifactId: string;
185+
type: string;
186+
name: string;
187+
isFullscreen: boolean;
188+
}
189+
190+
const PreviewContent = ({
191+
artifactId,
192+
type,
193+
name,
194+
isFullscreen,
195+
}: PreviewContentProps) => {
196+
const { backendUrl } = useBackend();
197+
198+
const { data, isLoading, error } = useQuery({
199+
queryKey: ["artifact-signed-url", artifactId],
200+
queryFn: () => getArtifactSignedUrl(artifactId, backendUrl),
201+
staleTime: TWENTY_FOUR_HOURS_IN_MS,
202+
retry: false,
203+
});
204+
205+
if (isLoading) return <Spinner />;
206+
207+
if (error) {
208+
return (
209+
<Paragraph tone="critical" size="xs">
210+
Could not load preview: {(error as Error).message}
211+
</Paragraph>
212+
);
213+
}
214+
215+
const signedUrl = data?.signed_url;
216+
if (!signedUrl) return null;
217+
218+
switch (type) {
219+
case "text":
220+
return <TextVisualizer signedUrl={signedUrl} />;
221+
case "image":
222+
return <ImageVisualizer src={signedUrl} name={name} />;
223+
case "csv":
224+
return (
225+
<CsvVisualizer
226+
signedUrl={signedUrl}
227+
delimiter=","
228+
isFullscreen={isFullscreen}
229+
/>
230+
);
231+
case "tsv":
232+
return (
233+
<CsvVisualizer
234+
signedUrl={signedUrl}
235+
delimiter={"\t"}
236+
isFullscreen={isFullscreen}
237+
/>
238+
);
239+
case "apacheparquet":
240+
return (
241+
<ParquetVisualizer signedUrl={signedUrl} isFullscreen={isFullscreen} />
242+
);
243+
case "jsonobject":
244+
case "jsonarray":
245+
return <JsonVisualizer signedUrl={signedUrl} name={name} />;
246+
default:
247+
return null;
248+
}
249+
};
250+
251+
export default ArtifactVisualizer;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Paragraph } from "@/components/ui/typography";
2+
3+
import TableVisualizer from "./TableVisualizer";
4+
import { renderFetchState, useArtifactFetch } from "./useArtifactFetch";
5+
import { type ArtifactTableData, parseCsv } from "./utils";
6+
7+
type CsvVisualizerProps = {
8+
delimiter: string;
9+
isFullscreen: boolean;
10+
} & (
11+
| { value: string; signedUrl?: never }
12+
| { value?: never; signedUrl: string }
13+
);
14+
15+
const CsvVisualizer = ({
16+
delimiter,
17+
isFullscreen,
18+
value,
19+
signedUrl,
20+
}: CsvVisualizerProps) => {
21+
const { data, isLoading, error } = useArtifactFetch<ArtifactTableData>(
22+
"csv",
23+
signedUrl,
24+
async (r) => parseCsv(await r.text(), delimiter),
25+
);
26+
27+
const fetchState = renderFetchState(isLoading, error);
28+
if (fetchState) return fetchState;
29+
30+
const parsed = value ? parseCsv(value, delimiter) : data;
31+
32+
if (!parsed || parsed.headers.length === 0) {
33+
return (
34+
<Paragraph tone="subdued" size="xs">
35+
No data
36+
</Paragraph>
37+
);
38+
}
39+
40+
return (
41+
<TableVisualizer
42+
data={parsed}
43+
signedUrl={signedUrl}
44+
isFullscreen={isFullscreen}
45+
/>
46+
);
47+
};
48+
49+
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="h-full rounded-md object-contain" />
8+
);
9+
10+
export default ImageVisualizer;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import IOCodeViewer from "../IOCodeViewer";
2+
import { renderFetchState, useArtifactFetch } from "./useArtifactFetch";
3+
4+
type JsonVisualizerProps = {
5+
name: string;
6+
} & (
7+
| { value: string; signedUrl?: never }
8+
| { value?: never; signedUrl: string }
9+
);
10+
11+
const JsonVisualizer = ({ name, value, signedUrl }: JsonVisualizerProps) => {
12+
const { data, isLoading, error } = useArtifactFetch("json", signedUrl, (r) =>
13+
r.text(),
14+
);
15+
16+
const fetchState = renderFetchState(isLoading, error);
17+
if (fetchState) return fetchState;
18+
19+
const content = value ?? data;
20+
if (!content) return null;
21+
22+
return <IOCodeViewer title={name} value={content} />;
23+
};
24+
25+
export default JsonVisualizer;

0 commit comments

Comments
 (0)