feat: display provisioner jobs and daemons for an organization (#16532)

**Jobs:**
<img width="1624" alt="Screenshot 2025-02-13 at 09 26 31"
src="https://github.com/user-attachments/assets/dc1f24de-48e8-4f91-b128-8e4f3b109328"
/>
[Figma
Link](https://www.figma.com/design/JYW69pbgOMr21fCMiQsPXg/Provisioners?node-id=10-2005&m=dev)

**Daemons:**
<img width="1624" alt="Screenshot 2025-02-13 at 09 26 53"
src="https://github.com/user-attachments/assets/429d77ee-2b4f-45d1-924f-796145901fd8"
/>
[Figma
Link](https://www.figma.com/design/JYW69pbgOMr21fCMiQsPXg/Provisioners?node-id=26-4038&m=dev)

Close https://github.com/coder/coder/issues/15192 and
https://github.com/coder/coder/issues/15193
This commit is contained in:
Bruno Quaresma
2025-02-18 11:27:51 -03:00
committed by GitHub
parent ebf97527fd
commit 0f3858ecc9
20 changed files with 1054 additions and 352 deletions
+44 -1
View File
@@ -1247,7 +1247,7 @@ class ApiMethods {
};
cancelTemplateVersionBuild = async (
templateVersionId: TypesGen.TemplateVersion["id"],
templateVersionId: string,
): Promise<TypesGen.Response> => {
const response = await this.axios.patch(
`/api/v2/templateversions/${templateVersionId}/cancel`,
@@ -1256,6 +1256,17 @@ class ApiMethods {
return response.data;
};
cancelTemplateVersionDryRun = async (
templateVersionId: string,
jobId: string,
): Promise<TypesGen.Response> => {
const response = await this.axios.patch(
`/api/v2/templateversions/${templateVersionId}/dry-run/${jobId}/cancel`,
);
return response.data;
};
createUser = async (
user: TypesGen.CreateUserRequestWithOrgs,
): Promise<TypesGen.User> => {
@@ -2304,6 +2315,38 @@ class ApiMethods {
);
return res.data;
};
getProvisionerJobs = async (orgId: string) => {
const res = await this.axios.get<TypesGen.ProvisionerJob[]>(
`/api/v2/organizations/${orgId}/provisionerjobs`,
);
return res.data;
};
cancelProvisionerJob = async (job: TypesGen.ProvisionerJob) => {
switch (job.type) {
case "workspace_build":
if (!job.input.workspace_build_id) {
throw new Error("Workspace build ID is required to cancel this job");
}
return this.cancelWorkspaceBuild(job.input.workspace_build_id);
case "template_version_import":
if (!job.input.template_version_id) {
throw new Error("Template version ID is required to cancel this job");
}
return this.cancelTemplateVersionBuild(job.input.template_version_id);
case "template_version_dry_run":
if (!job.input.template_version_id) {
throw new Error("Template version ID is required to cancel this job");
}
return this.cancelTemplateVersionDryRun(
job.input.template_version_id,
job.id,
);
}
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+13
View File
@@ -244,6 +244,19 @@ export const organizationPermissions = (organizationId: string | undefined) => {
};
};
export const provisionerJobQueryKey = (orgId: string) => [
"organization",
orgId,
"provisionerjobs",
];
export const provisionerJobs = (orgId: string) => {
return {
queryKey: provisionerJobQueryKey(orgId),
queryFn: () => API.getProvisionerJobs(orgId),
};
};
/**
* Fetch permissions for all provided organizations.
*
+16 -3
View File
@@ -7,16 +7,21 @@ import type { FC } from "react";
import { cn } from "utils/cn";
export const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-md border px-2 py-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-surface-secondary text-content-secondary shadow hover:bg-surface-tertiary",
},
size: {
sm: "text-2xs font-regular",
md: "text-xs font-medium",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
},
);
@@ -25,8 +30,16 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export const Badge: FC<BadgeProps> = ({ className, variant, ...props }) => {
export const Badge: FC<BadgeProps> = ({
className,
variant,
size,
...props
}) => {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
);
};
+4 -4
View File
@@ -9,7 +9,7 @@ import { cn } from "utils/cn";
export const buttonVariants = cva(
`inline-flex items-center justify-center gap-1 whitespace-nowrap
border-solid rounded-md transition-colors min-w-20
border-solid rounded-md transition-colors
text-sm font-semibold font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
@@ -28,9 +28,9 @@ export const buttonVariants = cva(
},
size: {
lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
icon: "h-[30px] min-w-[30px] px-1 py-1.5 [&_svg]:size-icon-sm",
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
},
},
defaultVariants: {
@@ -94,6 +94,11 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
IdP Organization Sync
</SidebarNavItem>
)}
{permissions.viewDeploymentValues && (
<SidebarNavItem href="/deployment/provisioners">
Provisioners
</SidebarNavItem>
)}
{!hasPremiumLicense && (
<SidebarNavItem href="/deployment/premium">Premium</SidebarNavItem>
)}
@@ -1,48 +0,0 @@
import { buildInfo } from "api/queries/buildInfo";
import { provisionerDaemonGroups } from "api/queries/organizations";
import { EmptyState } from "components/EmptyState/EmptyState";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { pageTitle } from "utils/page";
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
const OrganizationProvisionersPage: FC = () => {
const { organization: organizationName } = useParams() as {
organization: string;
};
const { organization } = useOrganizationSettings();
const { entitlements } = useDashboard();
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName));
if (!organization) {
return <EmptyState message="Organization not found" />;
}
return (
<>
<Helmet>
<title>
{pageTitle(
"Provisioners",
organization.display_name || organization.name,
)}
</title>
</Helmet>
<OrganizationProvisionersPageView
showPaywall={!entitlements.features.multiple_organizations.enabled}
error={provisionersQuery.error}
buildInfo={buildInfoQuery.data}
provisioners={provisionersQuery.data}
/>
</>
);
};
export default OrganizationProvisionersPage;
@@ -1,142 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { screen, userEvent } from "@storybook/test";
import {
MockBuildInfo,
MockProvisioner,
MockProvisioner2,
MockProvisionerBuiltinKey,
MockProvisionerKey,
MockProvisionerPskKey,
MockProvisionerUserAuthKey,
MockProvisionerWithTags,
MockUserProvisioner,
mockApiError,
} from "testHelpers/entities";
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
const meta: Meta<typeof OrganizationProvisionersPageView> = {
title: "pages/OrganizationProvisionersPage",
component: OrganizationProvisionersPageView,
args: {
buildInfo: MockBuildInfo,
},
};
export default meta;
type Story = StoryObj<typeof OrganizationProvisionersPageView>;
export const Provisioners: Story = {
args: {
provisioners: [
{
key: MockProvisionerBuiltinKey,
daemons: [MockProvisioner, MockProvisioner2],
},
{
key: MockProvisionerPskKey,
daemons: [
MockProvisioner,
MockUserProvisioner,
MockProvisionerWithTags,
],
},
{
key: MockProvisionerPskKey,
daemons: [MockProvisioner, MockProvisioner2],
},
{
key: { ...MockProvisionerKey, id: "ジェイデン", name: "ジェイデン" },
daemons: [
MockProvisioner,
{ ...MockProvisioner2, tags: { scope: "organization", owner: "" } },
],
},
{
key: { ...MockProvisionerKey, id: "ベン", name: "ベン" },
daemons: [
MockProvisioner,
{
...MockProvisioner2,
version: "2.0.0",
api_version: "1.0",
},
],
},
{
key: {
...MockProvisionerKey,
id: "ケイラ",
name: "ケイラ",
tags: {
...MockProvisioner.tags,
都市: "ユタ",
きっぷ: "yes",
ちいさい: "no",
},
},
daemons: Array.from({ length: 117 }, (_, i) => ({
...MockProvisioner,
id: `ケイラ-${i}`,
name: `ケイラ-${i}`,
})),
},
{
key: MockProvisionerUserAuthKey,
daemons: [
MockUserProvisioner,
{
...MockUserProvisioner,
id: "mock-user-provisioner-2",
name: "Test User Provisioner 2",
},
],
},
],
},
play: async ({ step }) => {
await step("open all details", async () => {
const expandButtons = await screen.findAllByRole("button", {
name: "Show provisioner details",
});
for (const it of expandButtons) {
await userEvent.click(it);
}
});
await step("close uninteresting/large details", async () => {
const collapseButtons = await screen.findAllByRole("button", {
name: "Hide provisioner details",
});
await userEvent.click(collapseButtons[2]);
await userEvent.click(collapseButtons[3]);
await userEvent.click(collapseButtons[5]);
});
await step("show version popover", async () => {
const outOfDate = await screen.findByText("Out of date");
await userEvent.hover(outOfDate);
});
},
};
export const Empty: Story = {
args: {
provisioners: [],
},
};
export const WithError: Story = {
args: {
error: mockApiError({
message: "Fern is mad",
detail: "Frieren slept in and didn't get groceries",
}),
},
};
export const Paywall: Story = {
args: {
showPaywall: true,
},
};
@@ -1,148 +0,0 @@
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Button from "@mui/material/Button";
import type {
BuildInfoResponse,
ProvisionerKey,
ProvisionerKeyDaemons,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Loader } from "components/Loader/Loader";
import { Paywall } from "components/Paywall/Paywall";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup";
import type { FC } from "react";
import { docs } from "utils/docs";
interface OrganizationProvisionersPageViewProps {
/** Determines if the paywall will be shown or not */
showPaywall?: boolean;
/** An error to display instead of the page content */
error?: unknown;
/** Info about the version of coderd */
buildInfo?: BuildInfoResponse;
/** Groups of provisioners, along with their key information */
provisioners?: readonly ProvisionerKeyDaemons[];
}
export const OrganizationProvisionersPageView: FC<
OrganizationProvisionersPageViewProps
> = ({ showPaywall, error, buildInfo, provisioners }) => {
return (
<div>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<SettingsHeader title="Provisioners" />
{!showPaywall && (
<Button
endIcon={<OpenInNewIcon />}
target="_blank"
href={docs("/admin/provisioners")}
>
Create a provisioner
</Button>
)}
</Stack>
{showPaywall ? (
<Paywall
message="Provisioners"
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
documentationLink={docs("/")}
/>
) : error ? (
<ErrorAlert error={error} />
) : !buildInfo || !provisioners ? (
<Loader />
) : (
<ViewContent buildInfo={buildInfo} provisioners={provisioners} />
)}
</div>
);
};
type ViewContentProps = Required<
Pick<OrganizationProvisionersPageViewProps, "buildInfo" | "provisioners">
>;
const ViewContent: FC<ViewContentProps> = ({ buildInfo, provisioners }) => {
const isEmpty = provisioners.every((group) => group.daemons.length === 0);
const provisionerGroupsCount = provisioners.length;
const provisionersCount = provisioners.reduce(
(a, group) => a + group.daemons.length,
0,
);
return (
<>
{isEmpty ? (
<EmptyState
message="No provisioners"
description="A provisioner is required before you can create templates and workspaces. You can connect your first provisioner by following our documentation."
cta={
<Button
endIcon={<OpenInNewIcon />}
target="_blank"
href={docs("/admin/provisioners")}
>
Create a provisioner
</Button>
}
/>
) : (
<div
css={(theme) => ({
margin: 0,
fontSize: 12,
paddingBottom: 18,
color: theme.palette.text.secondary,
})}
>
Showing {provisionerGroupsCount} groups and {provisionersCount}{" "}
provisioners
</div>
)}
<Stack spacing={4.5}>
{provisioners.map((group) => (
<ProvisionerGroup
key={group.key.id}
buildInfo={buildInfo}
keyName={group.key.name}
keyTags={group.key.tags}
type={getGroupType(group.key)}
provisioners={group.daemons}
/>
))}
</Stack>
</>
);
};
// Ideally these would be generated and appear in typesGenerated.ts, but that is
// not currently the case. In the meantime, these are taken from verbatim from
// the corresponding codersdk declarations. The names remain unchanged to keep
// usage of these special values "grep-able".
// https://github.com/coder/coder/blob/7c77a3cc832fb35d9da4ca27df163c740f786137/codersdk/provisionerdaemons.go#L291-L295
const ProvisionerKeyIDBuiltIn = "00000000-0000-0000-0000-000000000001";
const ProvisionerKeyIDUserAuth = "00000000-0000-0000-0000-000000000002";
const ProvisionerKeyIDPSK = "00000000-0000-0000-0000-000000000003";
function getGroupType(key: ProvisionerKey) {
switch (key.id) {
case ProvisionerKeyIDBuiltIn:
return "builtin";
case ProvisionerKeyIDUserAuth:
return "userAuth";
case ProvisionerKeyIDPSK:
return "psk";
default:
return "key";
}
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, waitFor, within } from "@storybook/test";
import { MockProvisionerJob } from "testHelpers/entities";
import { CancelJobButton } from "./CancelJobButton";
const meta: Meta<typeof CancelJobButton> = {
title: "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton",
component: CancelJobButton,
args: {
job: {
...MockProvisionerJob,
status: "running",
},
},
};
export default meta;
type Story = StoryObj<typeof CancelJobButton>;
export const Cancellable: Story = {};
export const NotCancellable: Story = {
args: {
job: {
...MockProvisionerJob,
status: "succeeded",
},
},
};
export const OnClick: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await user.click(button);
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
body.getByText("Cancel provisioner job");
});
},
};
@@ -0,0 +1,53 @@
import type { ProvisionerJob } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { BanIcon } from "lucide-react";
import { type FC, useState } from "react";
import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog";
const CANCELLABLE = ["pending", "running"];
type CancelJobButtonProps = {
job: ProvisionerJob;
};
export const CancelJobButton: FC<CancelJobButtonProps> = ({ job }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const isCancellable = CANCELLABLE.includes(job.status);
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={!isCancellable}
aria-label="Cancel job"
size="icon"
variant="outline"
onClick={() => {
setIsDialogOpen(true);
}}
>
<BanIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Cancel job</TooltipContent>
</Tooltip>
</TooltipProvider>
<CancelJobConfirmationDialog
open={isDialogOpen}
job={job}
onClose={() => {
setIsDialogOpen(false);
}}
/>
</>
);
};
@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
import type { Response } from "api/typesGenerated";
import { MockProvisionerJob } from "testHelpers/entities";
import { withGlobalSnackbar } from "testHelpers/storybook";
import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog";
const meta: Meta<typeof CancelJobConfirmationDialog> = {
title:
"pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog",
component: CancelJobConfirmationDialog,
args: {
open: true,
onClose: fn(),
cancelProvisionerJob: fn(),
job: {
...MockProvisionerJob,
status: "running",
},
},
};
export default meta;
type Story = StoryObj<typeof CancelJobConfirmationDialog>;
export const Idle: Story = {};
export const OnCancel: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const cancelButton = body.getByRole("button", { name: "Discard" });
user.click(cancelButton);
await waitFor(() => {
expect(args.onClose).toHaveBeenCalledTimes(1);
});
},
};
export const onConfirmSuccess: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
decorators: [withGlobalSnackbar],
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = body.getByRole("button", { name: "Confirm" });
user.click(confirmButton);
await waitFor(() => {
body.getByText("Provisioner job canceled successfully");
});
expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1);
expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job);
expect(args.onClose).toHaveBeenCalledTimes(1);
},
};
export const onConfirmFailure: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
decorators: [withGlobalSnackbar],
args: {
cancelProvisionerJob: fn(() => {
throw new Error("API Error");
}),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = body.getByRole("button", { name: "Confirm" });
user.click(confirmButton);
await waitFor(() => {
body.getByText("Failed to cancel provisioner job");
});
expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1);
expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job);
expect(args.onClose).toHaveBeenCalledTimes(0);
},
};
export const Confirming: Story = {
args: {
cancelProvisionerJob: fn(() => new Promise<Response>(() => {})),
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = body.getByRole("button", { name: "Confirm" });
user.click(confirmButton);
},
};
@@ -0,0 +1,59 @@
import { API } from "api/api";
import {
getProvisionerDaemonsKey,
provisionerJobQueryKey,
} from "api/queries/organizations";
import type { ProvisionerJob } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
type CancelJobConfirmationDialogProps = {
open: boolean;
onClose: () => void;
job: ProvisionerJob;
cancelProvisionerJob?: typeof API.cancelProvisionerJob;
};
export const CancelJobConfirmationDialog: FC<
CancelJobConfirmationDialogProps
> = ({
job,
cancelProvisionerJob = API.cancelProvisionerJob,
...dialogProps
}) => {
const queryClient = useQueryClient();
const cancelMutation = useMutation({
mutationFn: cancelProvisionerJob,
onSuccess: () => {
queryClient.invalidateQueries(
provisionerJobQueryKey(job.organization_id),
);
queryClient.invalidateQueries(
getProvisionerDaemonsKey(job.organization_id, job.tags),
);
},
});
return (
<ConfirmDialog
{...dialogProps}
type="delete"
title="Cancel provisioner job"
description={`Are you sure you want to cancel the provisioner job "${job.id}"? This operation will result in the associated workspaces not getting created.`}
confirmText="Confirm"
cancelText="Discard"
confirmLoading={cancelMutation.isLoading}
onConfirm={async () => {
try {
await cancelMutation.mutateAsync(job);
displaySuccess("Provisioner job canceled successfully");
dialogProps.onClose();
} catch {
displayError("Failed to cancel provisioner job");
}
}}
/>
);
};
@@ -0,0 +1,25 @@
import type { FC, HTMLProps } from "react";
import { cn } from "utils/cn";
export const DataGrid: FC<HTMLProps<HTMLDListElement>> = ({
className,
...props
}) => {
return (
<dl
{...props}
className={cn([
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dt]:text-content-primary [&_dt]:font-mono [&_dt]:leading-[22px]",
className,
])}
/>
);
};
export const DataGridSpace: FC<HTMLProps<HTMLDivElement>> = ({
className,
...props
}) => {
return <div {...props} className={cn(["h-6 col-span-2", className])} />;
};
@@ -0,0 +1,60 @@
import type {
ProvisionerDaemonJob,
ProvisionerJob,
ProvisionerJobStatus,
} from "api/typesGenerated";
import {
StatusIndicator,
StatusIndicatorDot,
type StatusIndicatorProps,
} from "components/StatusIndicator/StatusIndicator";
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
const variantByStatus: Record<
ProvisionerJobStatus,
StatusIndicatorProps["variant"]
> = {
succeeded: "success",
failed: "failed",
pending: "pending",
running: "pending",
canceling: "pending",
canceled: "inactive",
unknown: "inactive",
};
type JobStatusIndicatorProps = {
job: ProvisionerJob;
};
export const JobStatusIndicator: FC<JobStatusIndicatorProps> = ({ job }) => {
return (
<StatusIndicator size="sm" variant={variantByStatus[job.status]}>
<StatusIndicatorDot />
<span className="[&:first-letter]:uppercase">{job.status}</span>
{job.status === "failed" && (
<TriangleAlertIcon className="size-icon-xs p-[1px]" />
)}
{job.status === "pending" && `(${job.queue_position}/${job.queue_size})`}
</StatusIndicator>
);
};
type DaemonJobStatusIndicatorProps = {
job: ProvisionerDaemonJob;
};
export const DaemonJobStatusIndicator: FC<DaemonJobStatusIndicatorProps> = ({
job,
}) => {
return (
<StatusIndicator size="sm" variant={variantByStatus[job.status]}>
<StatusIndicatorDot />
<span className="[&:first-letter]:uppercase">{job.status}</span>
{job.status === "failed" && (
<TriangleAlertIcon className="size-icon-xs p-[1px]" />
)}
</StatusIndicator>
);
};
@@ -0,0 +1,274 @@
import { provisionerDaemons } from "api/queries/organizations";
import type { Organization, ProvisionerDaemon } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import {
StatusIndicator,
StatusIndicatorDot,
type StatusIndicatorProps,
} from "components/StatusIndicator/StatusIndicator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import { relativeTime } from "utils/time";
import { DataGrid, DataGridSpace } from "./DataGrid";
import { DaemonJobStatusIndicator } from "./JobStatusIndicator";
import { Tag, Tags, TruncateTags } from "./Tags";
type ProvisionerDaemonsPageProps = {
orgId: string;
};
export const ProvisionerDaemonsPage: FC<ProvisionerDaemonsPageProps> = ({
orgId,
}) => {
const {
data: daemons,
isLoadingError,
refetch,
} = useQuery({
...provisionerDaemons(orgId),
select: (data) =>
data.toSorted((a, b) => {
if (!a.last_seen_at && !b.last_seen_at) return 0;
if (!a.last_seen_at) return 1;
if (!b.last_seen_at) return -1;
return (
new Date(b.last_seen_at).getTime() -
new Date(a.last_seen_at).getTime()
);
}),
});
return (
<section className="flex flex-col gap-8">
<h2 className="sr-only">Provisioner daemons</h2>
<p className="text-sm text-content-secondary m-0 mt-2">
Coder server runs provisioner daemons which execute terraform during
workspace and template builds.{" "}
<Link
href={docs(
"/tutorials/best-practices/security-best-practices#provisioner-daemons",
)}
>
View docs
</Link>
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Last seen</TableHead>
<TableHead>Name</TableHead>
<TableHead>Template</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daemons ? (
daemons.length > 0 ? (
daemons.map((d) => <DaemonRow key={d.id} daemon={d} />)
) : (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No provisioner daemons found" />
</TableCell>
</TableRow>
)
) : isLoadingError ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="Error loading the provisioner daemons"
cta={<Button onClick={() => refetch()}>Retry</Button>}
/>
</TableCell>
</TableRow>
) : (
<TableRow>
<TableCell colSpan={999}>
<Loader />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</section>
);
};
type DaemonRowProps = {
daemon: ProvisionerDaemon;
};
const DaemonRow: FC<DaemonRowProps> = ({ daemon }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<TableRow key={daemon.id}>
<TableCell>
<button
className={cn([
"flex items-center gap-1 p-0 bg-transparent border-0 text-inherit text-xs cursor-pointer",
"transition-colors hover:text-content-primary font-medium whitespace-nowrap",
isOpen && "text-content-primary",
])}
type="button"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{isOpen ? (
<ChevronDownIcon className="size-icon-sm p-0.5" />
) : (
<ChevronRightIcon className="size-icon-sm p-0.5" />
)}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
<span className="[&:first-letter]:uppercase">
{relativeTime(
new Date(daemon.last_seen_at ?? new Date().toISOString()),
)}
</span>
</button>
</TableCell>
<TableCell>
<span className="block whitespace-nowrap text-ellipsis overflow-hidden">
{daemon.name}
</span>
</TableCell>
<TableCell>
{daemon.current_job ? (
<div className="flex items-center gap-1 whitespace-nowrap">
<Avatar
variant="icon"
src={daemon.current_job.template_icon}
fallback={
daemon.current_job.template_display_name ||
daemon.current_job.template_name
}
/>
{daemon.current_job.template_display_name ??
daemon.current_job.template_name}
</div>
) : (
<span className="whitespace-nowrap">Not linked</span>
)}
</TableCell>
<TableCell>
<TruncateTags tags={daemon.tags} />
</TableCell>
<TableCell>
<StatusIndicator size="sm" variant={statusIndicatorVariant(daemon)}>
<StatusIndicatorDot />
<span className="[&:first-letter]:uppercase">
{statusLabel(daemon)}
</span>
</StatusIndicator>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<DataGrid>
<dt>Last seen:</dt>
<dd>{daemon.last_seen_at}</dd>
<dt>Creation time:</dt>
<dd>{daemon.created_at}</dd>
<dt>Version:</dt>
<dd>{daemon.version}</dd>
<dt>Tags:</dt>
<dd>
<Tags>
{Object.entries(daemon.tags).map(([key, value]) => (
<Tag key={key} label={key} value={value} />
))}
</Tags>
</dd>
{daemon.current_job && (
<>
<DataGridSpace />
<dt>Last job:</dt>
<dd>{daemon.current_job.id}</dd>
<dt>Last job state:</dt>
<dd>
<DaemonJobStatusIndicator job={daemon.current_job} />
</dd>
</>
)}
{daemon.previous_job && (
<>
<DataGridSpace />
<dt>Previous job:</dt>
<dd>{daemon.previous_job.id}</dd>
<dt>Previous job state:</dt>
<dd>
<DaemonJobStatusIndicator job={daemon.previous_job} />
</dd>
</>
)}
</DataGrid>
</TableCell>
</TableRow>
)}
</>
);
};
function statusIndicatorVariant(
daemon: ProvisionerDaemon,
): StatusIndicatorProps["variant"] {
if (daemon.previous_job && daemon.previous_job.status === "failed") {
return "failed";
}
switch (daemon.status) {
case "idle":
return "success";
case "busy":
return "pending";
default:
return "inactive";
}
}
function statusLabel(daemon: ProvisionerDaemon) {
if (daemon.previous_job && daemon.previous_job.status === "failed") {
return "Last job failed";
}
switch (daemon.status) {
case "idle":
return "Idle";
case "busy":
return "Busy...";
case "offline":
return "Disconnected";
default:
return "Unknown";
}
}
@@ -0,0 +1,215 @@
import { provisionerJobs } from "api/queries/organizations";
import type { Organization, ProvisionerJob } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
ChevronDownIcon,
ChevronRightIcon,
TriangleAlertIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import { relativeTime } from "utils/time";
import { CancelJobButton } from "./CancelJobButton";
import { DataGrid } from "./DataGrid";
import { JobStatusIndicator } from "./JobStatusIndicator";
import { Tag, Tags, TruncateTags } from "./Tags";
type ProvisionerJobsPageProps = {
orgId: string;
};
export const ProvisionerJobsPage: FC<ProvisionerJobsPageProps> = ({
orgId,
}) => {
const {
data: jobs,
isLoadingError,
refetch,
} = useQuery(provisionerJobs(orgId));
return (
<section className="flex flex-col gap-8">
<h2 className="sr-only">Provisioner jobs</h2>
<p className="text-sm text-content-secondary m-0 mt-2">
Provisioner Jobs are the individual tasks assigned to Provisioners when
the workspaces are being built.{" "}
<Link href={docs("/admin/provisioners")}>View docs</Link>
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Created</TableHead>
<TableHead>Type</TableHead>
<TableHead>Template</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{jobs ? (
jobs.length > 0 ? (
jobs.map((j) => <JobRow key={j.id} job={j} />)
) : (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No provisioner jobs found" />
</TableCell>
</TableRow>
)
) : isLoadingError ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="Error loading the provisioner jobs"
cta={<Button onClick={() => refetch()}>Retry</Button>}
/>
</TableCell>
</TableRow>
) : (
<TableRow>
<TableCell colSpan={999}>
<Loader />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</section>
);
};
type JobRowProps = {
job: ProvisionerJob;
};
const JobRow: FC<JobRowProps> = ({ job }) => {
const metadata = job.metadata;
const [isOpen, setIsOpen] = useState(false);
return (
<>
<TableRow key={job.id}>
<TableCell>
<button
className={cn([
"flex items-center gap-1 p-0 bg-transparent border-0 text-inherit text-xs cursor-pointer",
"transition-colors hover:text-content-primary font-medium whitespace-nowrap",
isOpen && "text-content-primary",
])}
type="button"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{isOpen ? (
<ChevronDownIcon className="size-icon-sm p-0.5" />
) : (
<ChevronRightIcon className="size-icon-sm p-0.5" />
)}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
<span className="[&:first-letter]:uppercase">
{relativeTime(new Date(job.created_at))}
</span>
</button>
</TableCell>
<TableCell>
<Badge size="sm">{job.type}</Badge>
</TableCell>
<TableCell>
{job.metadata.template_name ? (
<div className="flex items-center gap-1 whitespace-nowrap">
<Avatar
variant="icon"
src={metadata.template_icon}
fallback={
metadata.template_display_name || metadata.template_name
}
/>
{metadata.template_display_name ?? metadata.template_name}
</div>
) : (
<span className="whitespace-nowrap">Not linked</span>
)}
</TableCell>
<TableCell>
<TruncateTags tags={job.tags} />
</TableCell>
<TableCell>
<JobStatusIndicator job={job} />
</TableCell>
<TableCell className="text-right">
<CancelJobButton job={job} />
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
{job.status === "failed" && (
<div
className={cn([
"inline-flex items-center gap-2 rounded border border-solid border-boder p-2",
"text-content-primary bg-surface-secondary mb-4",
])}
>
<TriangleAlertIcon className="text-content-destructive size-icon-sm p-0.5" />
<span className="[&:first-letter]:uppercase">{job.error}</span>
</div>
)}
<DataGrid>
<dt>Job ID:</dt>
<dd>{job.id}</dd>
<dt>Available provisioners:</dt>
<dd>
{job.available_workers
? JSON.stringify(job.available_workers)
: "[]"}
</dd>
<dt>Completed by provisioner:</dt>
<dd>{job.worker_id}</dd>
<dt>Associated workspace:</dt>
<dd>{job.metadata.workspace_name ?? "null"}</dd>
<dt>Creation time:</dt>
<dd>{job.created_at}</dd>
<dt>Queue:</dt>
<dd>
{job.queue_position}/{job.queue_size}
</dd>
<dt>Tags:</dt>
<dd>
<Tags>
{Object.entries(job.tags).map(([key, value]) => (
<Tag key={key} label={key} value={value} />
))}
</Tags>
</dd>
</DataGrid>
</TableCell>
</TableRow>
)}
</>
);
};
@@ -0,0 +1,73 @@
import { EmptyState } from "components/EmptyState/EmptyState";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage";
import { ProvisionerJobsPage } from "./ProvisionerJobsPage";
const ProvisionersPage: FC = () => {
const { organization } = useOrganizationSettings();
const tab = useSearchParamsKey({
key: "tab",
defaultValue: "jobs",
});
if (!organization) {
return (
<>
<Helmet>
<title>{pageTitle("Provisioners")}</title>
</Helmet>
<EmptyState message="Organization not found" />
</>
);
}
return (
<>
<Helmet>
<title>
{pageTitle(
"Provisioners",
organization.display_name || organization.name,
)}
</title>
</Helmet>
<div className="flex flex-col gap-12">
<header className="flex flex-row items-baseline justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl m-0">Provisioners</h1>
</div>
</header>
<main>
<Tabs active={tab.value}>
<TabsList>
<TabLink value="jobs" to="?tab=jobs">
Jobs
</TabLink>
<TabLink value="daemons" to="?tab=daemons">
Daemons
</TabLink>
</TabsList>
</Tabs>
<div className="mt-6">
{tab.value === "jobs" && (
<ProvisionerJobsPage orgId={organization.id} />
)}
{tab.value === "daemons" && (
<ProvisionerDaemonsPage orgId={organization.id} />
)}
</div>
</main>
</div>
</>
);
};
export default ProvisionersPage;
@@ -0,0 +1,52 @@
import { Badge } from "components/Badge/Badge";
import type { FC, HTMLProps } from "react";
import { cn } from "utils/cn";
export const Tags: FC<HTMLProps<HTMLDivElement>> = ({
className,
...props
}) => {
return (
<div
{...props}
className={cn(["flex items-center gap-1 flex-wrap", className])}
/>
);
};
type TagProps = {
label: string;
value?: string;
};
export const Tag: FC<TagProps> = ({ label, value }) => {
return (
<Badge size="sm" className="whitespace-nowrap">
[{label}
{value && `=${value}`}]
</Badge>
);
};
type TagsProps = {
tags: Record<string, string>;
};
export const TruncateTags: FC<TagsProps> = ({ tags }) => {
const keys = Object.keys(tags);
if (keys.length === 0) {
return null;
}
const firstKey = keys[0];
const firstValue = tags[firstKey];
const remainderCount = keys.length - 1;
return (
<Tags>
<Tag label={firstKey} value={firstValue} />
{remainderCount > 0 && <Badge size="sm">+{remainderCount}</Badge>}
</Tags>
);
};
+6 -6
View File
@@ -261,8 +261,11 @@ const CreateEditRolePage = lazy(
"./pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage"
),
);
const OrganizationProvisionersPage = lazy(
() => import("./pages/OrganizationSettingsPage/OrganizationProvisionersPage"),
const ProvisionersPage = lazy(
() =>
import(
"./pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage"
),
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
@@ -422,10 +425,7 @@ export const router = createBrowserRouter(
<Route path="create" element={<CreateEditRolePage />} />
<Route path=":roleName" element={<CreateEditRolePage />} />
</Route>
<Route
path="provisioners"
element={<OrganizationProvisionersPage />}
/>
<Route path="provisioners" element={<ProvisionersPage />} />
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
<Route path="settings" element={<OrganizationSettingsPage />} />
</Route>
+11
View File
@@ -1,3 +1,10 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import DayJSRelativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(DayJSRelativeTime);
export type TimeUnit = "days" | "hours";
export function humanDuration(durationInMs: number) {
@@ -29,3 +36,7 @@ export function durationInHours(duration: number): number {
export function durationInDays(duration: number): number {
return duration / 1000 / 60 / 60 / 24;
}
export function relativeTime(date: Date) {
return dayjs(date).fromNow();
}