refactor: update provisioners page to match the new design (#17232)

**Demo**


https://github.com/user-attachments/assets/b880326c-7e94-4778-8166-91af7699901e



Closes https://github.com/coder/coder/issues/17221
This commit is contained in:
Bruno Quaresma
2025-04-04 12:00:40 -03:00
committed by GitHub
parent 900eb251eb
commit 17664f481e
22 changed files with 853 additions and 463 deletions
@@ -1,5 +1,5 @@
import { type VariantProps, cva } from "class-variance-authority";
import { type FC, createContext, useContext } from "react";
import { type FC, createContext, forwardRef, useContext } from "react";
import { cn } from "utils/cn";
const statusIndicatorVariants = cva(
@@ -33,21 +33,19 @@ export interface StatusIndicatorProps
extends React.HTMLAttributes<HTMLDivElement>,
StatusIndicatorContextValue {}
export const StatusIndicator: FC<StatusIndicatorProps> = ({
size,
variant,
className,
...props
}) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
};
export const StatusIndicator = forwardRef<HTMLDivElement, StatusIndicatorProps>(
({ size, variant, className, ...props }, ref) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
ref={ref}
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
},
);
const dotVariants = cva("rounded-full inline-block border-4 border-solid", {
variants: {
@@ -3,7 +3,7 @@ import { MockProvisionerJob } from "testHelpers/entities";
import { JobStatusIndicator } from "./JobStatusIndicator";
const meta: Meta<typeof JobStatusIndicator> = {
title: "pages/OrganizationProvisionerJobsPage/JobStatusIndicator",
title: "modules/provisioners/JobStatusIndicator",
component: JobStatusIndicator,
};
@@ -12,65 +12,43 @@ type Story = StoryObj<typeof JobStatusIndicator>;
export const Succeeded: Story = {
args: {
job: {
...MockProvisionerJob,
status: "succeeded",
},
status: "succeeded",
},
};
export const Failed: Story = {
args: {
job: {
...MockProvisionerJob,
status: "failed",
},
status: "failed",
},
};
export const Pending: Story = {
args: {
job: {
...MockProvisionerJob,
status: "pending",
queue_position: 1,
queue_size: 1,
},
status: "pending",
queue: { size: 1, position: 1 },
},
};
export const Running: Story = {
args: {
job: {
...MockProvisionerJob,
status: "running",
},
status: "running",
},
};
export const Canceling: Story = {
args: {
job: {
...MockProvisionerJob,
status: "canceling",
},
status: "canceling",
},
};
export const Canceled: Story = {
args: {
job: {
...MockProvisionerJob,
status: "canceled",
},
status: "canceled",
},
};
export const Unknown: Story = {
args: {
job: {
...MockProvisionerJob,
status: "unknown",
},
status: "unknown",
},
};
@@ -1,4 +1,4 @@
import type { ProvisionerJob, ProvisionerJobStatus } from "api/typesGenerated";
import type { ProvisionerJobStatus } from "api/typesGenerated";
import {
StatusIndicator,
StatusIndicatorDot,
@@ -21,18 +21,22 @@ const variantByStatus: Record<
};
type JobStatusIndicatorProps = {
job: ProvisionerJob;
status: ProvisionerJobStatus;
queue?: { size: number; position: number };
};
export const JobStatusIndicator: FC<JobStatusIndicatorProps> = ({ job }) => {
export const JobStatusIndicator: FC<JobStatusIndicatorProps> = ({
status,
queue,
}) => {
return (
<StatusIndicator size="sm" variant={variantByStatus[job.status]}>
<StatusIndicator size="sm" variant={variantByStatus[status]}>
<StatusIndicatorDot />
<span className="[&:first-letter]:uppercase">{job.status}</span>
{job.status === "failed" && (
<span className="[&:first-letter]:uppercase">{status}</span>
{status === "failed" && (
<TriangleAlertIcon className="size-icon-xs p-[1px]" />
)}
{job.status === "pending" && `(${job.queue_position}/${job.queue_size})`}
{status === "pending" && queue && `(${queue.position}/${queue.size})`}
</StatusIndicator>
);
};
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
ProvisionerTag,
ProvisionerTags,
ProvisionerTruncateTags,
} from "./ProvisionerTags";
const meta: Meta = {
title: "modules/provisioners/ProvisionerTags",
};
export default meta;
type Story = StoryObj;
export const Tag: Story = {
render: () => {
return <ProvisionerTag label="cluster" value="dogfood-v2" />;
},
};
export const Tags: Story = {
render: () => {
return (
<ProvisionerTags>
<ProvisionerTag label="cluster" value="dogfood-v2" />
<ProvisionerTag label="env" value="gke" />
<ProvisionerTag label="scope" value="organization" />
</ProvisionerTags>
);
},
};
export const TruncateTags: Story = {
render: () => {
return (
<ProvisionerTruncateTags
tags={{
cluster: "dogfood-v2",
env: "gke",
scope: "organization",
}}
/>
);
},
};
@@ -2,7 +2,7 @@ import { Badge } from "components/Badge/Badge";
import type { FC, HTMLProps } from "react";
import { cn } from "utils/cn";
export const Tags: FC<HTMLProps<HTMLDivElement>> = ({
export const ProvisionerTags: FC<HTMLProps<HTMLDivElement>> = ({
className,
...props
}) => {
@@ -14,12 +14,12 @@ export const Tags: FC<HTMLProps<HTMLDivElement>> = ({
);
};
type TagProps = {
type ProvisionerTagProps = {
label: string;
value?: string;
};
export const Tag: FC<TagProps> = ({ label, value }) => {
export const ProvisionerTag: FC<ProvisionerTagProps> = ({ label, value }) => {
return (
<Badge size="sm" className="whitespace-nowrap">
[{label}
@@ -28,11 +28,11 @@ export const Tag: FC<TagProps> = ({ label, value }) => {
);
};
type TagsProps = {
type ProvisionerTagsProps = {
tags: Record<string, string>;
};
export const TruncateTags: FC<TagsProps> = ({ tags }) => {
export const ProvisionerTruncateTags: FC<ProvisionerTagsProps> = ({ tags }) => {
const keys = Object.keys(tags);
if (keys.length === 0) {
@@ -44,9 +44,9 @@ export const TruncateTags: FC<TagsProps> = ({ tags }) => {
const remainderCount = keys.length - 1;
return (
<Tags>
<Tag label={firstKey} value={firstValue} />
<ProvisionerTags>
<ProvisionerTag label={firstKey} value={firstValue} />
{remainderCount > 0 && <Badge size="sm">+{remainderCount}</Badge>}
</Tags>
</ProvisionerTags>
);
};
@@ -1,18 +1,23 @@
import type { ProvisionerJob } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { TableCell, TableRow } from "components/Table/Table";
import {
ChevronDownIcon,
ChevronRightIcon,
TriangleAlertIcon,
} from "lucide-react";
import { JobStatusIndicator } from "modules/provisioners/JobStatusIndicator";
import {
ProvisionerTag,
ProvisionerTags,
ProvisionerTruncateTags,
} from "modules/provisioners/ProvisionerTags";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
import { relativeTime } from "utils/time";
import { CancelJobButton } from "./CancelJobButton";
import { JobStatusIndicator } from "./JobStatusIndicator";
import { Tag, Tags, TruncateTags } from "./Tags";
type JobRowProps = {
job: ProvisionerJob;
@@ -21,32 +26,32 @@ type JobRowProps = {
export const JobRow: FC<JobRowProps> = ({ job }) => {
const metadata = job.metadata;
const [isOpen, setIsOpen] = useState(false);
const queue = {
size: job.queue_size,
position: job.queue_position,
};
return (
<>
<TableRow key={job.id}>
<TableCell>
<button
<Button
variant="subtle"
size="sm"
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",
"p-0 h-auto min-w-0 align-middle",
])}
type="button"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{isOpen ? (
<ChevronDownIcon className="size-icon-sm p-0.5" />
) : (
<ChevronRightIcon className="size-icon-sm p-0.5" />
)}
{isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
<span className="[&:first-letter]:uppercase">
<span className="block first-letter:uppercase">
{relativeTime(new Date(job.created_at))}
</span>
</button>
</Button>
</TableCell>
<TableCell>
<Badge size="sm">{job.type}</Badge>
@@ -68,10 +73,10 @@ export const JobRow: FC<JobRowProps> = ({ job }) => {
)}
</TableCell>
<TableCell>
<TruncateTags tags={job.tags} />
<ProvisionerTruncateTags tags={job.tags} />
</TableCell>
<TableCell>
<JobStatusIndicator job={job} />
<JobStatusIndicator status={job.status} queue={queue} />
</TableCell>
<TableCell className="text-right">
<CancelJobButton job={job} />
@@ -125,11 +130,11 @@ export const JobRow: FC<JobRowProps> = ({ job }) => {
<dt>Tags:</dt>
<dd>
<Tags>
<ProvisionerTags>
{Object.entries(job.tags).map(([key, value]) => (
<Tag key={key} label={key} value={value} />
<ProvisionerTag key={key} label={key} value={value} />
))}
</Tags>
</ProvisionerTags>
</dd>
</dl>
</TableCell>
@@ -15,6 +15,11 @@ import {
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import {
StatusIndicator,
StatusIndicatorDot,
@@ -95,46 +100,42 @@ const OrganizationProvisionerJobsPageView: FC<
</title>
</Helmet>
<section className="flex flex-col gap-8">
<header className="flex flex-row items-baseline justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl m-0">Provisioner Jobs</h1>
<p className="text-sm text-content-secondary m-0">
Provisioner Jobs are the individual tasks assigned to Provisioners
when the workspaces are being built.{" "}
<Link href={docs("/admin/provisioners")}>View docs</Link>
</p>
</div>
</header>
<section>
<SettingsHeader>
<SettingsHeaderTitle>Provisioner Jobs</SettingsHeaderTitle>
<SettingsHeaderDescription>
Provisioner Jobs are the individual tasks assigned to Provisioners
when the workspaces are being built.{" "}
<Link href={docs("/admin/provisioners")}>View docs</Link>
</SettingsHeaderDescription>
</SettingsHeader>
<div>
<Select
value={filter.status}
onValueChange={(status) => {
onFilterChange({ status: status as ProvisionerJobStatus });
}}
>
<SelectTrigger className="w-[180px]" data-testid="status-filter">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{StatusFilters.map((status) => (
<SelectItem key={status} value={status}>
<StatusIndicator variant={variantByStatus[status]}>
<StatusIndicatorDot />
<span className="block first-letter:uppercase">
{status}
</span>
</StatusIndicator>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Select
value={filter.status}
onValueChange={(status) => {
onFilterChange({ status: status as ProvisionerJobStatus });
}}
>
<SelectTrigger className="w-[180px]" data-testid="status-filter">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{StatusFilters.map((status) => (
<SelectItem key={status} value={status}>
<StatusIndicator variant={variantByStatus[status]}>
<StatusIndicatorDot />
<span className="block first-letter:uppercase">
{status}
</span>
</StatusIndicator>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Table>
<Table className="mt-6">
<TableHeader>
<TableRow>
<TableHead>Created</TableHead>
@@ -1,45 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
Tag as TagComponent,
Tags as TagsComponent,
TruncateTags as TruncateTagsComponent,
} from "./Tags";
const meta: Meta = {
title: "pages/OrganizationProvisionerJobsPage/Tags",
};
export default meta;
type Story = StoryObj;
export const Tag: Story = {
render: () => {
return <TagComponent label="cluster" value="dogfood-v2" />;
},
};
export const Tags: Story = {
render: () => {
return (
<TagsComponent>
<TagComponent label="cluster" value="dogfood-v2" />
<TagComponent label="env" value="gke" />
<TagComponent label="scope" value="organization" />
</TagsComponent>
);
},
};
export const TruncateTags: Story = {
render: () => {
return (
<TruncateTagsComponent
tags={{
cluster: "dogfood-v2",
env: "gke",
scope: "organization",
}}
/>
);
},
};
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent } from "@storybook/test";
import { LastConnectionHead } from "./LastConnectionHead";
const meta: Meta<typeof LastConnectionHead> = {
title: "pages/OrganizationProvisionersPage/LastConnectionHead",
component: LastConnectionHead,
};
export default meta;
type Story = StoryObj<typeof LastConnectionHead>;
export const Default: Story = {};
export const OnFocus: Story = {
play: async () => {
await userEvent.tab();
},
};
@@ -0,0 +1,32 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
export const LastConnectionHead: FC = () => {
return (
<span className="flex items-center gap-1 whitespace-nowrap text-xs font-medium text-content-secondary">
Last connection
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="flex items-center">
<span className="sr-only">More info</span>
<InfoIcon
tabIndex={0}
className="cursor-pointer size-icon-xs p-0.5"
/>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Last time the provisioner connected to the control plane
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
};
@@ -1,5 +1,5 @@
import { buildInfo } from "api/queries/buildInfo";
import { provisionerDaemonGroups } from "api/queries/organizations";
import { provisionerDaemons } from "api/queries/organizations";
import { EmptyState } from "components/EmptyState/EmptyState";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useDashboard } from "modules/dashboard/useDashboard";
@@ -20,7 +20,11 @@ const OrganizationProvisionersPage: FC = () => {
const { entitlements } = useDashboard();
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName));
const provisionersQuery = useQuery({
...provisionerDaemons(organizationName),
select: (provisioners) =>
provisioners.filter((p) => p.status !== "offline"),
});
if (!organization) {
return <EmptyState message="Organization not found" />;
@@ -52,8 +56,9 @@ const OrganizationProvisionersPage: FC = () => {
<OrganizationProvisionersPageView
showPaywall={!entitlements.features.multiple_organizations.enabled}
error={provisionersQuery.error}
buildInfo={buildInfoQuery.data}
provisioners={provisionersQuery.data}
buildVersion={buildInfoQuery.data?.version}
onRetry={provisionersQuery.refetch}
/>
</>
);
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockBuildInfo,
MockProvisioner,
MockProvisionerWithTags,
MockUserProvisioner,
mockApiError,
} from "testHelpers/entities";
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
const meta: Meta<typeof OrganizationProvisionersPageView> = {
title: "pages/OrganizationProvisionersPage",
component: OrganizationProvisionersPageView,
args: {
buildVersion: MockBuildInfo.version,
provisioners: [
MockProvisioner,
{
...MockUserProvisioner,
status: "busy",
},
{
...MockProvisionerWithTags,
version: "0.0.0",
},
],
},
};
export default meta;
type Story = StoryObj<typeof OrganizationProvisionersPageView>;
export const Loaded: Story = {};
export const Loading: Story = {
args: {
provisioners: undefined,
},
};
export const Empty: Story = {
args: {
provisioners: [],
},
};
export const WithError: Story = {
args: {
provisioners: undefined,
error: mockApiError({
message: "Fern is mad",
detail: "Frieren slept in and didn't get groceries",
}),
},
};
export const Paywall: Story = {
args: {
provisioners: undefined,
showPaywall: true,
},
};
@@ -0,0 +1,121 @@
import type { ProvisionerDaemon } from "api/typesGenerated";
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 { Paywall } from "components/Paywall/Paywall";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import type { FC } from "react";
import { docs } from "utils/docs";
import { LastConnectionHead } from "./LastConnectionHead";
import { ProvisionerRow } from "./ProvisionerRow";
interface OrganizationProvisionersPageViewProps {
showPaywall: boolean | undefined;
provisioners: readonly ProvisionerDaemon[] | undefined;
buildVersion: string | undefined;
error: unknown;
onRetry: () => void;
}
export const OrganizationProvisionersPageView: FC<
OrganizationProvisionersPageViewProps
> = ({ showPaywall, error, provisioners, buildVersion, onRetry }) => {
return (
<section>
<SettingsHeader>
<SettingsHeaderTitle>Provisioners</SettingsHeaderTitle>
<SettingsHeaderDescription>
Coder server runs provisioner daemons which execute terraform during
workspace and template builds.{" "}
<Link href={docs("/admin/provisioners")}>View docs</Link>
</SettingsHeaderDescription>
</SettingsHeader>
{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("/")}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Key</TableHead>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tags</TableHead>
<TableHead>
<LastConnectionHead />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{provisioners ? (
provisioners.length > 0 ? (
provisioners.map((provisioner) => (
<ProvisionerRow
provisioner={provisioner}
key={provisioner.id}
buildVersion={buildVersion}
/>
))
) : (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No provisioners found"
description="A provisioner is required before you can create templates and workspaces. You can connect your first provisioner by following our documentation."
cta={
<Button size="sm" asChild>
<a href={docs("/admin/provisioners")}>
Create a provisioner
<SquareArrowOutUpRightIcon />
</a>
</Button>
}
/>
</TableCell>
</TableRow>
)
) : error ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="Error loading the provisioner jobs"
cta={
<Button onClick={onRetry} size="sm">
Retry
</Button>
}
/>
</TableCell>
</TableRow>
) : (
<TableRow>
<TableCell colSpan={999}>
<Loader />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</section>
);
};
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent } from "@storybook/test";
import {
ProvisionerKeyNameBuiltIn,
ProvisionerKeyNamePSK,
ProvisionerKeyNameUserAuth,
} from "api/typesGenerated";
import { ProvisionerKey } from "./ProvisionerKey";
const meta: Meta<typeof ProvisionerKey> = {
title: "pages/OrganizationProvisionersPage/ProvisionerKey",
component: ProvisionerKey,
};
export default meta;
type Story = StoryObj<typeof ProvisionerKey>;
export const Key: Story = {
args: {
name: "gke-dogfood-v2-coder",
},
};
export const BuiltIn: Story = {
args: {
name: ProvisionerKeyNameBuiltIn,
},
play: async () => {
await userEvent.tab();
},
};
export const UserAuth: Story = {
args: {
name: ProvisionerKeyNameUserAuth,
},
play: async () => {
await userEvent.tab();
},
};
export const PSK: Story = {
args: {
name: ProvisionerKeyNamePSK,
},
play: async () => {
await userEvent.tab();
},
};
@@ -0,0 +1,85 @@
import {
ProvisionerKeyNameBuiltIn,
ProvisionerKeyNamePSK,
ProvisionerKeyNameUserAuth,
} from "api/typesGenerated";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { InfoIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
type KeyType = "builtin" | "userAuth" | "psk" | "key";
function getKeyType(name: string) {
switch (name) {
case ProvisionerKeyNameBuiltIn:
return "builtin";
case ProvisionerKeyNameUserAuth:
return "userAuth";
case ProvisionerKeyNamePSK:
return "psk";
default:
return "key";
}
}
const infoByType: Record<KeyType, ReactNode> = {
builtin: (
<>
These provisioners are running as part of a coderd instance. Built-in
provisioners are only available for the default organization.{" "}
</>
),
userAuth: (
<>
These provisioners are connected by users using the <code>coder</code>{" "}
CLI, and are authorized by the users credentials. They can be tagged to
only run provisioner jobs for that user. User-authenticated provisioners
are only available for the default organization.
</>
),
psk: (
<>
These provisioners all use pre-shared key authentication. PSK provisioners
are only available for the default organization.
</>
),
key: null,
};
type ProvisionerKeyProps = {
name: string;
};
export const ProvisionerKey: FC<ProvisionerKeyProps> = ({ name }) => {
const type = getKeyType(name);
const info = infoByType[type];
return (
<span className="flex items-center gap-1 whitespace-nowrap text-xs font-medium text-content-secondary">
{name}
{info && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="flex items-center">
<span className="sr-only">More info</span>
<InfoIcon
tabIndex={0}
className="cursor-pointer size-icon-xs p-0.5"
/>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{infoByType[type]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
);
};
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { Table, TableBody } from "components/Table/Table";
import { MockBuildInfo, MockProvisioner } from "testHelpers/entities";
import { ProvisionerRow } from "./ProvisionerRow";
const meta: Meta<typeof ProvisionerRow> = {
title: "pages/OrganizationProvisionersPage/ProvisionerRow",
component: ProvisionerRow,
args: {
provisioner: MockProvisioner,
buildVersion: MockBuildInfo.version,
},
render: (args) => {
return (
<Table>
<TableBody>
<ProvisionerRow {...args} />
</TableBody>
</Table>
);
},
};
export default meta;
type Story = StoryObj<typeof ProvisionerRow>;
export const Close: Story = {};
export const Outdated: Story = {
args: {
provisioner: {
...MockProvisioner,
version: "0.0.0",
},
},
};
export const OpenOnClick: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const showMoreButton = canvas.getByRole("button", { name: /show more/i });
await userEvent.click(showMoreButton);
const provisionerCreationTime = canvas.queryByText(
args.provisioner.created_at,
);
expect(provisionerCreationTime).toBeInTheDocument();
},
};
export const HideOnClick: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const showMoreButton = canvas.getByRole("button", { name: /show more/i });
await userEvent.click(showMoreButton);
const hideButton = canvas.getByRole("button", { name: /hide/i });
await userEvent.click(hideButton);
const provisionerCreationTime = canvas.queryByText(
args.provisioner.created_at,
);
expect(provisionerCreationTime).not.toBeInTheDocument();
},
};
@@ -0,0 +1,170 @@
import type {
ProvisionerDaemon,
ProvisionerDaemonStatus,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
StatusIndicator,
StatusIndicatorDot,
type StatusIndicatorProps,
} from "components/StatusIndicator/StatusIndicator";
import { TableCell, TableRow } from "components/Table/Table";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { JobStatusIndicator } from "modules/provisioners/JobStatusIndicator";
import {
ProvisionerTag,
ProvisionerTags,
ProvisionerTruncateTags,
} from "modules/provisioners/ProvisionerTags";
import { ProvisionerKey } from "pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
import { relativeTime } from "utils/time";
import { ProvisionerVersion } from "./ProvisionerVersion";
const variantByStatus: Record<
ProvisionerDaemonStatus,
StatusIndicatorProps["variant"]
> = {
idle: "success",
busy: "pending",
offline: "inactive",
};
type ProvisionerRowProps = {
provisioner: ProvisionerDaemon;
buildVersion: string | undefined;
};
export const ProvisionerRow: FC<ProvisionerRowProps> = ({
provisioner,
buildVersion,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<TableRow>
<TableCell>
<Button
variant="subtle"
size="sm"
className={cn([
isOpen && "text-content-primary",
"p-0 h-auto min-w-0 align-middle",
])}
onClick={() => {
setIsOpen((v) => !v);
}}
>
{isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
{provisioner.name}
</Button>
</TableCell>
<TableCell>
{provisioner.key_name && (
<ProvisionerKey name={provisioner.key_name} />
)}
</TableCell>
<TableCell>
<ProvisionerVersion
buildVersion={buildVersion}
provisionerVersion={provisioner.version}
/>
</TableCell>
<TableCell>
{provisioner.status && (
<StatusIndicator
size="sm"
variant={variantByStatus[provisioner.status]}
>
<StatusIndicatorDot />
<span className="block first-letter:uppercase">
{provisioner.status}
</span>
</StatusIndicator>
)}
</TableCell>
<TableCell>
<ProvisionerTruncateTags tags={provisioner.tags} />
</TableCell>
<TableCell>
{provisioner.last_seen_at ? (
<span className="block first-letter:uppercase">
{relativeTime(new Date(provisioner.last_seen_at))}
</span>
) : (
"Never"
)}
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<dl
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>Last seen:</dt>
<dd>{provisioner.last_seen_at}</dd>
<dt>Creation time:</dt>
<dd>{provisioner.created_at}</dd>
<dt>Version:</dt>
<dd>
{provisioner.version === buildVersion
? "up to date"
: "outdated"}
</dd>
<dt>Tags:</dt>
<dd>
<ProvisionerTags>
{Object.entries(provisioner.tags).map(([key, value]) => (
<ProvisionerTag key={key} label={key} value={value} />
))}
</ProvisionerTags>
</dd>
<div className="h-6 w-full col-span-2" />
{provisioner.current_job && (
<>
<dt>Current job:</dt>
<dd>{provisioner.current_job.id}</dd>
<dt>Current job status:</dt>
<dd>
<JobStatusIndicator
status={provisioner.current_job.status}
/>
</dd>
</>
)}
{provisioner.previous_job && (
<>
<dt>Previous job:</dt>
<dd>{provisioner.previous_job.id}</dd>
<dt>Previous job status:</dt>
<dd>
<JobStatusIndicator
status={provisioner.previous_job.status}
/>
</dd>
</>
)}
</dl>
</TableCell>
</TableRow>
)}
</>
);
};
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { MockBuildInfo, MockProvisioner } from "testHelpers/entities";
import { ProvisionerVersion } from "./ProvisionerVersion";
const meta: Meta<typeof ProvisionerVersion> = {
title: "pages/OrganizationProvisionersPage/ProvisionerVersion",
component: ProvisionerVersion,
args: {
provisionerVersion: MockProvisioner.version,
buildVersion: MockBuildInfo.version,
},
};
export default meta;
type Story = StoryObj<typeof ProvisionerVersion>;
export const UpToDate: Story = {};
export const Outdated: Story = {
args: {
provisionerVersion: "0.0.0",
buildVersion: MockBuildInfo.version,
},
};
export const OnFocus: Story = {
args: {
provisionerVersion: "0.0.0",
buildVersion: MockBuildInfo.version,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const version = canvas.getByText(/outdated/i);
await userEvent.tab();
expect(version).toHaveFocus();
},
};
@@ -0,0 +1,48 @@
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
export type ProvisionerVersionProps = {
buildVersion: string | undefined;
provisionerVersion: string;
};
export const ProvisionerVersion: FC<ProvisionerVersionProps> = ({
provisionerVersion,
buildVersion,
}) => {
return provisionerVersion === buildVersion ? (
<span className="text-xs font-medium text-content-secondary">
Up to date
</span>
) : (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<StatusIndicator
variant="warning"
size="sm"
className="cursor-pointer"
tabIndex={0}
>
<TriangleAlertIcon className="size-icon-xs" />
Outdated
</StatusIndicator>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="m-0">
This provisioner is out of date. You may experience issues when
using a provisioner version that doesn't match your Coder
deployment. Please upgrade to a newer version.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -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,154 +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,
SettingsHeaderTitle,
} 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>
<SettingsHeaderTitle>Provisioners</SettingsHeaderTitle>
</SettingsHeader>
{!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";
}
}
+4 -1
View File
@@ -264,7 +264,10 @@ const CreateEditRolePage = lazy(
),
);
const ProvisionersPage = lazy(
() => import("./pages/OrganizationSettingsPage/OrganizationProvisionersPage"),
() =>
import(
"./pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage"
),
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),