Skip to content

feat(site): warn on provisioner health during builds #15589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', encodeURIComponent(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;
};
Expand Down
9 changes: 5 additions & 4 deletions site/src/api/queries/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,17 @@ export const organizations = () => {
};
};

export const getProvisionerDaemonsKey = (organization: string) => [
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),
};
};

Expand Down
33 changes: 33 additions & 0 deletions site/src/modules/provisioners/ProvisionerAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Theme } from "@mui/material";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import type { AlertColor } from "@mui/material/Alert";
import { AlertDetail } from "components/Alert/Alert";
import type { FC } from "react";

type ProvisionerAlertProps = {
title: string,
detail: string,
severity: AlertColor,
}

export const ProvisionerAlert : FC<ProvisionerAlertProps> = ({
title,
detail,
severity,
}) => {
return (
<Alert
severity={severity}
css={(theme: Theme) => ({
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette.error.main}`,
})}
>
<AlertTitle>{title}</AlertTitle>
<AlertDetail>{detail}</AlertDetail>
</Alert>
);
};
24 changes: 24 additions & 0 deletions site/src/modules/provisioners/useCompatibleProvisioners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ProvisionerDaemon } from "api/typesGenerated";

export const provisionersUnhealthy = (provisioners : ProvisionerDaemon[]) => {
return provisioners.reduce((allUnhealthy, provisioner) => {
if (!allUnhealthy) {
// If we've found one healthy provisioner, then we don't need to look at the rest
return allUnhealthy;
}
// Otherwise, all provisioners so far have been unhealthy, so we check the next one

// If a provisioner has no last_seen_at value, then it's considered unhealthy
if (!provisioner.last_seen_at) {
return allUnhealthy;
}

// If a provisioner has not been seen within the last 60 seconds, then it's considered unhealthy
const lastSeen = new Date(provisioner.last_seen_at);
const oneMinuteAgo = new Date(Date.now() - 60000);
const unhealthy = lastSeen < oneMinuteAgo;


return allUnhealthy && unhealthy;
}, true);
}
47 changes: 46 additions & 1 deletion site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
MockTemplateVersion,
MockWorkspaceBuildLogs,
} from "testHelpers/entities";
import { withWebSocket } from "testHelpers/storybook";
import { withProvisioners, withWebSocket } from "testHelpers/storybook";
import { BuildLogsDrawer } from "./BuildLogsDrawer";

const meta: Meta<typeof BuildLogsDrawer> = {
Expand Down Expand Up @@ -34,6 +34,51 @@ export const MissingVariables: Story = {
},
};

export const NoProvisioners: Story = {
args: {
templateVersion: {...MockTemplateVersion, organization_id: "org-id"},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockTemplateVersion.job.tags,
provisioners: [],
}
};

export const ProvisionersUnhealthy: Story = {
args: {
templateVersion: {...MockTemplateVersion, organization_id: "org-id"},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockTemplateVersion.job.tags,
provisioners: [
{
last_seen_at: new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
},
],
}
};

export const ProvisionersHealthy: Story = {
args: {
templateVersion: {...MockTemplateVersion, organization_id: "org-id"},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockTemplateVersion.job.tags,
provisioners: [
{
last_seen_at: new Date()
},
],
}
};


export const Logs: Story = {
args: {
templateVersion: {
Expand Down
36 changes: 36 additions & 0 deletions site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, useLayoutEffect, useRef } from "react";
import { navHeight } from "theme/constants";
import { provisionersUnhealthy } from "modules/provisioners/useCompatibleProvisioners";
import { useQuery } from "react-query";
import { provisionerDaemons } from "api/queries/organizations";
import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert";

type BuildLogsDrawerProps = {
error: unknown;
Expand All @@ -27,6 +31,16 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
variablesSectionRef,
...drawerProps
}) => {
const org = templateVersion?.organization_id
const {
data: compatibleProvisioners,
isLoading: provisionerDaemonsLoading,
isError: couldntGetProvisioners,
} = useQuery(
org ? provisionerDaemons(org, templateVersion?.job.tags) : { enabled: false}
);
const compatibleProvisionersUnhealthy = !compatibleProvisioners || provisionersUnhealthy(compatibleProvisioners);

const logs = useWatchVersionLogs(templateVersion);
const logsContainer = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -65,6 +79,28 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
</IconButton>
</header>

{ !logs && !provisionerDaemonsLoading && (
couldntGetProvisioners ? (
<ProvisionerAlert
severity="warning"
title="Something went wrong"
detail="Could not determine provisioner status. Your template build may fail. If your template does not build, please contact your administrator"
/>
) : (!compatibleProvisioners || compatibleProvisioners.length === 0) ? (
<ProvisionerAlert
severity="warning"
title="Template Creation Stuck"
detail="This organization does not have any provisioners to process this template. Configure a provisioner."
/>
) : compatibleProvisionersUnhealthy && (
<ProvisionerAlert
severity="warning"
title="Template Creation Delayed"
detail="Provisioners are currently unresponsive. This may delay your template creation. Please contact your administrator for support."
/>
)
)}

{isMissingVariables ? (
<MissingVariablesBanner
onFillVariables={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
MockWorkspaceResourceSensitive,
MockWorkspaceVolumeResource,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { withDashboardProvider, withProvisioners } from "testHelpers/storybook";
import { TemplateVersionEditor } from "./TemplateVersionEditor";

const meta: Meta<typeof TemplateVersionEditor> = {
Expand Down Expand Up @@ -49,6 +49,101 @@ type Story = StoryObj<typeof TemplateVersionEditor>;

export const Example: Story = {};

export const UndefinedLogs: Story = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

args: {
defaultTab: "logs",
buildLogs: undefined,
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
},
},
};

export const EmptyLogs: Story = {
args: {
defaultTab: "logs",
buildLogs: [],
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
},
},
};

export const CouldntGetProvisioners: Story = {
args: {
defaultTab: "logs",
buildLogs: [],
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
},
},
};

export const NoProvisioners: Story = {
args: {
defaultTab: "logs",
buildLogs: [],
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
organization_id: "org-id",
},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockRunningProvisionerJob.tags,
provisioners: [],
}
};

export const UnhealthyProvisioners: Story = {
args: {
defaultTab: "logs",
buildLogs: [],
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
organization_id: "org-id"
},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockRunningProvisionerJob.tags,
provisioners: [
{
last_seen_at: new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
},
],
}
};

export const HealthyProvisioners: Story = {
args: {
defaultTab: "logs",
buildLogs: [],
templateVersion: {
...MockTemplateVersion,
job: MockRunningProvisionerJob,
organization_id: "org-id"
},
},
decorators: [withProvisioners],
parameters: {
organization_id: "org-id",
tags: MockRunningProvisionerJob.tags,
provisioners: [
{
last_seen_at: new Date(),
},
],
}
};

export const Logs: Story = {
args: {
defaultTab: "logs",
Expand Down
Loading
Loading