From 8dba4799f398e2b715fdc77f59029b5d860c13ef Mon Sep 17 00:00:00 2001 From: Omar Elkashef Date: Wed, 1 Apr 2026 15:45:59 +0200 Subject: [PATCH 1/2] feat: image registries list page Signed-off-by: Omar Elkashef --- tests/helpers/images.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/helpers/images.ts b/tests/helpers/images.ts index f1627c7b386..2bb27a8b3e9 100644 --- a/tests/helpers/images.ts +++ b/tests/helpers/images.ts @@ -40,3 +40,13 @@ export const deleteImage = async (page: Page, imageIdentifier: string) => { await page.waitForSelector(`text=Image ${imageName} deleted.`); }; + +export const visitImageRegistries = async (page: Page, project: string) => { + await gotoURL(page, `/ui/project/${project}/image-registries`); + await expect(page.getByTitle("Create image registry")).toBeVisible(); +}; + +export const selectAllRegistries = async (page: Page) => { + await page.getByLabel("multiselect rows").first().click(); + await page.getByRole("menuitem", { name: "Select all" }).click(); +}; From f04dcc2ef956e681cce2d8863296176ee0f44597 Mon Sep 17 00:00:00 2001 From: David Edler Date: Mon, 13 Apr 2026 13:57:28 +0200 Subject: [PATCH 2/2] feat(registries) use images from registries in image selector WD-25458 Signed-off-by: David Edler --- src/api/image-registries.tsx | 18 ++++- src/context/useImages.tsx | 26 +++++- src/pages/images/ImageSelector.tsx | 125 ++++++++++++----------------- src/types/image.d.ts | 10 +++ src/util/imageLegacy.ts | 63 +++++++++++++++ src/util/imageRegistry.ts | 99 +++++++++++++++++++++++ src/util/images.tsx | 4 +- tests/helpers/images.ts | 10 --- 8 files changed, 266 insertions(+), 89 deletions(-) create mode 100644 src/util/imageLegacy.ts create mode 100644 src/util/imageRegistry.ts diff --git a/src/api/image-registries.tsx b/src/api/image-registries.tsx index 8236f46eab3..9f41a53bfbd 100644 --- a/src/api/image-registries.tsx +++ b/src/api/image-registries.tsx @@ -1,5 +1,5 @@ import { handleResponse } from "util/helpers"; -import type { LxdImageRegistry } from "types/image"; +import type { LxdImage, LxdImageRegistry } from "types/image"; import type { LxdApiResponse } from "types/apiResponse"; import { addEntitlements } from "util/entitlements/api"; import { ROOT_PATH } from "util/rootPath"; @@ -34,3 +34,19 @@ export const fetchImageRegistry = async ( return data.metadata; }); }; + +export const fetchRegistryImages = async ( + name: string, + isFineGrained: boolean | null, +): Promise => { + const params = new URLSearchParams(); + params.set("recursion", "1"); + addEntitlements(params, isFineGrained, imageRegistryEntitlements); + return fetch( + `${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(name)}/images?${params.toString()}`, + ) + .then(handleResponse) + .then((data: LxdApiResponse) => { + return data.metadata; + }); +}; diff --git a/src/context/useImages.tsx b/src/context/useImages.tsx index 98714900d84..08fe1a745a2 100644 --- a/src/context/useImages.tsx +++ b/src/context/useImages.tsx @@ -6,7 +6,10 @@ import { fetchImagesInAllProjects, fetchLocalImagesInProject, } from "api/images"; -import type { LxdImage } from "types/image"; +import type { LxdImage, RemoteImagesResult } from "types/image"; +import { loadRemoteImagesLegacy } from "util/imageLegacy"; +import { loadImagesFromAllRegistries } from "util/imageRegistry"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; export const useLocalImagesInProject = ( project: string, @@ -30,3 +33,24 @@ export const useImagesInAllProjects = ( enabled: (enabled ?? true) && isFineGrained !== null, }); }; + +export const useImages = (): UseQueryResult => { + const { isFineGrained } = useAuth(); + const { hasImageRegistries } = useSupportedFeatures(); + + const legacyResult = useQuery({ + queryKey: [queryKeys.images, "selector", hasImageRegistries], + queryFn: async () => loadRemoteImagesLegacy(), + retry: false, // avoid retry to ease experience in air gapped deployments + enabled: !hasImageRegistries, + }); + + const registryResult = useQuery({ + queryKey: [queryKeys.images, "selector", hasImageRegistries], + queryFn: async () => loadImagesFromAllRegistries(!!isFineGrained), + retry: false, // avoid retry to ease experience in air gapped deployments + enabled: hasImageRegistries && isFineGrained !== null, + }); + + return hasImageRegistries ? registryResult : legacyResult; +}; diff --git a/src/pages/images/ImageSelector.tsx b/src/pages/images/ImageSelector.tsx index 66a4f28cee9..8ccabd7f178 100644 --- a/src/pages/images/ImageSelector.tsx +++ b/src/pages/images/ImageSelector.tsx @@ -6,19 +6,17 @@ import { Col, MainTable, Modal, + Notification, Row, ScrollableTable, SearchBox, Select, Spinner, } from "@canonical/react-components"; -import type { LxdImageType, RemoteImage, RemoteImageList } from "types/image"; -import { capitalizeFirstLetter, handleResponse } from "util/helpers"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; +import type { LxdImageType, RemoteImage } from "types/image"; +import { capitalizeFirstLetter } from "util/helpers"; import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; import { - byLtsFirst, byOSRelease, localLxdToRemoteImage, isContainerOnlyImage, @@ -30,24 +28,18 @@ import { getArchitectureAliases } from "util/architectures"; import { instanceCreationTypes } from "util/instanceOptions"; import { useSettings } from "context/useSettings"; import { useParams } from "react-router-dom"; -import { useLocalImagesInProject } from "context/useImages"; +import { useImages, useLocalImagesInProject } from "context/useImages"; +import { + canonicalServer, + imagesLxdServer, + minimalServer, +} from "util/imageLegacy"; interface Props { onSelect: (image: RemoteImage, type?: LxdImageType) => void; onClose: () => void; } -const canonicalJson = - "https://cloud-images.ubuntu.com/releases/streams/v1/com.ubuntu.cloud:released:download.json"; -const canonicalServer = "https://cloud-images.ubuntu.com/releases"; - -const minimalJson = - "https://cloud-images.ubuntu.com/minimal/releases/streams/v1/com.ubuntu.cloud:released:download.json"; -const minimalServer = "https://cloud-images.ubuntu.com/minimal/releases/"; - -const imagesLxdJson = "https://images.lxd.canonical.com/streams/v1/images.json"; -const imagesLxdServer = "https://images.lxd.canonical.com/"; - const ANY = "any"; const CONTAINER = "container"; const VM = "virtual-machine"; @@ -60,56 +52,23 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { const [type, setType] = useState(undefined); const [variant, setVariant] = useState(ANY); const [hideRemote, setHideRemote] = useState(false); + const [error, setError] = useState(""); + const [hideError, setHideError] = useState(false); const { project } = useParams<{ project: string }>(); - const loadImages = async ( - file: string, - server: string, - ): Promise => { - return new Promise((resolve, reject) => { - fetch(file) - .then(handleResponse) - .then((data: RemoteImageList) => { - const images = Object.entries(data.products).map((product) => { - const { os, ...image } = product[1]; - const formattedOs = capitalizeFirstLetter(os); - return { ...image, os: formattedOs, server: server }; - }); - resolve(images); - }) - .catch(reject); - }); - }; - const { data: settings, isLoading: isSettingsLoading } = useSettings(); - - const { data: canonicalImages = [], isLoading: isCiLoading } = useQuery({ - queryKey: [queryKeys.images, canonicalServer], - queryFn: async () => loadImages(canonicalJson, canonicalServer), - retry: false, // avoid retry to ease experience in airgapped deployments - }); - - const { data: minimalImages = [], isLoading: isMinimalLoading } = useQuery({ - queryKey: [queryKeys.images, minimalServer], - queryFn: async () => loadImages(minimalJson, minimalServer), - retry: false, // avoid retry to ease experience in airgapped deployments - }); - - const { data: imagesLxdImages = [], isLoading: isImagesLxdLoading } = - useQuery({ - queryKey: [queryKeys.images, imagesLxdServer], - queryFn: async () => loadImages(imagesLxdJson, imagesLxdServer), - retry: false, // avoid retry to ease experience in airgapped deployments - }); - + const { data: remoteImages, isLoading: isRemoteImagesLoading } = useImages(); const { data: localImages = [], isLoading: isLocalImageLoading } = useLocalImagesInProject(project ?? "default"); - const isRemoteImagesLoading = - !hideRemote && (isCiLoading || isMinimalLoading || isImagesLxdLoading); + if (remoteImages?.error && remoteImages?.error !== error) { + setError(remoteImages?.error ?? ""); + setHideError(false); + } + const isWaitingForRemoteImages = !hideRemote && isRemoteImagesLoading; const isLoading = - isRemoteImagesLoading || isLocalImageLoading || isSettingsLoading; + isWaitingForRemoteImages || isLocalImageLoading || isSettingsLoading; const archSupported = getArchitectureAliases( settings?.environment?.architectures ?? [], @@ -119,9 +78,7 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { : localImages .map(localLxdToRemoteImage) .sort(byOSRelease) - .concat([...canonicalImages].reverse().sort(byLtsFirst)) - .concat([...minimalImages].reverse().sort(byLtsFirst)) - .concat([...imagesLxdImages]) + .concat(remoteImages?.images ?? []) .filter((image) => archSupported.includes(image.arch)); const archAll = [...new Set(images.map((item) => item.arch))] @@ -231,6 +188,11 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { if (!item.cached && item.created_at) { source = "Local"; } + if (item.registryName) { + source = item.registryBuiltIn + ? item.registryName.split("-").map(capitalizeFirstLetter).join(" ") + : item.registryName; + } if (item.server === canonicalServer) { source = "Ubuntu"; } @@ -280,6 +242,7 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { { className: "u-hide--small u-hide--medium", content: item.aliases.split(",").pop(), + title: item.aliases.split(",").pop(), role: "cell", "aria-label": "Alias", onClick: selectImage, @@ -458,20 +421,32 @@ const ImageSelector: FC = ({ onSelect, onClose }) => {
-
- { - setQuery(value.toLowerCase()); - setOs(""); - setRelease(""); + {error && !hideError ? ( + { + setHideError(true); }} - placeholder="Search an image" - /> -
+ > + {error} + + ) : ( +
+ { + setQuery(value.toLowerCase()); + setOs(""); + setRelease(""); + }} + placeholder="Search an image" + /> +
+ )}
=> { + const canonicalImages = await loadImageJson(canonicalJson, canonicalServer); + const minimalImages = await loadImageJson(minimalJson, minimalServer); + const imagesLxdImages = await loadImageJson(imagesLxdJson, imagesLxdServer); + + const images = [...canonicalImages.images] + .reverse() + .sort(byLtsFirst) + .concat([...minimalImages.images].reverse().sort(byLtsFirst)) + .concat([...imagesLxdImages.images]); + + const errors = [ + canonicalImages.error, + minimalImages.error, + imagesLxdImages.error, + ].filter((e) => e !== ""); + + return { images, error: errors.join(". ") }; +}; + +const loadImageJson = async ( + file: string, + server: string, +): Promise => { + return new Promise((resolve) => { + fetch(file) + .then(handleResponse) + .then((data: RemoteImageList) => { + const images: RemoteImage[] = Object.entries(data.products).map( + (product) => { + const { os, ...image } = product[1]; + const formattedOs = capitalizeFirstLetter(os); + return { ...image, os: formattedOs, server: server }; + }, + ); + resolve({ images, error: "" }); + }) + .catch((e: Error) => { + resolve({ images: [], error: e.message }); + }); + }); +}; diff --git a/src/util/imageRegistry.ts b/src/util/imageRegistry.ts new file mode 100644 index 00000000000..a9702a16bbe --- /dev/null +++ b/src/util/imageRegistry.ts @@ -0,0 +1,99 @@ +import { byLtsFirst, byOSRelease, localLxdToRemoteImage } from "util/images"; +import type { RemoteImage, RemoteImagesResult } from "types/image"; +import { + fetchImageRegistries, + fetchRegistryImages, +} from "api/image-registries"; + +// fetch image registries and images from all configured registries +export const loadImagesFromAllRegistries = async ( + isFineGrained: boolean, +): Promise => { + const registries = await fetchImageRegistries(isFineGrained); + + const imagesByRegistry: Record = {}; + const imageRequests = await Promise.allSettled( + registries.map(async (registry) => { + const registryImages = await fetchRegistryImages( + registry.name, + isFineGrained, + ); + + imagesByRegistry[registry.name] = registryImages + .filter((image) => image.aliases !== null) + .map((image) => { + const item = localLxdToRemoteImage(image); + const ltsAlias = image.aliases.find((a) => a.name === "lts"); + + return { + ...item, + isLts: ltsAlias !== undefined, + registryBuiltIn: registry.builtin, + registryName: registry.name, + server: registry.config?.url, + title: item.os + item.release_title + item.release + item.server, + }; + }) + .sort(byOSRelease); + }), + ); + + // if any image request failed, throw + const errors: string[] = []; + imageRequests.forEach((res) => { + if (res.status !== "fulfilled") { + errors.push(res.reason as string); + } + }); + + const uniqueTitle = ( + item: RemoteImage, + index: number, + self: RemoteImage[], + ) => { + return index === self.findIndex((t) => t.title === item.title); + }; + + const markVmAndContainerImagesAsOne = (item: RemoteImage) => { + delete item.type; + item.versions = { + vm: { + items: { + "disk1.img": { ftype: "disk1.img" }, + }, + }, + }; + }; + + const ubuntuRegistries = [ + "ubuntu", + "ubuntu-minimal", + "ubuntu-daily", + "ubuntu-minimal-daily", + ]; + + ubuntuRegistries.forEach((name) => { + const images = name in imagesByRegistry ? imagesByRegistry[name] : []; + imagesByRegistry[name] = images.filter(uniqueTitle); + imagesByRegistry[name].map(markVmAndContainerImagesAsOne); + imagesByRegistry[name].reverse().sort(byLtsFirst); + }); + + // order for builtin registries + const images: RemoteImage[] = [ + ...imagesByRegistry["ubuntu"], + ...imagesByRegistry["ubuntu-minimal"], + ...("images" in imagesByRegistry ? imagesByRegistry["images"] : []), + ...imagesByRegistry["ubuntu-daily"], + ...imagesByRegistry["ubuntu-minimal-daily"], + ]; + + // add images from custom registries + Object.entries(imagesByRegistry).forEach(([registry, images]) => { + if (!ubuntuRegistries.includes(registry) && registry !== "images") { + images.push(...images); + } + }); + + return { images, error: errors.join(". ") }; +}; diff --git a/src/util/images.tsx b/src/util/images.tsx index 4a5ab061fc9..c6247c6086f 100644 --- a/src/util/images.tsx +++ b/src/util/images.tsx @@ -64,10 +64,10 @@ export const localLxdToRemoteImage = (image: LxdImage): RemoteImage => { }; export const byLtsFirst = (a: RemoteImage, b: RemoteImage): number => { - if (a.aliases.includes("lts")) { + if (a.aliases.includes("lts") || a.isLts) { return -1; } - if (b.aliases.includes("lts")) { + if (b.aliases.includes("lts") || b.isLts) { return 1; } return 0; diff --git a/tests/helpers/images.ts b/tests/helpers/images.ts index 2bb27a8b3e9..f1627c7b386 100644 --- a/tests/helpers/images.ts +++ b/tests/helpers/images.ts @@ -40,13 +40,3 @@ export const deleteImage = async (page: Page, imageIdentifier: string) => { await page.waitForSelector(`text=Image ${imageName} deleted.`); }; - -export const visitImageRegistries = async (page: Page, project: string) => { - await gotoURL(page, `/ui/project/${project}/image-registries`); - await expect(page.getByTitle("Create image registry")).toBeVisible(); -}; - -export const selectAllRegistries = async (page: Page) => { - await page.getByLabel("multiselect rows").first().click(); - await page.getByRole("menuitem", { name: "Select all" }).click(); -};