Skip to content

Commit 04a412e

Browse files
committed
fix(artifacts): render images natively and add filename to downloads
- Render image content types with img tag instead of raw text - Use isFetching instead of isLoading to fix spinner for images - Add artifactDownloadURL helper with filename query param - Update all download URL call sites to include filename
1 parent cc91f6c commit 04a412e

14 files changed

Lines changed: 722 additions & 9 deletions

File tree

src/App.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import React, { ReactNode, useEffect, useState, useMemo } from "react";
77
import { IconType } from "react-icons";
88
import { AiFillHeart } from "react-icons/ai";
99
import { BsLink, BsToggles } from "react-icons/bs";
10-
import { FaBell, FaCrosshairs, FaTasks } from "react-icons/fa";
10+
import { FaArchive, FaBell, FaCrosshairs, FaTasks } from "react-icons/fa";
1111
import { HiUser } from "react-icons/hi";
1212
import { ImLifebuoy } from "react-icons/im";
1313
import {
@@ -46,6 +46,7 @@ import { IncidentPageContextProvider } from "./context/IncidentPageContext";
4646
import { UserAccessStateContextProvider } from "./context/UserAccessContext/UserAccessContext";
4747
import { tables } from "./context/UserAccessContext/permissions";
4848

49+
import { ArtifactsPage } from "./pages/Settings/ArtifactsPage";
4950
import { PermissionsPage } from "./pages/Settings/PermissionsPage";
5051
import { PermissionsSubjectsPage } from "./pages/Settings/PermissionsSubjectsPage";
5152
import McpOverviewPage from "./pages/Settings/mcp/McpOverviewPage";
@@ -391,6 +392,13 @@ const settingsNav: SettingsNavigationItems = {
391392
icon: AdjustmentsIcon,
392393
checkPath: false,
393394
submenu: [
395+
{
396+
name: "Artifacts",
397+
href: "/settings/artifacts",
398+
icon: FaArchive,
399+
featureName: features["settings.artifacts"],
400+
resourceName: tables.artifacts
401+
},
394402
{
395403
name: "Connections",
396404
href: "/settings/connections",
@@ -737,6 +745,15 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) {
737745
</Route>
738746

739747
<Route path="settings" element={sidebar}>
748+
<Route
749+
path="artifacts"
750+
element={withAuthorizationAccessCheck(
751+
<ArtifactsPage />,
752+
tables.artifacts,
753+
"read",
754+
true
755+
)}
756+
/>
740757
<Route
741758
path="connections"
742759
element={withAuthorizationAccessCheck(

src/api/services/artifacts.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,67 @@
1-
import { ArtifactAPI } from "../axios";
1+
import { SortingState } from "@tanstack/react-table";
2+
import { Artifact, ArtifactSummary } from "../types/artifacts";
3+
import { ArtifactAPI, IncidentCommander } from "../axios";
4+
import { resolvePostGrestRequestWithPagination } from "../resolve";
5+
6+
export function artifactDownloadURL(artifactId: string, filename?: string) {
7+
const params = filename ? `?filename=${encodeURIComponent(filename)}` : "";
8+
return `/api/artifacts/download/${artifactId}${params}`;
9+
}
210

311
export async function downloadArtifact(artifactId: string) {
412
const response = await ArtifactAPI.get(`/download/${artifactId}`, {
513
responseType: "text"
614
});
715
return response.data;
816
}
17+
18+
export async function fetchArtifacts({
19+
pageIndex = 0,
20+
pageSize = 50,
21+
sortBy,
22+
contentType,
23+
filenameSearch
24+
}: {
25+
pageIndex?: number;
26+
pageSize?: number;
27+
sortBy?: SortingState;
28+
contentType?: string;
29+
filenameSearch?: string;
30+
}) {
31+
const query = new URLSearchParams({
32+
select:
33+
"*,playbook_run_action:playbook_run_actions(id,name,playbook_run_id),config_change:config_changes(id,config_id,change_type)"
34+
});
35+
36+
query.set("limit", pageSize.toString());
37+
query.set("offset", (pageIndex * pageSize).toString());
38+
39+
if (sortBy && sortBy.length > 0) {
40+
query.set("order", `${sortBy[0].id}.${sortBy[0].desc ? "desc" : "asc"}`);
41+
} else {
42+
query.set("order", "created_at.desc");
43+
}
44+
45+
if (contentType && contentType !== "all") {
46+
query.set("content_type", `eq.${contentType}`);
47+
}
48+
49+
if (filenameSearch) {
50+
query.set("filename", `ilike.*${filenameSearch}*`);
51+
}
52+
53+
return resolvePostGrestRequestWithPagination(
54+
IncidentCommander.get<Artifact[]>(`/artifacts?${query.toString()}`, {
55+
headers: {
56+
Prefer: "count=exact"
57+
}
58+
})
59+
);
60+
}
61+
62+
export async function fetchArtifactSummary() {
63+
const res = await IncidentCommander.get<ArtifactSummary[]>(
64+
"/artifact_summary?select=*"
65+
);
66+
return res.data ?? [];
67+
}

src/api/services/configs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export const getConfigListFilteredByType = (types: string[]) => {
382382

383383
export const getConfigChangeById = async (id: string) => {
384384
const res = await ConfigDB.get<ConfigChange[] | null>(
385-
`/config_changes?id=eq.${id}&select=id,config_id,change_type,created_at,external_created_by,source,diff,details,patches,created_by,config:configs(id,name,type,config_class)`
385+
`/config_changes?id=eq.${id}&select=id,config_id,change_type,created_at,external_created_by,source,diff,details,patches,created_by,config:configs(id,name,type,config_class),artifacts:artifacts(*)::jsonb`
386386
);
387387
return res.data?.[0] ?? null;
388388
};

src/api/types/artifacts.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export type Artifact = {
2+
id: string;
3+
filename: string;
4+
path: string;
5+
content_type: string;
6+
checksum: string;
7+
size: number;
8+
playbook_run_action_id?: string;
9+
config_change_id?: string;
10+
connection_id?: string;
11+
created_at: string;
12+
// PostgREST embedded joins
13+
playbook_run_action?: { id: string; name: string; playbook_run_id: string };
14+
config_change?: { id: string; config_id: string; change_type: string };
15+
};
16+
17+
export type ArtifactSummary = {
18+
content_type: string;
19+
storage: "inline" | "external";
20+
connection_id: string | null;
21+
connection_name: string | null;
22+
connection_type: string | null;
23+
total_count: number;
24+
total_size: number;
25+
};

src/api/types/configs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Agent, Avatar, CreatedAt, Timestamped } from "../traits";
22
import { HealthCheckSummary } from "./health";
3+
import { PlaybookArtifact } from "./playbooks";
34
import { Property } from "./topology";
45

56
export interface ConfigChange extends CreatedAt {
@@ -24,6 +25,7 @@ export interface ConfigChange extends CreatedAt {
2425
first_observed?: string;
2526
count?: number;
2627
inserted_at?: string;
28+
artifacts?: PlaybookArtifact[];
2729
}
2830

2931
export interface Change {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { Artifact } from "@flanksource-ui/api/types/artifacts";
2+
import {
3+
artifactDownloadURL,
4+
downloadArtifact
5+
} from "@flanksource-ui/api/services/artifacts";
6+
import { formatBytes } from "@flanksource-ui/utils/common";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle
13+
} from "@flanksource-ui/components/ui/dialog";
14+
import { Button } from "@flanksource-ui/ui/Buttons/Button";
15+
import CodeBlock from "@flanksource-ui/ui/Code/CodeBlock";
16+
import { darkTheme } from "@flanksource-ui/ui/Code/JSONViewerTheme";
17+
import { JSONViewer } from "@flanksource-ui/ui/Code/JSONViewer";
18+
import { DisplayMarkdown } from "@flanksource-ui/components/Utils/Markdown";
19+
import { useQuery } from "@tanstack/react-query";
20+
import { IoMdDownload } from "react-icons/io";
21+
22+
type ArtifactPreviewModalProps = {
23+
artifact: Artifact | undefined;
24+
onClose: () => void;
25+
};
26+
27+
const MAX_PREVIEW_SIZE = 50 * 1024 * 1024; // 50MB
28+
29+
function isImageContentType(contentType: string) {
30+
return contentType.startsWith("image/");
31+
}
32+
33+
export function ArtifactPreviewModal({
34+
artifact,
35+
onClose
36+
}: ArtifactPreviewModalProps) {
37+
const isSmallFile = artifact ? artifact.size < MAX_PREVIEW_SIZE : false;
38+
39+
const isImage = artifact ? isImageContentType(artifact.content_type) : false;
40+
41+
const { data: content, isFetching: isLoading } = useQuery({
42+
queryKey: ["artifact", artifact?.id],
43+
queryFn: () => downloadArtifact(artifact!.id),
44+
enabled: !!artifact && isSmallFile && !isImage,
45+
staleTime: Infinity,
46+
cacheTime: 1000 * 60 * 60 * 24
47+
});
48+
49+
const downloadURL = artifact
50+
? artifactDownloadURL(artifact.id, artifact.filename)
51+
: undefined;
52+
53+
return (
54+
<Dialog open={!!artifact} onOpenChange={(open) => !open && onClose()}>
55+
<DialogContent className="max-h-[80vh] max-w-4xl overflow-hidden">
56+
<DialogHeader>
57+
<DialogTitle className="flex items-center gap-2">
58+
<span>{artifact?.filename}</span>
59+
{artifact && (
60+
<span className="text-sm font-normal text-gray-500">
61+
({formatBytes(artifact.size)})
62+
</span>
63+
)}
64+
</DialogTitle>
65+
</DialogHeader>
66+
67+
<div className="max-h-[60vh] overflow-auto">
68+
{artifact && !isSmallFile && (
69+
<div className="py-8 text-center text-gray-500">
70+
<p className="mb-4">
71+
File is too large to preview. Maximum file size is 50MB.
72+
</p>
73+
<a href={downloadURL} target="_blank" rel="noreferrer">
74+
<Button text="Download" icon={<IoMdDownload />} />
75+
</a>
76+
</div>
77+
)}
78+
79+
{isLoading && (
80+
<div className="flex items-center justify-center py-8">
81+
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-500" />
82+
<span className="ml-2">Loading...</span>
83+
</div>
84+
)}
85+
86+
{isImage && artifact && downloadURL && (
87+
<img
88+
src={downloadURL}
89+
alt={artifact.filename}
90+
className="max-h-[60vh] max-w-full object-contain"
91+
/>
92+
)}
93+
94+
{content &&
95+
artifact &&
96+
!isImage &&
97+
renderContent(artifact.content_type, content)}
98+
</div>
99+
100+
<DialogFooter className="pt-2">
101+
{downloadURL && (
102+
<a href={downloadURL} target="_blank" rel="noreferrer">
103+
<Button
104+
text="Download"
105+
icon={<IoMdDownload />}
106+
className="btn-secondary"
107+
/>
108+
</a>
109+
)}
110+
<Button text="Close" onClick={onClose} className="btn-primary" />
111+
</DialogFooter>
112+
</DialogContent>
113+
</Dialog>
114+
);
115+
}
116+
117+
function renderContent(contentType: string, content: string) {
118+
switch (contentType) {
119+
case "text/x-shellscript":
120+
return (
121+
<CodeBlock
122+
code={content}
123+
language="bash"
124+
className="bg-black text-white"
125+
codeBlockClassName="whitespace-pre"
126+
theme={darkTheme}
127+
/>
128+
);
129+
130+
case "application/sql":
131+
return (
132+
<CodeBlock
133+
code={content}
134+
language="sql"
135+
className="bg-black text-white"
136+
codeBlockClassName="whitespace-pre"
137+
theme={darkTheme}
138+
/>
139+
);
140+
141+
case "text/markdown":
142+
case "markdown":
143+
return <DisplayMarkdown md={content} />;
144+
145+
case "application/yaml":
146+
case "application/json":
147+
return (
148+
<pre>
149+
<JSONViewer format="json" code={content} convertToYaml />
150+
</pre>
151+
);
152+
153+
case "text/plain":
154+
default:
155+
return (
156+
<pre className="whitespace-pre-wrap bg-gray-50 p-4 text-sm">
157+
{content}
158+
</pre>
159+
);
160+
}
161+
}

0 commit comments

Comments
 (0)