Skip to content

Commit 1b7cfd0

Browse files
committed
feat(image registry): detail page
Signed-off-by: Kim Anh Nguyen <4783194+kimanhou@users.noreply.github.com>
1 parent b222b63 commit 1b7cfd0

13 files changed

Lines changed: 822 additions & 2 deletions

src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ const StoragePoolDetail = lazy(
9595
const CreateStorageVolume = lazy(
9696
async () => import("pages/storage/forms/CreateStorageVolume"),
9797
);
98+
const ImageRegistryDetail = lazy(
99+
async () => import("pages/images/ImageRegistryDetail"),
100+
);
98101
const StorageVolumeDetail = lazy(
99102
async () => import("pages/storage/StorageVolumeDetail"),
100103
);
@@ -509,6 +512,10 @@ const App: FC = () => {
509512
path={`${ROOT_PATH}/ui/project/:project/local-images`}
510513
element={<ProtectedRoute outlet={<LocalImageList />} />}
511514
/>
515+
<Route
516+
path={`${ROOT_PATH}/ui/image-registry/:name`}
517+
element={<ProtectedRoute outlet={<ImageRegistryDetail />} />}
518+
/>
512519
<Route
513520
path={`${ROOT_PATH}/ui/server`}
514521
element={<ProtectedRoute outlet={<Server />} />}

src/api/image-registries.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { handleResponse } from "util/helpers";
2+
import type { LxdImage, LxdImageRegistry } from "types/image";
3+
import type { LxdApiResponse } from "types/apiResponse";
4+
import { addEntitlements } from "util/entitlements/api";
5+
import { ROOT_PATH } from "util/rootPath";
6+
7+
const imageRegistryEntitlements = ["can_edit", "can_delete"];
8+
9+
export const fetchImageRegistries = async (
10+
isFineGrained: boolean | null,
11+
): Promise<LxdImageRegistry[]> => {
12+
const params = new URLSearchParams();
13+
params.set("recursion", "1");
14+
addEntitlements(params, isFineGrained, imageRegistryEntitlements);
15+
return fetch(`${ROOT_PATH}/1.0/image-registries?${params.toString()}`)
16+
.then(handleResponse)
17+
.then((data: LxdApiResponse<LxdImageRegistry[]>) => {
18+
return data.metadata;
19+
});
20+
};
21+
22+
export const fetchImageRegistry = async (
23+
name: string,
24+
isFineGrained: boolean | null,
25+
): Promise<LxdImageRegistry> => {
26+
const params = new URLSearchParams();
27+
params.set("recursion", "1");
28+
addEntitlements(params, isFineGrained, imageRegistryEntitlements);
29+
return fetch(
30+
`${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(name)}?${params.toString()}`,
31+
)
32+
.then(handleResponse)
33+
.then((data: LxdApiResponse<LxdImageRegistry>) => {
34+
return data.metadata;
35+
});
36+
};
37+
38+
export const fetchImageRegistryImages = async (
39+
name: string,
40+
): Promise<LxdImage[]> => {
41+
return fetch(
42+
`${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(name)}/images`,
43+
)
44+
.then(handleResponse)
45+
.then((data: LxdApiResponse<LxdImage[]>) => {
46+
return data.metadata;
47+
});
48+
};
49+
50+
export const renameImageRegistry = async (
51+
oldName: string,
52+
newName: string,
53+
): Promise<void> => {
54+
await fetch(
55+
`${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(oldName)}`,
56+
{
57+
method: "POST",
58+
headers: {
59+
"Content-Type": "application/json",
60+
},
61+
body: JSON.stringify({ name: newName }),
62+
},
63+
).then(handleResponse);
64+
};
65+
66+
export const deleteImageRegistry = async (name: string): Promise<void> => {
67+
await fetch(`${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(name)}`, {
68+
method: "DELETE",
69+
}).then(handleResponse);
70+
};

src/components/ResourceIcon.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type ResourceIconType =
1919
| "volume"
2020
| "iso-volume"
2121
| "image"
22+
| "image-registry"
2223
| "metric"
2324
| "oidc-identity"
2425
| "placement-group"
@@ -46,6 +47,7 @@ const resourceIcons: Record<ResourceIconType, string> = {
4647
volume: "storage-volume",
4748
"iso-volume": "iso",
4849
image: "image",
50+
"image-registry": "repository",
4951
"oidc-identity": "user",
5052
certificate: "certificate",
5153
"auth-group": "user-group",

src/context/useImageRegistries.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { queryKeys } from "util/queryKeys";
3+
import { useAuth } from "./auth";
4+
import {
5+
fetchImageRegistries,
6+
fetchImageRegistry,
7+
fetchImageRegistryImages,
8+
} from "api/image-registries";
9+
10+
export const useImageRegistries = (isEnabled = true) => {
11+
const { isFineGrained } = useAuth();
12+
return useQuery({
13+
queryKey: [queryKeys.imageRegistries],
14+
queryFn: async () => fetchImageRegistries(isFineGrained),
15+
enabled: isEnabled && isFineGrained !== null,
16+
});
17+
};
18+
19+
export const useImageRegistry = (name: string, isEnabled = true) => {
20+
const { isFineGrained } = useAuth();
21+
return useQuery({
22+
queryKey: [queryKeys.imageRegistries, name],
23+
queryFn: async () => fetchImageRegistry(name, isFineGrained),
24+
enabled: isEnabled && isFineGrained !== null,
25+
});
26+
};
27+
28+
export const useImageRegistryImages = (imageRegistryName: string) => {
29+
return useQuery({
30+
queryKey: [queryKeys.imageRegistries, imageRegistryName, "images"],
31+
queryFn: async () => fetchImageRegistryImages(imageRegistryName),
32+
enabled: !!imageRegistryName,
33+
});
34+
};
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { FC } from "react";
2+
import { useParams } from "react-router-dom";
3+
import { Col, CustomLayout, Row, Spinner } from "@canonical/react-components";
4+
import NotFound from "components/NotFound";
5+
import {
6+
useImageRegistry,
7+
useImageRegistryImages,
8+
} from "context/useImageRegistries";
9+
import ImagesTable from "pages/images/ImagesTable";
10+
import ImageRegistryDetailHeader from "pages/images/ImageRegistryDetailHeader";
11+
12+
const ImageRegistryDetail: FC = () => {
13+
const { name } = useParams<{
14+
name: string;
15+
}>();
16+
17+
if (!name) {
18+
return <>Missing name</>;
19+
}
20+
21+
const { data: imageRegistry, error, isLoading } = useImageRegistry(name);
22+
const { data: images, isLoading: isImagesLoading } =
23+
useImageRegistryImages(name);
24+
25+
if (isLoading) {
26+
return <Spinner className="u-loader" text="Loading..." isMainComponent />;
27+
} else if (!imageRegistry) {
28+
return (
29+
<NotFound
30+
entityType="image-registry"
31+
entityName={name}
32+
errorMessage={error?.message}
33+
/>
34+
);
35+
}
36+
37+
return (
38+
<CustomLayout
39+
header={
40+
<ImageRegistryDetailHeader
41+
imageRegistry={imageRegistry}
42+
project="default"
43+
/>
44+
}
45+
contentClassName="detail-page"
46+
>
47+
<Row className="section">
48+
<Col size={3}>
49+
<h2 className="p-heading--5">General</h2>
50+
</Col>
51+
<Col size={7}>
52+
<table>
53+
<tbody>
54+
<tr>
55+
<th className="u-text--muted">Name</th>
56+
<td>{imageRegistry.name}</td>
57+
</tr>
58+
<tr>
59+
<th className="u-text--muted">Description</th>
60+
<td>{imageRegistry.description ?? "-"}</td>
61+
</tr>
62+
<tr>
63+
<th className="u-text--muted">Protocol</th>
64+
<td>{imageRegistry.protocol}</td>
65+
</tr>
66+
<tr>
67+
<th className="u-text--muted">Built-in</th>
68+
<td>{imageRegistry.builtin ? "Yes" : "No"}</td>
69+
</tr>
70+
</tbody>
71+
</table>
72+
</Col>
73+
</Row>
74+
75+
<Row className="section">
76+
<Col size={3}>
77+
<h2 className="p-heading--5">Configuration</h2>
78+
</Col>
79+
<Col size={7}>
80+
<table>
81+
<tbody>
82+
<tr>
83+
<th className="u-text--muted">URL</th>
84+
<td>{imageRegistry.config?.url ?? "-"}</td>
85+
</tr>
86+
<tr>
87+
<th className="u-text--muted">Public</th>
88+
<td>{imageRegistry.config?.public ?? "-"}</td>
89+
</tr>
90+
<tr>
91+
<th className="u-text--muted">Cluster</th>
92+
<td>{imageRegistry.config?.cluster ?? "-"}</td>
93+
</tr>
94+
<tr>
95+
<th className="u-text--muted">Source project</th>
96+
<td>{imageRegistry.config?.source_project ?? "-"}</td>
97+
</tr>
98+
</tbody>
99+
</table>
100+
</Col>
101+
</Row>
102+
103+
<Row className="section">
104+
<Col size={3}>
105+
<h2 className="p-heading--5">Images</h2>
106+
</Col>
107+
<Col size={9}>
108+
{isImagesLoading ? (
109+
<Spinner text="Loading images..." />
110+
) : (
111+
<ImagesTable images={images ?? []} />
112+
)}
113+
</Col>
114+
</Row>
115+
</CustomLayout>
116+
);
117+
};
118+
119+
export default ImageRegistryDetail;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useState, type FC } from "react";
2+
import { Link, useNavigate } from "react-router-dom";
3+
import { useNotify, useToastNotification } from "@canonical/react-components";
4+
import { renameImageRegistry } from "api/image-registries";
5+
import RenameHeader from "components/RenameHeader";
6+
import type { RenameHeaderValues } from "components/RenameHeader";
7+
import { useFormik } from "formik";
8+
import ImageRegistryRichChip from "pages/images/ImageRegistryRichChip";
9+
import DeleteImageRegistryBtn from "pages/images/actions/DeleteImageRegistryBtn";
10+
import type { LxdImageRegistry } from "types/image";
11+
import { useImageRegistriesEntitlements } from "util/entitlements/images";
12+
import { checkDuplicateName } from "util/helpers";
13+
import { ROOT_PATH } from "util/rootPath";
14+
import * as Yup from "yup";
15+
16+
interface Props {
17+
imageRegistry: LxdImageRegistry;
18+
project: string;
19+
}
20+
21+
const ImageRegistryDetailHeader: FC<Props> = ({ imageRegistry, project }) => {
22+
const navigate = useNavigate();
23+
const notify = useNotify();
24+
const toastNotify = useToastNotification();
25+
const { canEditImageRegistry } = useImageRegistriesEntitlements();
26+
const controllerState = useState<AbortController | null>(null);
27+
28+
const RenameSchema = Yup.object().shape({
29+
name: Yup.string()
30+
.test(
31+
"deduplicate",
32+
"An image registry with this name already exists",
33+
async (value) =>
34+
checkDuplicateName(
35+
value,
36+
project,
37+
controllerState,
38+
"image-registries",
39+
),
40+
)
41+
.required("Image registry name is required"),
42+
});
43+
44+
const getDisabledReason = () => {
45+
if (!canEditImageRegistry(imageRegistry)) {
46+
return "You do not have permission to rename this image registry";
47+
}
48+
49+
if (!imageRegistry) {
50+
return "Invalid Image Registry: Cannot be renamed";
51+
}
52+
53+
return undefined;
54+
};
55+
56+
const onSuccess = (imageRegistryName: string) => {
57+
const url = `${ROOT_PATH}/ui/image-registry/${encodeURIComponent(imageRegistryName)}`;
58+
navigate(url);
59+
console.log("Navigating to", url);
60+
toastNotify.success(
61+
<>
62+
Image registry <strong>{imageRegistry.name}</strong> renamed to{" "}
63+
<ImageRegistryRichChip imageRegistryName={imageRegistryName} />
64+
</>,
65+
);
66+
};
67+
68+
const onFailure = (imageRegistryName: string, e: unknown) => {
69+
notify.failure(`Renaming of image registry ${imageRegistryName} failed`, e);
70+
};
71+
72+
const formik = useFormik<RenameHeaderValues>({
73+
initialValues: {
74+
name: imageRegistry.name,
75+
isRenaming: false,
76+
},
77+
validationSchema: RenameSchema,
78+
onSubmit: (values) => {
79+
if (imageRegistry.name === values.name) {
80+
formik.setFieldValue("isRenaming", false);
81+
formik.setSubmitting(false);
82+
return;
83+
}
84+
renameImageRegistry(imageRegistry.name, values.name)
85+
.then(() => {
86+
onSuccess(values.name);
87+
formik.setFieldValue("isRenaming", false);
88+
})
89+
.catch((e) => {
90+
onFailure(values.name, e);
91+
})
92+
.finally(() => {
93+
formik.setSubmitting(false);
94+
});
95+
},
96+
});
97+
98+
return (
99+
<RenameHeader
100+
name={imageRegistry.name}
101+
parentItems={[
102+
<Link
103+
to={`${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/image-registries`}
104+
key={1}
105+
>
106+
Image registries
107+
</Link>,
108+
]}
109+
controls={[
110+
<DeleteImageRegistryBtn
111+
key="delete"
112+
imageRegistry={imageRegistry}
113+
shouldExpand
114+
/>,
115+
]}
116+
isLoaded={Boolean(imageRegistry.name)}
117+
formik={formik}
118+
renameDisabledReason={getDisabledReason()}
119+
/>
120+
);
121+
};
122+
123+
export default ImageRegistryDetailHeader;

0 commit comments

Comments
 (0)