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 organization Can be the organization's ID or name
* @param tags to filter provisioner daemons by.
*/ */
getProvisionerDaemonsByOrganization = async ( getProvisionerDaemonsByOrganization = async (
organization: string, organization: string,
tags?: Record<string, string>,
): Promise<TypesGen.ProvisionerDaemon[]> => { ): Promise<TypesGen.ProvisionerDaemon[]> => {
const params = new URLSearchParams();
if (tags) {
params.append("tags", JSON.stringify(tags));
}
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>( const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
`/api/v2/organizations/${organization}/provisionerdaemons`, `/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`,
); );
return response.data; return response.data;
}; };

View File

@ -115,16 +115,18 @@ export const organizations = () => {
}; };
}; };
export const getProvisionerDaemonsKey = (organization: string) => [ export const getProvisionerDaemonsKey = (
"organization", organization: string,
organization, tags?: Record<string, string>,
"provisionerDaemons", ) => ["organization", organization, tags, "provisionerDaemons"];
];
export const provisionerDaemons = (organization: string) => { export const provisionerDaemons = (
organization: string,
tags?: Record<string, string>,
) => {
return { return {
queryKey: getProvisionerDaemonsKey(organization), queryKey: getProvisionerDaemonsKey(organization, tags),
queryFn: () => API.getProvisionerDaemonsByOrganization(organization), queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags),
}; };
}; };

View File

