diff --git a/site/src/components/StatusIndicator/StatusIndicator.tsx b/site/src/components/StatusIndicator/StatusIndicator.tsx index 3ea9ca0dba7ac..2568690f6db23 100644 --- a/site/src/components/StatusIndicator/StatusIndicator.tsx +++ b/site/src/components/StatusIndicator/StatusIndicator.tsx @@ -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, StatusIndicatorContextValue {} -export const StatusIndicator: FC = ({ - size, - variant, - className, - ...props -}) => { - return ( - -
- - ); -}; +export const StatusIndicator = forwardRef( + ({ size, variant, className, ...props }, ref) => { + return ( + +
+ + ); + }, +); const dotVariants = cva("rounded-full inline-block border-4 border-solid", { variants: { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.stories.tsx b/site/src/modules/provisioners/JobStatusIndicator.stories.tsx similarity index 55% rename from site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.stories.tsx rename to site/src/modules/provisioners/JobStatusIndicator.stories.tsx index d77cc98cc168f..621aa36c3f14e 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.stories.tsx +++ b/site/src/modules/provisioners/JobStatusIndicator.stories.tsx @@ -3,7 +3,7 @@ import { MockProvisionerJob } from "testHelpers/entities"; import { JobStatusIndicator } from "./JobStatusIndicator"; const meta: Meta = { - title: "pages/OrganizationProvisionerJobsPage/JobStatusIndicator", + title: "modules/provisioners/JobStatusIndicator", component: JobStatusIndicator, }; @@ -12,65 +12,43 @@ type Story = StoryObj; 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", }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.tsx b/site/src/modules/provisioners/JobStatusIndicator.tsx similarity index 63% rename from site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.tsx rename to site/src/modules/provisioners/JobStatusIndicator.tsx index 2111b11902129..665b252001bd5 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobStatusIndicator.tsx +++ b/site/src/modules/provisioners/JobStatusIndicator.tsx @@ -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 = ({ job }) => { +export const JobStatusIndicator: FC = ({ + status, + queue, +}) => { return ( - + - {job.status} - {job.status === "failed" && ( + {status} + {status === "failed" && ( )} - {job.status === "pending" && `(${job.queue_position}/${job.queue_size})`} + {status === "pending" && queue && `(${queue.position}/${queue.size})`} ); }; diff --git a/site/src/modules/provisioners/ProvisionerTags.stories.tsx b/site/src/modules/provisioners/ProvisionerTags.stories.tsx new file mode 100644 index 0000000000000..a47ab734ae4aa --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerTags.stories.tsx @@ -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 ; + }, +}; + +export const Tags: Story = { + render: () => { + return ( + + + + + + ); + }, +}; + +export const TruncateTags: Story = { + render: () => { + return ( + + ); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.tsx b/site/src/modules/provisioners/ProvisionerTags.tsx similarity index 65% rename from site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.tsx rename to site/src/modules/provisioners/ProvisionerTags.tsx index 449aa25593f1c..b31be42df234f 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.tsx +++ b/site/src/modules/provisioners/ProvisionerTags.tsx @@ -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> = ({ +export const ProvisionerTags: FC> = ({ className, ...props }) => { @@ -14,12 +14,12 @@ export const Tags: FC> = ({ ); }; -type TagProps = { +type ProvisionerTagProps = { label: string; value?: string; }; -export const Tag: FC = ({ label, value }) => { +export const ProvisionerTag: FC = ({ label, value }) => { return ( [{label} @@ -28,11 +28,11 @@ export const Tag: FC = ({ label, value }) => { ); }; -type TagsProps = { +type ProvisionerTagsProps = { tags: Record; }; -export const TruncateTags: FC = ({ tags }) => { +export const ProvisionerTruncateTags: FC = ({ tags }) => { const keys = Object.keys(tags); if (keys.length === 0) { @@ -44,9 +44,9 @@ export const TruncateTags: FC = ({ tags }) => { const remainderCount = keys.length - 1; return ( - - + + {remainderCount > 0 && +{remainderCount}} - + ); }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx index bda73cb2e3688..94d4687565275 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/JobRow.tsx @@ -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 = ({ job }) => { const metadata = job.metadata; const [isOpen, setIsOpen] = useState(false); + const queue = { + size: job.queue_size, + position: job.queue_position, + }; return ( <> - + {job.type} @@ -68,10 +73,10 @@ export const JobRow: FC = ({ job }) => { )} - + - + @@ -125,11 +130,11 @@ export const JobRow: FC = ({ job }) => {
Tags:
- + {Object.entries(job.tags).map(([key, value]) => ( - + ))} - +
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx index e77b3933e73c8..6aa372c7c6205 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPageView.tsx @@ -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< -
-
-
-

Provisioner Jobs

-

- Provisioner Jobs are the individual tasks assigned to Provisioners - when the workspaces are being built.{" "} - View docs -

-
-
+
+ + Provisioner Jobs + + Provisioner Jobs are the individual tasks assigned to Provisioners + when the workspaces are being built.{" "} + View docs + + -
- -
+ - +
Created diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.stories.tsx deleted file mode 100644 index 8d4612d525bdf..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/Tags.stories.tsx +++ /dev/null @@ -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 ; - }, -}; - -export const Tags: Story = { - render: () => { - return ( - - - - - - ); - }, -}; - -export const TruncateTags: Story = { - render: () => { - return ( - - ); - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.stories.tsx new file mode 100644 index 0000000000000..8f67f6f92cff8 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent } from "@storybook/test"; +import { LastConnectionHead } from "./LastConnectionHead"; + +const meta: Meta = { + title: "pages/OrganizationProvisionersPage/LastConnectionHead", + component: LastConnectionHead, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const OnFocus: Story = { + play: async () => { + await userEvent.tab(); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.tsx new file mode 100644 index 0000000000000..d084ce0075f9f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/LastConnectionHead.tsx @@ -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 ( + + Last connection + + + + + More info + + + + + Last time the provisioner connected to the control plane + + + + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx similarity index 84% rename from site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index fc736975c07f5..181bbbb4c62a3 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx @@ -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 ; @@ -52,8 +56,9 @@ const OrganizationProvisionersPage: FC = () => { ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx new file mode 100644 index 0000000000000..93d47e97d6a9f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx @@ -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 = { + 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; + +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, + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx new file mode 100644 index 0000000000000..e0ccddd9f5448 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -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 ( +
+ + Provisioners + + Coder server runs provisioner daemons which execute terraform during + workspace and template builds.{" "} + View docs + + + + {showPaywall ? ( + + ) : ( +
+ + + Name + Key + Version + Status + Tags + + + + + + + {provisioners ? ( + provisioners.length > 0 ? ( + provisioners.map((provisioner) => ( + + )) + ) : ( + + + + + Create a provisioner + + + + } + /> + + + ) + ) : error ? ( + + + + Retry + + } + /> + + + ) : ( + + + + + + )} + +
+ )} +
+ ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.stories.tsx new file mode 100644 index 0000000000000..4d75ad83587fb --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.stories.tsx @@ -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 = { + title: "pages/OrganizationProvisionersPage/ProvisionerKey", + component: ProvisionerKey, +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.tsx new file mode 100644 index 0000000000000..0bccc8c0442fc --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey.tsx @@ -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 = { + 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 coder{" "} + 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 = ({ name }) => { + const type = getKeyType(name); + const info = infoByType[type]; + + return ( + + {name} + {info && ( + + + + + More info + + + + + {infoByType[type]} + + + + )} + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.stories.tsx new file mode 100644 index 0000000000000..eecba0494eac9 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.stories.tsx @@ -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 = { + title: "pages/OrganizationProvisionersPage/ProvisionerRow", + component: ProvisionerRow, + args: { + provisioner: MockProvisioner, + buildVersion: MockBuildInfo.version, + }, + render: (args) => { + return ( + + + + +
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.tsx new file mode 100644 index 0000000000000..2e40fe4d5388e --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerRow.tsx @@ -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 = ({ + provisioner, + buildVersion, +}) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + + {provisioner.key_name && ( + + )} + + + + + + {provisioner.status && ( + + + + {provisioner.status} + + + )} + + + + + + {provisioner.last_seen_at ? ( + + {relativeTime(new Date(provisioner.last_seen_at))} + + ) : ( + "Never" + )} + + + + {isOpen && ( + + +
+
Last seen:
+
{provisioner.last_seen_at}
+ +
Creation time:
+
{provisioner.created_at}
+ +
Version:
+
+ {provisioner.version === buildVersion + ? "up to date" + : "outdated"} +
+ +
Tags:
+
+ + {Object.entries(provisioner.tags).map(([key, value]) => ( + + ))} + +
+ +
+ + {provisioner.current_job && ( + <> +
Current job:
+
{provisioner.current_job.id}
+ +
Current job status:
+
+ +
+ + )} + + {provisioner.previous_job && ( + <> +
Previous job:
+
{provisioner.previous_job.id}
+ +
Previous job status:
+
+ +
+ + )} +
+
+
+ )} + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.stories.tsx new file mode 100644 index 0000000000000..305fbd441fa7f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.stories.tsx @@ -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 = { + title: "pages/OrganizationProvisionersPage/ProvisionerVersion", + component: ProvisionerVersion, + args: { + provisionerVersion: MockProvisioner.version, + buildVersion: MockBuildInfo.version, + }, +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.tsx new file mode 100644 index 0000000000000..bffe4e3569807 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerVersion.tsx @@ -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 = ({ + provisionerVersion, + buildVersion, +}) => { + return provisionerVersion === buildVersion ? ( + + Up to date + + ) : ( + + + + + + Outdated + + + +

+ 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. +

+
+
+
+ ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx deleted file mode 100644 index 5bbf6cfe81731..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx +++ /dev/null @@ -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 = { - title: "pages/OrganizationProvisionersPage", - component: OrganizationProvisionersPageView, - args: { - buildInfo: MockBuildInfo, - }, -}; - -export default meta; -type Story = StoryObj; - -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, - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx deleted file mode 100644 index 0b89c588d4c9a..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx +++ /dev/null @@ -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 ( -
- - - Provisioners - - - {!showPaywall && ( - - )} - - {showPaywall ? ( - - ) : error ? ( - - ) : !buildInfo || !provisioners ? ( - - ) : ( - - )} -
- ); -}; - -type ViewContentProps = Required< - Pick ->; - -const ViewContent: FC = ({ 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 ? ( - } - target="_blank" - href={docs("/admin/provisioners")} - > - Create a provisioner - - } - /> - ) : ( -
({ - margin: 0, - fontSize: 12, - paddingBottom: 18, - color: theme.palette.text.secondary, - })} - > - Showing {provisionerGroupsCount} groups and {provisionersCount}{" "} - provisioners -
- )} - - {provisioners.map((group) => ( - - ))} - - - ); -}; - -// 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"; - } -} diff --git a/site/src/router.tsx b/site/src/router.tsx index 4f9ba95d1e05c..cd7cd56b690cc 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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"),