mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat(site): warn on provisioner health during builds (#15589)
This PR adds warning alerts to log drawers for templates and template versions. warning alerts for workspace builds to follow in a subsequent PR. Phrasing to be finalised. Stories added and manually verified. See screenshots below. Updating a template version with no provisioners: <img width="1250" alt="Screenshot 2024-11-27 at 11 06 28" src="https://github.com/user-attachments/assets/47aa0940-57a8-44e1-b9a3-25a638fa2c8d"> Build Errors for template versions now show tags as well: <img width="1250" alt="Screenshot 2024-11-27 at 11 07 01" src="https://github.com/user-attachments/assets/566e5339-0fe1-4cf7-8eab-9bf4892ed28a"> Updating a template version with provisioners that are busy or unresponsive: <img width="1250" alt="Screenshot 2024-11-27 at 11 06 40" src="https://github.com/user-attachments/assets/71977c8c-e4ed-457f-8587-2154850e7567"> Creating a new template with provisioners that are busy or unresponsive: <img width="819" alt="Screenshot 2024-11-27 at 11 08 55" src="https://github.com/user-attachments/assets/bda11501-b482-4046-95c5-feabcd1ad7f5"> Creating a new template when there are no provisioners to do the build: <img width="819" alt="Screenshot 2024-11-27 at 11 08 45" src="https://github.com/user-attachments/assets/e4279ebb-399e-4c6e-86e2-ead8f3ac7605">
This commit is contained in:
@ -682,12 +682,20 @@ class ApiMethods {
|
||||
|
||||
/**
|
||||
* @param organization Can be the organization's ID or name
|
||||
* @param tags to filter provisioner daemons by.
|
||||
*/
|
||||
getProvisionerDaemonsByOrganization = async (
|
||||
organization: string,
|
||||
tags?: Record<string, string>,
|
||||
): Promise<TypesGen.ProvisionerDaemon[]> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (tags) {
|
||||
params.append("tags", JSON.stringify(tags));
|
||||
}
|
||||
|
||||
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons`,
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
@ -115,16 +115,18 @@ export const organizations = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getProvisionerDaemonsKey = (organization: string) => [
|
||||
"organization",
|
||||
organization,
|
||||
"provisionerDaemons",
|
||||
];
|
||||
export const getProvisionerDaemonsKey = (
|
||||
organization: string,
|
||||
tags?: Record<string, string>,
|
||||
) => ["organization", organization, tags, "provisionerDaemons"];
|
||||
|
||||
export const provisionerDaemons = (organization: string) => {
|
||||
export const provisionerDaemons = (
|
||||
organization: string,
|
||||
tags?: Record<string, string>,
|
||||
) => {
|
||||
return {
|
||||
queryKey: getProvisionerDaemonsKey(organization),
|
||||
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
|
||||
queryKey: getProvisionerDaemonsKey(organization, tags),
|
||||
queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import MuiAlert, {
|
||||
type AlertColor as MuiAlertColor,
|
||||
type AlertProps as MuiAlertProps,
|
||||
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
|
||||
} from "@mui/material/Alert";
|
||||
@ -11,6 +12,8 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type AlertColor = MuiAlertColor;
|
||||
|
||||
export type AlertProps = MuiAlertProps & {
|
||||
actions?: ReactNode;
|
||||
dismissible?: boolean;
|
||||
|
28
site/src/modules/provisioners/ProvisionerAlert.stories.tsx
Normal file
28
site/src/modules/provisioners/ProvisionerAlert.stories.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { ProvisionerAlert } from "./ProvisionerAlert";
|
||||
|
||||
const meta: Meta<typeof ProvisionerAlert> = {
|
||||
title: "modules/provisioners/ProvisionerAlert",
|
||||
parameters: {
|
||||
chromatic,
|
||||
layout: "centered",
|
||||
},
|
||||
component: ProvisionerAlert,
|
||||
args: {
|
||||
title: "Title",
|
||||
detail: "Detail",
|
||||
severity: "info",
|
||||
tags: { tag: "tagValue" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProvisionerAlert>;
|
||||
|
||||
export const Info: Story = {};
|
||||
export const NullTags: Story = {
|
||||
args: {
|
||||
tags: undefined,
|
||||
},
|
||||
};
|
45
site/src/modules/provisioners/ProvisionerAlert.tsx
Normal file
45
site/src/modules/provisioners/ProvisionerAlert.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import { Alert, type AlertColor } from "components/Alert/Alert";
|
||||
import { AlertDetail } from "components/Alert/Alert";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
|
||||
import type { FC } from "react";
|
||||
interface ProvisionerAlertProps {
|
||||
title: string;
|
||||
detail: string;
|
||||
severity: AlertColor;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
|
||||
title,
|
||||
detail,
|
||||
severity,
|
||||
tags,
|
||||
}) => {
|
||||
return (
|
||||
<Alert
|
||||
severity={severity}
|
||||
css={(theme) => {
|
||||
return {
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: `2px solid ${theme.palette[severity].main}`,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDetail>
|
||||
<div>{detail}</div>
|
||||
<Stack direction="row" spacing={1} wrap="wrap">
|
||||
{Object.entries(tags ?? {})
|
||||
.filter(([key]) => key !== "owner")
|
||||
.map(([key, value]) => (
|
||||
<ProvisionerTag key={key} tagName={key} tagValue={value} />
|
||||
))}
|
||||
</Stack>
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
);
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockTemplateVersion } from "testHelpers/entities";
|
||||
import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert";
|
||||
|
||||
const meta: Meta<typeof ProvisionerStatusAlert> = {
|
||||
title: "modules/provisioners/ProvisionerStatusAlert",
|
||||
parameters: {
|
||||
chromatic,
|
||||
layout: "centered",
|
||||
},
|
||||
component: ProvisionerStatusAlert,
|
||||
args: {
|
||||
matchingProvisioners: 0,
|
||||
availableProvisioners: 0,
|
||||
tags: MockTemplateVersion.job.tags,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProvisionerStatusAlert>;
|
||||
|
||||
export const HealthyProvisioners: Story = {
|
||||
args: {
|
||||
matchingProvisioners: 1,
|
||||
availableProvisioners: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const UndefinedMatchingProvisioners: Story = {
|
||||
args: {
|
||||
matchingProvisioners: undefined,
|
||||
availableProvisioners: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const UndefinedAvailableProvisioners: Story = {
|
||||
args: {
|
||||
matchingProvisioners: 1,
|
||||
availableProvisioners: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMatchingProvisioners: Story = {
|
||||
args: {
|
||||
matchingProvisioners: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoAvailableProvisioners: Story = {
|
||||
args: {
|
||||
matchingProvisioners: 1,
|
||||
availableProvisioners: 0,
|
||||
},
|
||||
};
|
47
site/src/modules/provisioners/ProvisionerStatusAlert.tsx
Normal file
47
site/src/modules/provisioners/ProvisionerStatusAlert.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import type { AlertColor } from "components/Alert/Alert";
|
||||
import type { FC } from "react";
|
||||
import { ProvisionerAlert } from "./ProvisionerAlert";
|
||||
|
||||
interface ProvisionerStatusAlertProps {
|
||||
matchingProvisioners: number | undefined;
|
||||
availableProvisioners: number | undefined;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
|
||||
matchingProvisioners,
|
||||
availableProvisioners,
|
||||
tags,
|
||||
}) => {
|
||||
let title: string;
|
||||
let detail: string;
|
||||
let severity: AlertColor;
|
||||
switch (true) {
|
||||
case matchingProvisioners === 0:
|
||||
title = "Build pending provisioner deployment";
|
||||
detail =
|
||||
"Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator.";
|
||||
severity = "warning";
|
||||
break;
|
||||
case availableProvisioners === 0:
|
||||
title = "Build delayed";
|
||||
detail =
|
||||
"Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete.";
|
||||
severity = "warning";
|
||||
break;
|
||||
default:
|
||||
title = "Build enqueued";
|
||||
detail =
|
||||
"Your build has been enqueued and will begin once a provisioner becomes available to process it.";
|
||||
severity = "info";
|
||||
}
|
||||
|
||||
return (
|
||||
<ProvisionerAlert
|
||||
title={title}
|
||||
detail={detail}
|
||||
severity={severity}
|
||||
tags={tags}
|
||||
/>
|
||||
);
|
||||
};
|
@ -34,6 +34,42 @@ export const MissingVariables: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoProvisioners: Story = {
|
||||
args: {
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
matched_provisioners: {
|
||||
count: 0,
|
||||
available: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ProvisionersUnhealthy: Story = {
|
||||
args: {
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
matched_provisioners: {
|
||||
count: 1,
|
||||
available: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ProvisionersHealthy: Story = {
|
||||
args: {
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
matched_provisioners: {
|
||||
count: 1,
|
||||
available: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Logs: Story = {
|
||||
args: {
|
||||
templateVersion: {
|
||||
|
@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils";
|
||||
import { JobError } from "api/queries/templates";
|
||||
import type { TemplateVersion } from "api/typesGenerated";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
|
||||
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { type FC, useLayoutEffect, useRef } from "react";
|
||||
@ -27,6 +28,10 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
variablesSectionRef,
|
||||
...drawerProps
|
||||
}) => {
|
||||
const matchingProvisioners = templateVersion?.matched_provisioners?.count;
|
||||
const availableProvisioners =
|
||||
templateVersion?.matched_provisioners?.available;
|
||||
|
||||
const logs = useWatchVersionLogs(templateVersion);
|
||||
const logsContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -65,6 +70,8 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
{}
|
||||
|
||||
{isMissingVariables ? (
|
||||
<MissingVariablesBanner
|
||||
onFillVariables={() => {
|
||||
@ -82,7 +89,14 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
<WorkspaceBuildLogs logs={logs} css={{ border: 0 }} />
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<ProvisionerStatusAlert
|
||||
matchingProvisioners={matchingProvisioners}
|
||||
availableProvisioners={availableProvisioners}
|
||||
tags={templateVersion?.job.tags ?? {}}
|
||||
/>
|
||||
<Loader />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
|
@ -49,6 +49,73 @@ type Story = StoryObj<typeof TemplateVersionEditor>;
|
||||
|
||||
export const Example: Story = {};
|
||||
|
||||
export const UndefinedLogs: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
buildLogs: undefined,
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: MockRunningProvisionerJob,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyLogs: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
buildLogs: [],
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: MockRunningProvisionerJob,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoProvisioners: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
buildLogs: [],
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: MockRunningProvisionerJob,
|
||||
matched_provisioners: {
|
||||
count: 0,
|
||||
available: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableProvisioners: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
buildLogs: [],
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: MockRunningProvisionerJob,
|
||||
matched_provisioners: {
|
||||
count: 1,
|
||||
available: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const HealthyProvisioners: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
buildLogs: [],
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: MockRunningProvisionerJob,
|
||||
matched_provisioners: {
|
||||
count: 1,
|
||||
available: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Logs: Story = {
|
||||
args: {
|
||||
defaultTab: "logs",
|
||||
|
@ -4,7 +4,6 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
||||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
|
||||
import WarningOutlined from "@mui/icons-material/WarningOutlined";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import Button from "@mui/material/Button";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@ -17,7 +16,7 @@ import type {
|
||||
VariableValue,
|
||||
WorkspaceResource,
|
||||
} from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Sidebar } from "components/FullPageLayout/Sidebar";
|
||||
import {
|
||||
Topbar,
|
||||
@ -29,6 +28,8 @@ import {
|
||||
} from "components/FullPageLayout/Topbar";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { linkToTemplate, useLinks } from "modules/navigation";
|
||||
import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert";
|
||||
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
|
||||
import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree";
|
||||
import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData";
|
||||
import { TemplateResourcesTable } from "modules/templates/TemplateResourcesTable/TemplateResourcesTable";
|
||||
@ -126,6 +127,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
|
||||
const [renameFileOpen, setRenameFileOpen] = useState<string>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const matchingProvisioners = templateVersion.matched_provisioners?.count;
|
||||
const availableProvisioners = templateVersion.matched_provisioners?.available;
|
||||
|
||||
const triggerPreview = useCallback(async () => {
|
||||
await onPreview(fileTree);
|
||||
@ -192,6 +195,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
linkToTemplate(template.organization_name, template.name),
|
||||
);
|
||||
|
||||
const gotBuildLogs = buildLogs && buildLogs.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
@ -581,31 +586,34 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
css={[styles.logs, styles.tabContent]}
|
||||
ref={logsContentRef}
|
||||
>
|
||||
{templateVersion.job.error && (
|
||||
{templateVersion.job.error ? (
|
||||
<div>
|
||||
<Alert
|
||||
<ProvisionerAlert
|
||||
title="Error during the build"
|
||||
detail={templateVersion.job.error}
|
||||
severity="error"
|
||||
css={{
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: `2px solid ${theme.palette.error.main}`,
|
||||
}}
|
||||
>
|
||||
<AlertTitle>Error during the build</AlertTitle>
|
||||
<AlertDetail>{templateVersion.job.error}</AlertDetail>
|
||||
</Alert>
|
||||
tags={templateVersion.job.tags}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!gotBuildLogs && (
|
||||
<>
|
||||
<ProvisionerStatusAlert
|
||||
matchingProvisioners={matchingProvisioners}
|
||||
availableProvisioners={availableProvisioners}
|
||||
tags={templateVersion.job.tags}
|
||||
/>
|
||||
<Loader css={{ height: "100%" }} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{buildLogs && buildLogs.length > 0 ? (
|
||||
{gotBuildLogs && (
|
||||
<WorkspaceBuildLogs
|
||||
css={styles.buildLogs}
|
||||
hideTimestamps
|
||||
logs={buildLogs}
|
||||
/>
|
||||
) : (
|
||||
<Loader css={{ height: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import type { StoryContext } from "@storybook/react";
|
||||
import { withDefaultFeatures } from "api/api";
|
||||
import { getAuthorizationKey } from "api/queries/authCheck";
|
||||
import { getProvisionerDaemonsKey } from "api/queries/organizations";
|
||||
import { hasFirstUserKey, meKey } from "api/queries/users";
|
||||
import type { Entitlements } from "api/typesGenerated";
|
||||
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
|
||||
@ -121,6 +122,30 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const withProvisioners = (Story: FC, { parameters }: StoryContext) => {
|
||||
if (!parameters.organization_id) {
|
||||
throw new Error(
|
||||
"You forgot to add `parameters.organization_id` to your story",
|
||||
);
|
||||
}
|
||||
if (!parameters.provisioners) {
|
||||
throw new Error(
|
||||
"You forgot to add `parameters.provisioners` to your story",
|
||||
);
|
||||
}
|
||||
if (!parameters.tags) {
|
||||
throw new Error("You forgot to add `parameters.tags` to your story");
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(
|
||||
getProvisionerDaemonsKey(parameters.organization_id, parameters.tags),
|
||||
parameters.provisioners,
|
||||
);
|
||||
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
export const withGlobalSnackbar = (Story: FC) => (
|
||||
<>
|
||||
<Story />
|
||||
|
Reference in New Issue
Block a user