mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+44
-1
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+98
@@ -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);
|
||||
},
|
||||
};
|
||||
+59
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user