mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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: {
|
||||
|
||||
+9
-31
@@ -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",
|
||||
},
|
||||
};
|
||||
+11
-7
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
+8
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
+23
-18
@@ -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>
|
||||
|
||||
+39
-38
@@ -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>
|
||||
|
||||
-45
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
+19
@@ -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();
|
||||
},
|
||||
};
|
||||
+32
@@ -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>
|
||||
);
|
||||
};
|
||||
+8
-3
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
+62
@@ -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,
|
||||
},
|
||||
};
|
||||
+121
@@ -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>
|
||||
);
|
||||
};
|
||||
+49
@@ -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();
|
||||
},
|
||||
};
|
||||
+85
@@ -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>
|
||||
);
|
||||
};
|
||||
+68
@@ -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();
|
||||
},
|
||||
};
|
||||
+170
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+38
@@ -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();
|
||||
},
|
||||
};
|
||||
+48
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user