From a8862be546f347c59201e2219d917e28121c0edb Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 28 Nov 2025 08:44:38 +0100 Subject: [PATCH] feat(site): add tab to invalidate prebuilds (#20864) Updates https://github.com/coder/coder/issues/17917 --- site/src/api/api.ts | 9 +++ .../src/pages/TemplatePage/TemplateLayout.tsx | 9 +++ .../TemplatePrebuildsPage.tsx | 80 +++++++++++++++++++ .../TemplatePrebuildsPageView.stories.tsx | 65 +++++++++++++++ site/src/router.tsx | 5 ++ 5 files changed, 168 insertions(+) create mode 100644 site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx create mode 100644 site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 27c58cd520..ee97f4d64f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1177,6 +1177,15 @@ class ApiMethods { return response.data; }; + invalidateTemplatePresets = async ( + templateId: string, + ): Promise => { + const response = await this.axios.post( + `/api/v2/templates/${templateId}/prebuilds/invalidate`, + ); + return response.data; + }; + getWorkspace = async ( workspaceId: string, params?: TypesGen.WorkspaceOptions, diff --git a/site/src/pages/TemplatePage/TemplateLayout.tsx b/site/src/pages/TemplatePage/TemplateLayout.tsx index 57fad23dc9..344639f9db 100644 --- a/site/src/pages/TemplatePage/TemplateLayout.tsx +++ b/site/src/pages/TemplatePage/TemplateLayout.tsx @@ -6,6 +6,7 @@ import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { useAuthenticated } from "hooks"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { type WorkspacePermissions, workspacePermissionChecks, @@ -105,6 +106,8 @@ export const TemplateLayout: FC = ({ // have permission to update templates. Need both checks. const shouldShowInsights = data?.permissions?.canUpdateTemplate || data?.permissions?.canReadInsights; + const { workspace_prebuilds: isWorkspacePrebuildsEnabled } = + useFeatureVisibility(); if (error || workspacePermissionsQuery.error) { return ( @@ -157,6 +160,12 @@ export const TemplateLayout: FC = ({ Insights )} + {isWorkspacePrebuildsEnabled && + data.permissions.canUpdateTemplate && ( + + Prebuilds + + )} diff --git a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx new file mode 100644 index 0000000000..38392762d0 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx @@ -0,0 +1,80 @@ +import { API } from "api/api"; +import type { InvalidatePresetsResponse } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button } from "components/Button/Button"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { RefreshCw } from "lucide-react"; +import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; +import type { FC } from "react"; +import { useMutation } from "react-query"; +import { pageTitle } from "utils/page"; + +const TemplatePrebuildsPage: FC = () => { + const { template } = useTemplateLayoutContext(); + + return ( + <> + {pageTitle(`${template.name} - Prebuilds`)} + + + ); +}; + +interface TemplatePrebuildsPageViewProps { + templateId: string; +} + +export const TemplatePrebuildsPageView: FC = ({ + templateId, +}) => { + const invalidateMutation = useMutation({ + mutationFn: () => API.invalidateTemplatePresets(templateId), + onSuccess: (data: InvalidatePresetsResponse) => { + if (data.invalidated.length === 0) { + displaySuccess("No template presets required invalidation."); + return; + } + + // They all have the same template version + const { template_version_name } = data.invalidated[0]; + const count = data.invalidated.length; + + displaySuccess( + `Invalidated ${count} ${count === 1 ? "preset" : "presets"} for version ${template_version_name}.`, + ); + }, + }); + + return ( +
+
+ {invalidateMutation.error && ( + + )} +
+

+ Invalidate presets +

+

+ All prebuilt workspaces for the active template version are marked + as invalid. This is useful when prebuilds become stale due to + repository changes or infrastructure updates and need recycling. +

+
+ +
+ +
+
+
+ ); +}; + +export default TemplatePrebuildsPage; diff --git a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx new file mode 100644 index 0000000000..ac47880259 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx @@ -0,0 +1,65 @@ +import { MockTemplate } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { spyOn, userEvent, within } from "storybook/test"; +import { TemplatePrebuildsPageView } from "./TemplatePrebuildsPage"; + +const meta: Meta = { + title: "pages/TemplatePage/TemplatePrebuildsPageView", + component: TemplatePrebuildsPageView, + args: { + templateId: MockTemplate.id, + }, + decorators: [withGlobalSnackbar], +}; + +export default meta; +type Story = StoryObj; + +export const NoPresetsInvalidated: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "invalidateTemplatePresets").mockResolvedValue({ + invalidated: [], + }); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const groupLabel = await canvas.findByText("Invalidate now"); + await user.click(groupLabel); + }, +}; + +export const PresetsInvalidated: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "invalidateTemplatePresets").mockResolvedValue({ + invalidated: [ + { + preset_name: "First preset", + template_name: "Super template", + template_version_name: "abcdef", + }, + { + preset_name: "Second preset", + template_name: "Super template", + template_version_name: "abcdef", + }, + ], + }); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const groupLabel = await canvas.findByText("Invalidate now"); + await user.click(groupLabel); + }, +}; + +export const InvalidationFailed: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "invalidateTemplatePresets").mockRejectedValue( + new Error("Mocked error"), + ); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const groupLabel = await canvas.findByText("Invalidate now"); + await user.click(groupLabel); + }, +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 7dc9cddf80..8979460ba1 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -287,6 +287,10 @@ const TemplateInsightsPage = lazy( () => import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"), ); +const TemplatePrebuildsPage = lazy( + () => + import("./pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage"), +); const PremiumPage = lazy( () => import("./pages/DeploymentSettingsPage/PremiumPage/PremiumPage"), ); @@ -361,6 +365,7 @@ const templateRouter = () => { } /> } /> } /> + } /> } />