mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add tab to invalidate prebuilds (#20864)
Updates https://github.com/coder/coder/issues/17917
This commit is contained in:
@@ -1177,6 +1177,15 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
invalidateTemplatePresets = async (
|
||||
templateId: string,
|
||||
): Promise<TypesGen.InvalidatePresetsResponse> => {
|
||||
const response = await this.axios.post<TypesGen.InvalidatePresetsResponse>(
|
||||
`/api/v2/templates/${templateId}/prebuilds/invalidate`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getWorkspace = async (
|
||||
workspaceId: string,
|
||||
params?: TypesGen.WorkspaceOptions,
|
||||
|
||||
@@ -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<PropsWithChildren> = ({
|
||||
// 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<PropsWithChildren> = ({
|
||||
Insights
|
||||
</TabLink>
|
||||
)}
|
||||
{isWorkspacePrebuildsEnabled &&
|
||||
data.permissions.canUpdateTemplate && (
|
||||
<TabLink to="prebuilds" value="prebuilds">
|
||||
Prebuilds
|
||||
</TabLink>
|
||||
)}
|
||||
</TabsList>
|
||||
</Margins>
|
||||
</Tabs>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<title>{pageTitle(`${template.name} - Prebuilds`)}</title>
|
||||
<TemplatePrebuildsPageView templateId={template.id} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplatePrebuildsPageViewProps {
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
export const TemplatePrebuildsPageView: FC<TemplatePrebuildsPageViewProps> = ({
|
||||
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 (
|
||||
<div className="flex">
|
||||
<div className="max-w-xl space-y-6">
|
||||
{invalidateMutation.error && (
|
||||
<ErrorAlert error={invalidateMutation.error} />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl text-content-primary m-0">
|
||||
Invalidate presets
|
||||
</h3>
|
||||
<p className="text-sm text-content-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => invalidateMutation.mutate()}
|
||||
disabled={invalidateMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Invalidate now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatePrebuildsPage;
|
||||
+65
@@ -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<typeof TemplatePrebuildsPageView> = {
|
||||
title: "pages/TemplatePage/TemplatePrebuildsPageView",
|
||||
component: TemplatePrebuildsPageView,
|
||||
args: {
|
||||
templateId: MockTemplate.id,
|
||||
},
|
||||
decorators: [withGlobalSnackbar],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TemplatePrebuildsPageView>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -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 = () => {
|
||||
<Route path="versions" element={<TemplateVersionsPage />} />
|
||||
<Route path="embed" element={<TemplateEmbedExperimentRouter />} />
|
||||
<Route path="insights" element={<TemplateInsightsPage />} />
|
||||
<Route path="prebuilds" element={<TemplatePrebuildsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="workspace" element={<CreateWorkspacePage />} />
|
||||
|
||||
Reference in New Issue
Block a user