diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 43051961fa7e7..eca34261340a0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1238,7 +1238,7 @@ class ApiMethods { }; cancelTemplateVersionBuild = async ( - templateVersionId: TypesGen.TemplateVersion["id"], + templateVersionId: string, ): Promise => { const response = await this.axios.patch( `/api/v2/templateversions/${templateVersionId}/cancel`, @@ -1247,6 +1247,17 @@ class ApiMethods { return response.data; }; + cancelTemplateVersionDryRun = async ( + templateVersionId: string, + jobId: string, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/templateversions/${templateVersionId}/dry-run/${jobId}/cancel`, + ); + + return response.data; + }; + createUser = async ( user: TypesGen.CreateUserRequestWithOrgs, ): Promise => { @@ -2295,6 +2306,38 @@ class ApiMethods { ); return res.data; }; + + getProvisionerJobs = async (orgId: string) => { + const res = await this.axios.get( + `/api/v2/organizations/${orgId}/provisionerjobs`, + ); + return res.data; + }; + + cancelProvisionerJob = async (job: TypesGen.ProvisionerJob) => { + switch (job.type) { + case "workspace_build": + if (!job.input.workspace_build_id) { + throw new Error("Workspace build ID is required to cancel this job"); + } + return this.cancelWorkspaceBuild(job.input.workspace_build_id); + + case "template_version_import": + if (!job.input.template_version_id) { + throw new Error("Template version ID is required to cancel this job"); + } + return this.cancelTemplateVersionBuild(job.input.template_version_id); + + case "template_version_dry_run": + if (!job.input.template_version_id) { + throw new Error("Template version ID is required to cancel this job"); + } + return this.cancelTemplateVersionDryRun( + job.input.template_version_id, + job.id, + ); + } + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 6246664e6ecf0..70cd57628f578 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -244,6 +244,19 @@ export const organizationPermissions = (organizationId: string | undefined) => { }; }; +export const provisionerJobQueryKey = (orgId: string) => [ + "organization", + orgId, + "provisionerjobs", +]; + +export const provisionerJobs = (orgId: string) => { + return { + queryKey: provisionerJobQueryKey(orgId), + queryFn: () => API.getProvisionerJobs(orgId), + }; +}; + /** * Fetch permissions for all provided organizations. * diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index 94d0fa9052340..2044db6d20614 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -7,16 +7,21 @@ import type { FC } from "react"; import { cn } from "utils/cn"; export const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-md border px-2 py-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-surface-secondary text-content-secondary shadow hover:bg-surface-tertiary", }, + size: { + sm: "text-2xs font-regular", + md: "text-xs font-medium", + }, }, defaultVariants: { variant: "default", + size: "md", }, }, ); @@ -25,8 +30,16 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -export const Badge: FC = ({ className, variant, ...props }) => { +export const Badge: FC = ({ + className, + variant, + size, + ...props +}) => { return ( -
+
); }; diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index 93e1a479aa6cc..23803b89add15 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -9,7 +9,7 @@ import { cn } from "utils/cn"; export const buttonVariants = cva( `inline-flex items-center justify-center gap-1 whitespace-nowrap - border-solid rounded-md transition-colors min-w-20 + border-solid rounded-md transition-colors text-sm font-semibold font-medium cursor-pointer no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled @@ -28,9 +28,9 @@ export const buttonVariants = cva( }, size: { - lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg", - sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm", - icon: "h-[30px] min-w-[30px] px-1 py-1.5 [&_svg]:size-icon-sm", + lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg", + sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm", + icon: "size-8 px-1.5 [&_svg]:size-icon-sm", }, }, defaultVariants: { diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 4783133a872bb..21ff6f84b4a48 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -94,6 +94,11 @@ export const DeploymentSidebarView: FC = ({ IdP Organization Sync )} + {permissions.viewDeploymentValues && ( + + Provisioners + + )} {!hasPremiumLicense && ( Premium )} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx deleted file mode 100644 index 5a4965c039e1f..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { buildInfo } from "api/queries/buildInfo"; -import { provisionerDaemonGroups } from "api/queries/organizations"; -import { EmptyState } from "components/EmptyState/EmptyState"; -import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { useDashboard } from "modules/dashboard/useDashboard"; -import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; -import type { FC } from "react"; -import { Helmet } from "react-helmet-async"; -import { useQuery } from "react-query"; -import { useParams } from "react-router-dom"; -import { pageTitle } from "utils/page"; -import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; - -const OrganizationProvisionersPage: FC = () => { - const { organization: organizationName } = useParams() as { - organization: string; - }; - const { organization } = useOrganizationSettings(); - const { entitlements } = useDashboard(); - const { metadata } = useEmbeddedMetadata(); - const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName)); - - if (!organization) { - return ; - } - - return ( - <> - - - {pageTitle( - "Provisioners", - organization.display_name || organization.name, - )} - - - - - ); -}; - -export default OrganizationProvisionersPage; 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 649a75836b603..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import Button from "@mui/material/Button"; -import type { - BuildInfoResponse, - ProvisionerKey, - ProvisionerKeyDaemons, -} from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { EmptyState } from "components/EmptyState/EmptyState"; -import { Loader } from "components/Loader/Loader"; -import { Paywall } from "components/Paywall/Paywall"; -import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; -import { Stack } from "components/Stack/Stack"; -import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup"; -import type { FC } from "react"; -import { docs } from "utils/docs"; - -interface OrganizationProvisionersPageViewProps { - /** Determines if the paywall will be shown or not */ - showPaywall?: boolean; - - /** An error to display instead of the page content */ - error?: unknown; - - /** Info about the version of coderd */ - buildInfo?: BuildInfoResponse; - - /** Groups of provisioners, along with their key information */ - provisioners?: readonly ProvisionerKeyDaemons[]; -} - -export const OrganizationProvisionersPageView: FC< - OrganizationProvisionersPageViewProps -> = ({ showPaywall, error, buildInfo, provisioners }) => { - return ( -
- - - {!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/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx new file mode 100644 index 0000000000000..337149f17639c --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, waitFor, within } from "@storybook/test"; +import { MockProvisionerJob } from "testHelpers/entities"; +import { CancelJobButton } from "./CancelJobButton"; + +const meta: Meta = { + title: "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton", + component: CancelJobButton, + args: { + job: { + ...MockProvisionerJob, + status: "running", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Cancellable: Story = {}; + +export const NotCancellable: Story = { + args: { + job: { + ...MockProvisionerJob, + status: "succeeded", + }, + }, +}; + +export const OnClick: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await user.click(button); + + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + body.getByText("Cancel provisioner job"); + }); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx new file mode 100644 index 0000000000000..4c024911ee23f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx @@ -0,0 +1,53 @@ +import type { ProvisionerJob } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { BanIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog"; + +const CANCELLABLE = ["pending", "running"]; + +type CancelJobButtonProps = { + job: ProvisionerJob; +}; + +export const CancelJobButton: FC = ({ job }) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const isCancellable = CANCELLABLE.includes(job.status); + + return ( + <> + + + + + + Cancel job + + + + { + setIsDialogOpen(false); + }} + /> + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx new file mode 100644 index 0000000000000..8d48fe6d80d1a --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; +import type { Response } from "api/typesGenerated"; +import { MockProvisionerJob } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog"; + +const meta: Meta = { + title: + "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog", + component: CancelJobConfirmationDialog, + args: { + open: true, + onClose: fn(), + cancelProvisionerJob: fn(), + job: { + ...MockProvisionerJob, + status: "running", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Idle: Story = {}; + +export const OnCancel: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const cancelButton = body.getByRole("button", { name: "Discard" }); + user.click(cancelButton); + await waitFor(() => { + expect(args.onClose).toHaveBeenCalledTimes(1); + }); + }, +}; + +export const onConfirmSuccess: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + decorators: [withGlobalSnackbar], + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = body.getByRole("button", { name: "Confirm" }); + + user.click(confirmButton); + await waitFor(() => { + body.getByText("Provisioner job canceled successfully"); + }); + expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1); + expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job); + expect(args.onClose).toHaveBeenCalledTimes(1); + }, +}; + +export const onConfirmFailure: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + decorators: [withGlobalSnackbar], + args: { + cancelProvisionerJob: fn(() => { + throw new Error("API Error"); + }), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = body.getByRole("button", { name: "Confirm" }); + + user.click(confirmButton); + await waitFor(() => { + body.getByText("Failed to cancel provisioner job"); + }); + expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1); + expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job); + expect(args.onClose).toHaveBeenCalledTimes(0); + }, +}; + +export const Confirming: Story = { + args: { + cancelProvisionerJob: fn(() => new Promise(() => {})), + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = body.getByRole("button", { name: "Confirm" }); + user.click(confirmButton); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx new file mode 100644 index 0000000000000..573f7090a1ebb --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx @@ -0,0 +1,59 @@ +import { API } from "api/api"; +import { + getProvisionerDaemonsKey, + provisionerJobQueryKey, +} from "api/queries/organizations"; +import type { ProvisionerJob } from "api/typesGenerated"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; + +type CancelJobConfirmationDialogProps = { + open: boolean; + onClose: () => void; + job: ProvisionerJob; + cancelProvisionerJob?: typeof API.cancelProvisionerJob; +}; + +export const CancelJobConfirmationDialog: FC< + CancelJobConfirmationDialogProps +> = ({ + job, + cancelProvisionerJob = API.cancelProvisionerJob, + ...dialogProps +}) => { + const queryClient = useQueryClient(); + const cancelMutation = useMutation({ + mutationFn: cancelProvisionerJob, + onSuccess: () => { + queryClient.invalidateQueries( + provisionerJobQueryKey(job.organization_id), + ); + queryClient.invalidateQueries( + getProvisionerDaemonsKey(job.organization_id, job.tags), + ); + }, + }); + + return ( + { + try { + await cancelMutation.mutateAsync(job); + displaySuccess("Provisioner job canceled successfully"); + dialogProps.onClose(); + } catch { + displayError("Failed to cancel provisioner job"); + } + }} + /> + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx new file mode 100644 index 0000000000000..7c9d11a238581 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx @@ -0,0 +1,25 @@ +import type { FC, HTMLProps } from "react"; +import { cn } from "utils/cn"; + +export const DataGrid: FC> = ({ + className, + ...props +}) => { + return ( +
+ ); +}; + +export const DataGridSpace: FC> = ({ + className, + ...props +}) => { + return
; +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/JobStatusIndicator.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/JobStatusIndicator.tsx new file mode 100644 index 0000000000000..0671a6b932d10 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/JobStatusIndicator.tsx @@ -0,0 +1,60 @@ +import type { + ProvisionerDaemonJob, + ProvisionerJob, + ProvisionerJobStatus, +} from "api/typesGenerated"; +import { + StatusIndicator, + StatusIndicatorDot, + type StatusIndicatorProps, +} from "components/StatusIndicator/StatusIndicator"; +import { TriangleAlertIcon } from "lucide-react"; +import type { FC } from "react"; + +const variantByStatus: Record< + ProvisionerJobStatus, + StatusIndicatorProps["variant"] +> = { + succeeded: "success", + failed: "failed", + pending: "pending", + running: "pending", + canceling: "pending", + canceled: "inactive", + unknown: "inactive", +}; + +type JobStatusIndicatorProps = { + job: ProvisionerJob; +}; + +export const JobStatusIndicator: FC = ({ job }) => { + return ( + + + {job.status} + {job.status === "failed" && ( + + )} + {job.status === "pending" && `(${job.queue_position}/${job.queue_size})`} + + ); +}; + +type DaemonJobStatusIndicatorProps = { + job: ProvisionerDaemonJob; +}; + +export const DaemonJobStatusIndicator: FC = ({ + job, +}) => { + return ( + + + {job.status} + {job.status === "failed" && ( + + )} + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx new file mode 100644 index 0000000000000..93d670eb9b42a --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx @@ -0,0 +1,274 @@ +import { provisionerDaemons } from "api/queries/organizations"; +import type { Organization, ProvisionerDaemon } from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Button } from "components/Button/Button"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Link } from "components/Link/Link"; +import { Loader } from "components/Loader/Loader"; +import { + StatusIndicator, + StatusIndicatorDot, + type StatusIndicatorProps, +} from "components/StatusIndicator/StatusIndicator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { useQuery } from "react-query"; +import { cn } from "utils/cn"; +import { docs } from "utils/docs"; +import { relativeTime } from "utils/time"; +import { DataGrid, DataGridSpace } from "./DataGrid"; +import { DaemonJobStatusIndicator } from "./JobStatusIndicator"; +import { Tag, Tags, TruncateTags } from "./Tags"; + +type ProvisionerDaemonsPageProps = { + orgId: string; +}; + +export const ProvisionerDaemonsPage: FC = ({ + orgId, +}) => { + const { + data: daemons, + isLoadingError, + refetch, + } = useQuery({ + ...provisionerDaemons(orgId), + select: (data) => + data.toSorted((a, b) => { + if (!a.last_seen_at && !b.last_seen_at) return 0; + if (!a.last_seen_at) return 1; + if (!b.last_seen_at) return -1; + return ( + new Date(b.last_seen_at).getTime() - + new Date(a.last_seen_at).getTime() + ); + }), + }); + + return ( +
+

Provisioner daemons

+

+ Coder server runs provisioner daemons which execute terraform during + workspace and template builds.{" "} + + View docs + +

+ + + + + Last seen + Name + Template + Tags + Status + + + + {daemons ? ( + daemons.length > 0 ? ( + daemons.map((d) => ) + ) : ( + + + + + + ) + ) : isLoadingError ? ( + + + refetch()}>Retry} + /> + + + ) : ( + + + + + + )} + +
+
+ ); +}; + +type DaemonRowProps = { + daemon: ProvisionerDaemon; +}; + +const DaemonRow: FC = ({ daemon }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + + + {daemon.name} + + + + {daemon.current_job ? ( +
+ + {daemon.current_job.template_display_name ?? + daemon.current_job.template_name} +
+ ) : ( + Not linked + )} +
+ + + + + + + + {statusLabel(daemon)} + + + +
+ + {isOpen && ( + + + +
Last seen:
+
{daemon.last_seen_at}
+ +
Creation time:
+
{daemon.created_at}
+ +
Version:
+
{daemon.version}
+ +
Tags:
+
+ + {Object.entries(daemon.tags).map(([key, value]) => ( + + ))} + +
+ + {daemon.current_job && ( + <> + + +
Last job:
+
{daemon.current_job.id}
+ +
Last job state:
+
+ +
+ + )} + + {daemon.previous_job && ( + <> + + +
Previous job:
+
{daemon.previous_job.id}
+ +
Previous job state:
+
+ +
+ + )} +
+
+
+ )} + + ); +}; + +function statusIndicatorVariant( + daemon: ProvisionerDaemon, +): StatusIndicatorProps["variant"] { + if (daemon.previous_job && daemon.previous_job.status === "failed") { + return "failed"; + } + + switch (daemon.status) { + case "idle": + return "success"; + case "busy": + return "pending"; + default: + return "inactive"; + } +} + +function statusLabel(daemon: ProvisionerDaemon) { + if (daemon.previous_job && daemon.previous_job.status === "failed") { + return "Last job failed"; + } + + switch (daemon.status) { + case "idle": + return "Idle"; + case "busy": + return "Busy..."; + case "offline": + return "Disconnected"; + default: + return "Unknown"; + } +} diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx new file mode 100644 index 0000000000000..e852e90f2cf7f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -0,0 +1,215 @@ +import { provisionerJobs } from "api/queries/organizations"; +import type { Organization, ProvisionerJob } from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Badge } from "components/Badge/Badge"; +import { Button } from "components/Button/Button"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Link } from "components/Link/Link"; +import { Loader } from "components/Loader/Loader"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { + ChevronDownIcon, + ChevronRightIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { type FC, useState } from "react"; +import { useQuery } from "react-query"; +import { cn } from "utils/cn"; +import { docs } from "utils/docs"; +import { relativeTime } from "utils/time"; +import { CancelJobButton } from "./CancelJobButton"; +import { DataGrid } from "./DataGrid"; +import { JobStatusIndicator } from "./JobStatusIndicator"; +import { Tag, Tags, TruncateTags } from "./Tags"; + +type ProvisionerJobsPageProps = { + orgId: string; +}; + +export const ProvisionerJobsPage: FC = ({ + orgId, +}) => { + const { + data: jobs, + isLoadingError, + refetch, + } = useQuery(provisionerJobs(orgId)); + + return ( +
+

Provisioner jobs

+

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

+ + + + + Created + Type + Template + Tags + Status + + + + + {jobs ? ( + jobs.length > 0 ? ( + jobs.map((j) => ) + ) : ( + + + + + + ) + ) : isLoadingError ? ( + + + refetch()}>Retry} + /> + + + ) : ( + + + + + + )} + +
+
+ ); +}; + +type JobRowProps = { + job: ProvisionerJob; +}; + +const JobRow: FC = ({ job }) => { + const metadata = job.metadata; + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + + {job.type} + + + {job.metadata.template_name ? ( +
+ + {metadata.template_display_name ?? metadata.template_name} +
+ ) : ( + Not linked + )} +
+ + + + + + + + + +
+ + {isOpen && ( + + + {job.status === "failed" && ( +
+ + {job.error} +
+ )} + +
Job ID:
+
{job.id}
+ +
Available provisioners:
+
+ {job.available_workers + ? JSON.stringify(job.available_workers) + : "[]"} +
+ +
Completed by provisioner:
+
{job.worker_id}
+ +
Associated workspace:
+
{job.metadata.workspace_name ?? "null"}
+ +
Creation time:
+
{job.created_at}
+ +
Queue:
+
+ {job.queue_position}/{job.queue_size} +
+ +
Tags:
+
+ + {Object.entries(job.tags).map(([key, value]) => ( + + ))} + +
+
+
+
+ )} + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx new file mode 100644 index 0000000000000..871eb7b91fa0f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -0,0 +1,73 @@ +import { EmptyState } from "components/EmptyState/EmptyState"; +import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; +import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; +import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage"; +import { ProvisionerJobsPage } from "./ProvisionerJobsPage"; + +const ProvisionersPage: FC = () => { + const { organization } = useOrganizationSettings(); + const tab = useSearchParamsKey({ + key: "tab", + defaultValue: "jobs", + }); + + if (!organization) { + return ( + <> + + {pageTitle("Provisioners")} + + + + ); + } + + return ( + <> + + + {pageTitle( + "Provisioners", + organization.display_name || organization.name, + )} + + + +
+
+
+

Provisioners

+
+
+ +
+ + + + Jobs + + + Daemons + + + + +
+ {tab.value === "jobs" && ( + + )} + {tab.value === "daemons" && ( + + )} +
+
+
+ + ); +}; + +export default ProvisionersPage; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx new file mode 100644 index 0000000000000..449aa25593f1c --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx @@ -0,0 +1,52 @@ +import { Badge } from "components/Badge/Badge"; +import type { FC, HTMLProps } from "react"; +import { cn } from "utils/cn"; + +export const Tags: FC> = ({ + className, + ...props +}) => { + return ( +
+ ); +}; + +type TagProps = { + label: string; + value?: string; +}; + +export const Tag: FC = ({ label, value }) => { + return ( + + [{label} + {value && `=${value}`}] + + ); +}; + +type TagsProps = { + tags: Record; +}; + +export const TruncateTags: FC = ({ tags }) => { + const keys = Object.keys(tags); + + if (keys.length === 0) { + return null; + } + + const firstKey = keys[0]; + const firstValue = tags[firstKey]; + const remainderCount = keys.length - 1; + + return ( + + + {remainderCount > 0 && +{remainderCount}} + + ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index acaf417cecbcd..7e7776eeecf18 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -261,8 +261,11 @@ const CreateEditRolePage = lazy( "./pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage" ), ); -const OrganizationProvisionersPage = lazy( - () => import("./pages/OrganizationSettingsPage/OrganizationProvisionersPage"), +const ProvisionersPage = lazy( + () => + import( + "./pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage" + ), ); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), @@ -422,10 +425,7 @@ export const router = createBrowserRouter( } /> } /> - } - /> + } /> } /> } /> diff --git a/site/src/utils/time.ts b/site/src/utils/time.ts index 3b945c665769f..f890cd3f7a6ea 100644 --- a/site/src/utils/time.ts +++ b/site/src/utils/time.ts @@ -1,3 +1,10 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import DayJSRelativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(duration); +dayjs.extend(DayJSRelativeTime); + export type TimeUnit = "days" | "hours"; export function humanDuration(durationInMs: number) { @@ -29,3 +36,7 @@ export function durationInHours(duration: number): number { export function durationInDays(duration: number): number { return duration / 1000 / 60 / 60 / 24; } + +export function relativeTime(date: Date) { + return dayjs(date).fromNow(); +}