@ -1,4 +1,5 @@
import MuiAlert, { import MuiAlert, {
type AlertColor as MuiAlertColor,
type AlertProps as MuiAlertProps, type AlertProps as MuiAlertProps,
// biome-ignore lint/nursery/noRestrictedImports: Used as base component // biome-ignore lint/nursery/noRestrictedImports: Used as base component
} from "@mui/material/Alert"; } from "@mui/material/Alert";
@ -11,6 +12,8 @@ import {
useState, useState,
} from "react"; } from "react";
export type AlertColor = MuiAlertColor;
export type AlertProps = MuiAlertProps & { export type AlertProps = MuiAlertProps & {
actions?: ReactNode; actions?: ReactNode;
dismissible?: boolean; 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 = { export const Logs: Story = {
args: { args: {
templateVersion: { templateVersion: {

View File

@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils";
import { JobError } from "api/queries/templates"; import { JobError } from "api/queries/templates";
import type { TemplateVersion } from "api/typesGenerated"; import type { TemplateVersion } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, useLayoutEffect, useRef } from "react"; import { type FC, useLayoutEffect, useRef } from "react";
@ -27,6 +28,10 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
variablesSectionRef, variablesSectionRef,
...drawerProps ...drawerProps
}) => { }) => {
const matchingProvisioners = templateVersion?.matched_provisioners?.count;
const availableProvisioners =
templateVersion?.matched_provisioners?.available;
const logs = useWatchVersionLogs(templateVersion); const logs = useWatchVersionLogs(templateVersion);
const logsContainer = useRef<HTMLDivElement>(null); const logsContainer = useRef<HTMLDivElement>(null);
@ -65,6 +70,8 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
</IconButton> </IconButton>
</header> </header>
{}
{isMissingVariables ? ( {isMissingVariables ? (
<MissingVariablesBanner <MissingVariablesBanner
onFillVariables={() => { onFillVariables={() => {
@ -82,7 +89,14 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
<WorkspaceBuildLogs logs={logs} css={{ border: 0 }} /> <WorkspaceBuildLogs logs={logs} css={{ border: 0 }} />
</section> </section>
) : ( ) : (
<Loader /> <>
<ProvisionerStatusAlert
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}}
/>
<Loader />
</>
)} )}
</div> </div>
</Drawer> </Drawer>

View File

@ -49,6 +49,73 @@ type Story = StoryObj<typeof TemplateVersionEditor>;
export const Example: Story = {}; 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 = { export const Logs: Story = {
args: { args: {
defaultTab: "logs", defaultTab: "logs",

View File

@ -4,7 +4,6 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined"; import CloseOutlined from "@mui/icons-material/CloseOutlined";
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"; import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import WarningOutlined from "@mui/icons-material/WarningOutlined"; import WarningOutlined from "@mui/icons-material/WarningOutlined";
import AlertTitle from "@mui/material/AlertTitle";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup"; import ButtonGroup from "@mui/material/ButtonGroup";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@ -17,7 +16,7 @@ import type {
VariableValue, VariableValue,
WorkspaceResource, WorkspaceResource,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert"; import { Alert } from "components/Alert/Alert";
import { Sidebar } from "components/FullPageLayout/Sidebar"; import { Sidebar } from "components/FullPageLayout/Sidebar";
import { import {
Topbar, Topbar,
@ -29,6 +28,8 @@ import {
} from "components/FullPageLayout/Topbar"; } from "components/FullPageLayout/Topbar";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { linkToTemplate, useLinks } from "modules/navigation"; 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 { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree";
import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData";
import { TemplateResourcesTable } from "modules/templates/TemplateResourcesTable/TemplateResourcesTable"; import { TemplateResourcesTable } from "modules/templates/TemplateResourcesTable/TemplateResourcesTable";
@ -126,6 +127,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
const [deleteFileOpen, setDeleteFileOpen] = useState<string>(); const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
const [renameFileOpen, setRenameFileOpen] = useState<string>(); const [renameFileOpen, setRenameFileOpen] = useState<string>();
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const matchingProvisioners = templateVersion.matched_provisioners?.count;
const availableProvisioners = templateVersion.matched_provisioners?.available;
const triggerPreview = useCallback(async () => { const triggerPreview = useCallback(async () => {
await onPreview(fileTree); await onPreview(fileTree);
@ -192,6 +195,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
linkToTemplate(template.organization_name, template.name), linkToTemplate(template.organization_name, template.name),
); );
const gotBuildLogs = buildLogs && buildLogs.length > 0;
return ( return (
<> <>
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}> <div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
@ -581,31 +586,34 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
css={[styles.logs, styles.tabContent]} css={[styles.logs, styles.tabContent]}
ref={logsContentRef} ref={logsContentRef}
> >
{templateVersion.job.error && ( {templateVersion.job.error ? (
<div> <div>
<Alert <ProvisionerAlert
title="Error during the build"
detail={templateVersion.job.error}
severity="error" severity="error"
css={{ tags={templateVersion.job.tags}
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>
</div> </div>
) : (
!gotBuildLogs && (
<>
<ProvisionerStatusAlert
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion.job.tags}
/>
<Loader css={{ height: "100%" }} />
</>
)
)} )}
{buildLogs && buildLogs.length > 0 ? ( {gotBuildLogs && (
<WorkspaceBuildLogs <WorkspaceBuildLogs
css={styles.buildLogs} css={styles.buildLogs}
hideTimestamps hideTimestamps
logs={buildLogs} logs={buildLogs}
/> />
) : (
<Loader css={{ height: "100%" }} />
)} )}
</div> </div>
)} )}

View File

@ -1,6 +1,7 @@
import type { StoryContext } from "@storybook/react"; import type { StoryContext } from "@storybook/react";
import { withDefaultFeatures } from "api/api"; import { withDefaultFeatures } from "api/api";
import { getAuthorizationKey } from "api/queries/authCheck"; import { getAuthorizationKey } from "api/queries/authCheck";
import { getProvisionerDaemonsKey } from "api/queries/organizations";
import { hasFirstUserKey, meKey } from "api/queries/users"; import { hasFirstUserKey, meKey } from "api/queries/users";
import type { Entitlements } from "api/typesGenerated"; import type { Entitlements } from "api/typesGenerated";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; 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) => ( export const withGlobalSnackbar = (Story: FC) => (
<> <>
<Story /> <Story />