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:
Sas Swart
2024-11-28 16:58:32 +02:00
committed by GitHub
parent 74f7961018
commit 56c792ab52
12 changed files with 365 additions and 27 deletions

View File

@ -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;
};

View File

@ -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),
};
};

View File

@ -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;

View 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,
},
};

View 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>
);
};

View File

@ -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,
},
};

View 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}
/>
);
};

View File

@ -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: {

View File

@ -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>
) : (
<Loader />
<>
<ProvisionerStatusAlert
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}}
/>
<Loader />
</>
)}
</div>
</Drawer>

View File

@ -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",

View File

@ -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>
)}

View File

@ -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 />