diff --git a/.evergreen/scripts/add-runtime-environments-credentials.sh b/.evergreen/scripts/add-runtime-environments-credentials.sh
new file mode 100755
index 000000000..8599590bc
--- /dev/null
+++ b/.evergreen/scripts/add-runtime-environments-credentials.sh
@@ -0,0 +1 @@
+echo "{ \"_id\": \"runtime_environments\", \"base_url\": \"${BASE_URL}\", \"api_key\":\"${API_KEY}\" }" >> ../evergreen/testdata/local/admin.json
diff --git a/.evergreen/shared.yml b/.evergreen/shared.yml
index 16783f902..cc3b1ed3d 100644
--- a/.evergreen/shared.yml
+++ b/.evergreen/shared.yml
@@ -1,4 +1,13 @@
functions:
+ add-runtime-environments-credentials:
+ command: subprocess.exec
+ params:
+ working_dir: ui/.evergreen
+ command: bash scripts/add-runtime-environments-credentials.sh
+ env:
+ BASE_URL: ${staging_runtime_environments_base_url}
+ API_KEY: ${staging_runtime_environments_api_key}
+
assume-ec2-role:
command: ec2.assume_role
params:
@@ -259,6 +268,7 @@ tasks:
- name: e2e
commands:
- func: setup-mongodb
+ - func: add-runtime-environments-credentials
- func: run-make-background
vars:
target: local-evergreen
diff --git a/apps/spruce/cypress/integration/image/navigation.ts b/apps/spruce/cypress/integration/image/navigation.ts
index adf39ff1f..559851f04 100644
--- a/apps/spruce/cypress/integration/image/navigation.ts
+++ b/apps/spruce/cypress/integration/image/navigation.ts
@@ -4,4 +4,22 @@ describe("/image/imageId/random redirect route", () => {
cy.location("pathname").should("not.contain", "/random");
cy.location("pathname").should("eq", "/image/imageId/build-information");
});
+
+ it("navigates to the image when clicked", () => {
+ cy.visit("/image/amazon2/build-information");
+ cy.dataCy("images-select").should("be.visible").as("button");
+ cy.get("@button").click();
+ cy.get(".images-select-options").find("li").should("exist");
+ cy.get(".images-select-options").within(() => {
+ cy.get("li").eq(1).click();
+ cy.get("li")
+ .eq(1)
+ .invoke("text")
+ .then((text) => {
+ cy.location("pathname").then((pathname) => {
+ expect(pathname).to.include(text);
+ });
+ });
+ });
+ });
});
diff --git a/apps/spruce/src/components/Content/index.tsx b/apps/spruce/src/components/Content/index.tsx
index bc85ee141..76b867a40 100644
--- a/apps/spruce/src/components/Content/index.tsx
+++ b/apps/spruce/src/components/Content/index.tsx
@@ -5,6 +5,7 @@ import {
UserPatchesRedirect,
WaterfallCommitsRedirect,
} from "components/Redirects";
+import { showImageVisibilityPage } from "constants/featureFlags";
import { redirectRoutes, routes, slugs } from "constants/routes";
import { CommitQueue } from "pages/CommitQueue";
import { Commits } from "pages/Commits";
@@ -27,7 +28,6 @@ import { TaskQueue } from "pages/TaskQueue";
import { UserPatches } from "pages/UserPatches";
import { VariantHistory } from "pages/VariantHistory";
import { VersionPage } from "pages/Version";
-import { isProduction } from "utils/environmentVariables";
import { Layout } from "./Layout";
export const Content: React.FC = () => (
@@ -54,7 +54,7 @@ export const Content: React.FC = () => (
/>
} />
} />
- {!isProduction() && (
+ {showImageVisibilityPage && (
}>
diff --git a/apps/spruce/src/constants/featureFlags.ts b/apps/spruce/src/constants/featureFlags.ts
index cd08f65d4..e0da5faf5 100644
--- a/apps/spruce/src/constants/featureFlags.ts
+++ b/apps/spruce/src/constants/featureFlags.ts
@@ -1,3 +1,4 @@
import { isProduction } from "utils/environmentVariables";
export const showGitHubAccessTokenProject = !isProduction();
+export const showImageVisibilityPage = !isProduction();
diff --git a/apps/spruce/src/gql/generated/types.ts b/apps/spruce/src/gql/generated/types.ts
index 0eb4573ff..6b33341e9 100644
--- a/apps/spruce/src/gql/generated/types.ts
+++ b/apps/spruce/src/gql/generated/types.ts
@@ -6098,6 +6098,10 @@ export type HostsQuery = {
};
};
+export type ImagesQueryVariables = Exact<{ [key: string]: never }>;
+
+export type ImagesQuery = { __typename?: "Query"; images: Array };
+
export type InstanceTypesQueryVariables = Exact<{ [key: string]: never }>;
export type InstanceTypesQuery = {
diff --git a/apps/spruce/src/gql/queries/images.graphql b/apps/spruce/src/gql/queries/images.graphql
new file mode 100644
index 000000000..4c955acca
--- /dev/null
+++ b/apps/spruce/src/gql/queries/images.graphql
@@ -0,0 +1,3 @@
+query Images {
+ images
+}
diff --git a/apps/spruce/src/gql/queries/index.ts b/apps/spruce/src/gql/queries/index.ts
index d661b4933..e52c50d3a 100644
--- a/apps/spruce/src/gql/queries/index.ts
+++ b/apps/spruce/src/gql/queries/index.ts
@@ -24,6 +24,7 @@ import HAS_VERSION from "./has-version.graphql";
import HOST_EVENTS from "./host-events.graphql";
import HOST from "./host.graphql";
import HOSTS from "./hosts.graphql";
+import IMAGES from "./images.graphql";
import INSTANCE_TYPES from "./instance-types.graphql";
import IS_PATCH_CONFIGURED from "./is-patch-configured.graphql";
import JIRA_CUSTOM_CREATED_ISSUES from "./jira-custom-created-issues.graphql";
@@ -109,6 +110,7 @@ export {
HOST_EVENTS,
HOST,
HOSTS,
+ IMAGES,
INSTANCE_TYPES,
IS_PATCH_CONFIGURED,
JIRA_CUSTOM_CREATED_ISSUES,
diff --git a/apps/spruce/src/hooks/index.ts b/apps/spruce/src/hooks/index.ts
index 9afda3ba1..80c833b15 100644
--- a/apps/spruce/src/hooks/index.ts
+++ b/apps/spruce/src/hooks/index.ts
@@ -23,6 +23,7 @@ export { useUserSettings } from "./useUserSettings";
export { useUserTimeZone } from "./useUserTimeZone";
export { useDateFormat } from "./useDateFormat";
export { useFirstDistro } from "./useFirstDistro";
+export { useFirstImage } from "./useFirstImage";
export { useBreadcrumbRoot } from "./useBreadcrumbRoot";
export { useResize } from "./useResize";
export { useRunningTime } from "./useRunningTime";
diff --git a/apps/spruce/src/hooks/useFirstImage/index.ts b/apps/spruce/src/hooks/useFirstImage/index.ts
new file mode 100644
index 000000000..b7634879e
--- /dev/null
+++ b/apps/spruce/src/hooks/useFirstImage/index.ts
@@ -0,0 +1,13 @@
+import { useQuery } from "@apollo/client";
+import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
+import { IMAGES } from "gql/queries";
+
+/**
+ * `useFirstImage` returns the alphabetically first image from Evergreen's list of images.
+ * @returns an object containing the image ID (string) and loading state (boolean)
+ */
+export const useFirstImage = () => {
+ const { data, loading } = useQuery(IMAGES);
+
+ return { image: data?.images?.[0] ?? "ubuntu2204", loading };
+};
diff --git a/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx b/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx
new file mode 100644
index 000000000..578a5acab
--- /dev/null
+++ b/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx
@@ -0,0 +1,44 @@
+import { MockedProvider, MockedProviderProps } from "@apollo/client/testing";
+import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
+import { IMAGES } from "gql/queries";
+import { renderHook, waitFor } from "test_utils";
+import { ApolloMock } from "types/gql";
+import { useFirstImage } from ".";
+
+interface ProviderProps {
+ mocks?: MockedProviderProps["mocks"];
+ children: React.ReactNode;
+}
+const ProviderWrapper: React.FC = ({ children, mocks = [] }) => (
+ {children}
+);
+
+describe("useFirstImage", () => {
+ it("retrieve first image from list", async () => {
+ const { result } = renderHook(() => useFirstImage(), {
+ wrapper: ({ children }) =>
+ ProviderWrapper({
+ children,
+ mocks: [getImages],
+ }),
+ });
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.image).toBe("amazon2");
+ });
+});
+
+const getImages: ApolloMock = {
+ request: {
+ query: IMAGES,
+ variables: {},
+ },
+ result: {
+ data: {
+ images: ["amazon2", "debian12", "suse15", "ubuntu1604"],
+ },
+ },
+};
diff --git a/apps/spruce/src/pages/image/ImageSelect/index.tsx b/apps/spruce/src/pages/image/ImageSelect/index.tsx
new file mode 100644
index 000000000..624b2668e
--- /dev/null
+++ b/apps/spruce/src/pages/image/ImageSelect/index.tsx
@@ -0,0 +1,55 @@
+import { useQuery } from "@apollo/client";
+import { Combobox, ComboboxOption } from "@leafygreen-ui/combobox";
+import { Skeleton } from "@leafygreen-ui/skeleton-loader";
+import { useNavigate } from "react-router-dom";
+import { getImageRoute } from "constants/routes";
+import { zIndex } from "constants/tokens";
+import { useToastContext } from "context/toast";
+import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
+import { IMAGES } from "gql/queries";
+
+interface ImageSelectProps {
+ selectedImage: string;
+}
+
+export const ImageSelect: React.FC = ({ selectedImage }) => {
+ const navigate = useNavigate();
+
+ const dispatchToast = useToastContext();
+ const { data: imagesData, loading } = useQuery<
+ ImagesQuery,
+ ImagesQueryVariables
+ >(IMAGES, {
+ onError: (err) => {
+ dispatchToast.warning(`Failed to retrieve images: ${err.message}`);
+ },
+ });
+
+ const { images } = imagesData || {};
+
+ if (!loading) {
+ return (
+ {
+ navigate(getImageRoute(imageId));
+ }}
+ value={selectedImage}
+ >
+ {images?.map((image) => (
+
+ {image}
+
+ ))}
+
+ );
+ }
+ return ;
+};
diff --git a/apps/spruce/src/pages/image/index.tsx b/apps/spruce/src/pages/image/index.tsx
index 2d7bf39b7..b7a2af32f 100644
--- a/apps/spruce/src/pages/image/index.tsx
+++ b/apps/spruce/src/pages/image/index.tsx
@@ -1,37 +1,44 @@
+import styled from "@emotion/styled";
+import { sideNavItemSidePadding } from "@leafygreen-ui/side-nav";
import { Link, useParams, Navigate } from "react-router-dom";
import { SideNav, SideNavGroup, SideNavItem } from "components/styles";
import { ImageTabRoutes, getImageRoute, slugs } from "constants/routes";
+import { size } from "constants/tokens";
+import { useFirstImage } from "hooks";
import { getTabTitle } from "./getTabTitle";
+import { ImageSelect } from "./ImageSelect";
const Image: React.FC = () => {
- const {
- [slugs.imageId]: imageId,
- [slugs.tab]: currentTab = ImageTabRoutes.BuildInformation,
- } = useParams<{
+ const { [slugs.imageId]: imageId, [slugs.tab]: currentTab } = useParams<{
[slugs.imageId]: string;
[slugs.tab]: ImageTabRoutes;
}>();
- if (!Object.values(ImageTabRoutes).includes(currentTab)) {
+ const { image: firstImage } = useFirstImage();
+
+ const selectedImage = imageId ?? firstImage;
+
+ if (!Object.values(ImageTabRoutes).includes(currentTab as ImageTabRoutes)) {
return (
);
}
return (
+
+
+
{Object.values(ImageTabRoutes).map((tab) => (
{getTabTitle(tab).title}
@@ -42,4 +49,11 @@ const Image: React.FC = () => {
);
};
+const ButtonsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${size.xs};
+ margin: 0 ${sideNavItemSidePadding}px;
+`;
+
export default Image;