From b6430bb4942f0c10a44eb0dbc34b03f7a2ce86b1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 5 Feb 2025 13:34:08 +0000 Subject: [PATCH 01/15] Set base structure to display the provisioner jobs --- site/src/components/Badge/Badge.tsx | 19 +++- site/src/components/Button/Button.tsx | 8 +- .../management/DeploymentSidebarView.tsx | 5 + .../ProvisionersPage/ProvisionersPage.tsx | 96 +++++++++++++++++++ site/src/router.tsx | 5 + 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx 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/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx new file mode 100644 index 0000000000000..6bee4e485a12e --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -0,0 +1,96 @@ +import { AvatarFallback } from "@radix-ui/react-avatar"; +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 { Link } from "components/Link/Link"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; +import { BanIcon } from "lucide-react"; +import type { FC } from "react"; +import { docs } from "utils/docs"; + +export const ProvisionersPage: FC = () => { + return ( +
+
+
+

Provisioners

+
+
+ +
+ + + + Jobs + + + Daemons + + + + +
+ +
+
+
+ ); +}; + +const JobsTabContent: FC = () => { + return ( +
+

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

+ + + + + Last seen + Name + Template + Tags + Status + + + + + 5 min ago + + workspace_build + + +
+ + Write Coder on Coder +
+
+ + [foo=bar] + + Completed + + + +
+
+
+
+ ); +}; + +export default ProvisionersPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index acaf417cecbcd..c56bd1ee27ca1 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -304,6 +304,10 @@ const ChangePasswordPage = lazy( const IdpOrgSyncPage = lazy( () => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"), ); +const ProvisionersPage = lazy( + () => + import("./pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage"), +); const RoutesWithSuspense = () => { return ( @@ -452,6 +456,7 @@ export const router = createBrowserRouter( /> } /> } /> + } /> From 643c3626ea522c584bd4f40bd86d14d42acbdbee Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 7 Feb 2025 18:56:03 +0000 Subject: [PATCH 02/15] [WIP]: Load data and display them in the table --- .devcontainer/devcontainer.json | 11 +- site/src/api/api.ts | 7 + site/src/api/queries/organizations.ts | 7 + .../ProvisionersPage/ProvisionersPage.tsx | 96 ---------- .../OrganizationProvisionersPage.tsx | 167 ++++++++++++++++-- ...ganizationProvisionersPageView.stories.tsx | 142 --------------- .../OrganizationProvisionersPageView.tsx | 148 ---------------- site/src/router.tsx | 5 - site/src/utils/time.ts | 11 ++ 9 files changed, 187 insertions(+), 407 deletions(-) delete mode 100644 site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index de550f174bc9f..be0dbfd3e89e9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,5 +9,14 @@ } }, // SYS_PTRACE to enable go debugging - "runArgs": ["--cap-add=SYS_PTRACE"] + "runArgs": [ + "--cap-add=SYS_PTRACE" + ], + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome" + ] + } + } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5a314ddde151a..85cc2b6eff47f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2295,6 +2295,13 @@ class ApiMethods { ); return res.data; }; + + getProvisionerJobs = async (orgId: string) => { + const res = await this.axios.get( + `/api/v2/organizations/${orgId}/provisionerjobs`, + ); + return res.data; + }; } // 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..f99976fcd2422 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -244,6 +244,13 @@ export const organizationPermissions = (organizationId: string | undefined) => { }; }; +export const provisionerJobs = (orgId: string) => { + return { + queryKey: ["organization", orgId, "provisionerjobs"], + queryFn: () => API.getProvisionerJobs(orgId), + }; +}; + /** * Fetch permissions for all provided organizations. * diff --git a/site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx deleted file mode 100644 index 6bee4e485a12e..0000000000000 --- a/site/src/pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { AvatarFallback } from "@radix-ui/react-avatar"; -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 { Link } from "components/Link/Link"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "components/Table/Table"; -import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; -import { BanIcon } from "lucide-react"; -import type { FC } from "react"; -import { docs } from "utils/docs"; - -export const ProvisionersPage: FC = () => { - return ( -
-
-
-

Provisioners

-
-
- -
- - - - Jobs - - - Daemons - - - - -
- -
-
-
- ); -}; - -const JobsTabContent: FC = () => { - return ( -
-

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

- - - - - Last seen - Name - Template - Tags - Status - - - - - 5 min ago - - workspace_build - - -
- - Write Coder on Coder -
-
- - [foo=bar] - - Completed - - - -
-
-
-
- ); -}; - -export default ProvisionersPage; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx index 5a4965c039e1f..5f5227f36dc9f 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx @@ -1,25 +1,52 @@ import { buildInfo } from "api/queries/buildInfo"; -import { provisionerDaemonGroups } from "api/queries/organizations"; +import { + provisionerDaemonGroups, + provisionerJobs, +} from "api/queries/organizations"; +import type { Organization } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { TableEmpty } from "components/TableEmpty/TableEmpty"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { BanIcon } from "lucide-react"; 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 { docs } from "utils/docs"; import { pageTitle } from "utils/page"; -import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; +import { relativeTime } from "utils/time"; const OrganizationProvisionersPage: FC = () => { - const { organization: organizationName } = useParams() as { - organization: string; - }; + // 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)); + // const { entitlements } = useDashboard(); + // const { metadata } = useEmbeddedMetadata(); + // const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + // const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName)); if (!organization) { return ; @@ -35,14 +62,124 @@ const OrganizationProvisionersPage: FC = () => { )} - + +
+
+
+

Provisioners

+
+
+ +
+ + + + Jobs + + + Daemons + + + + +
+ +
+
+
); }; +type JobsTabContentProps = { + org: Organization; +}; + +const JobsTabContent: FC = ({ org }) => { + const { organization } = useOrganizationSettings(); + const { data: jobs, isLoadingError } = useQuery(provisionerJobs(org.id)); + + return ( +
+

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

+ + + + + Last seen + Type + Template + Tags + Status + + + + {jobs ? ( + jobs.length > 0 ? ( + jobs.map(({ metadata, ...job }) => { + if (!metadata) { + throw new Error( + `Metadata is required but it is missing in the job ${job.id}`, + ); + } + return ( + + + {relativeTime(new Date(job.created_at))} + + + {job.type} + + +
+ + {metadata.template_display_name ?? + metadata.template_name} +
+
+ + [foo=bar] + + Completed + + + + + + + Cancel job + + + +
+ ); + }) + ) : ( + + ) + ) : isLoadingError ? ( + + ) : ( + + )} +
+
+
+ ); +}; + 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/router.tsx b/site/src/router.tsx index c56bd1ee27ca1..acaf417cecbcd 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -304,10 +304,6 @@ const ChangePasswordPage = lazy( const IdpOrgSyncPage = lazy( () => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"), ); -const ProvisionersPage = lazy( - () => - import("./pages/DeploymentSettingsPage/ProvisionersPage/ProvisionersPage"), -); const RoutesWithSuspense = () => { return ( @@ -456,7 +452,6 @@ 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(); +} From 6e967f10ec5f61575c8b4531fa680b6f17056a35 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 10 Feb 2025 13:38:35 +0000 Subject: [PATCH 03/15] Update table to use API data --- .../OrganizationProvisionersPage.tsx | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx index 5f5227f36dc9f..f588086ece241 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx @@ -3,12 +3,17 @@ import { provisionerDaemonGroups, provisionerJobs, } from "api/queries/organizations"; -import type { Organization } from "api/typesGenerated"; +import type { Organization, ProvisionerJobStatus } 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 { + StatusIndicator, + StatusIndicatorDot, + type StatusIndicatorProps, +} from "components/StatusIndicator/StatusIndicator"; import { Table, TableBody, @@ -27,7 +32,7 @@ import { TooltipTrigger, } from "components/Tooltip/Tooltip"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { BanIcon } from "lucide-react"; +import { BanIcon, TriangleAlertIcon } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; @@ -110,7 +115,7 @@ const JobsTabContent: FC = ({ org }) => { - Last seen + Created Type Template Tags @@ -126,6 +131,9 @@ const JobsTabContent: FC = ({ org }) => { `Metadata is required but it is missing in the job ${job.id}`, ); } + + const canCancel = ["pending", "running"].includes(job.status); + return ( @@ -138,8 +146,11 @@ const JobsTabContent: FC = ({ org }) => {
{metadata.template_display_name ?? metadata.template_name} @@ -148,12 +159,28 @@ const JobsTabContent: FC = ({ org }) => { [foo=bar] - Completed + + + + + {job.status} + + {job.status === "failed" && ( + + )} + {job.status === "pending" && + `(${job.queue_position}/${job.queue_size})`} + +
@@ -101,7 +117,6 @@ type JobsTabContentProps = { }; const JobsTabContent: FC = ({ org }) => { - const { organization } = useOrganizationSettings(); const { data: jobs, isLoadingError } = useQuery(provisionerJobs(org.id)); return ( @@ -125,76 +140,7 @@ const JobsTabContent: FC = ({ org }) => { {jobs ? ( jobs.length > 0 ? ( - jobs.map(({ metadata, ...job }) => { - if (!metadata) { - throw new Error( - `Metadata is required but it is missing in the job ${job.id}`, - ); - } - - const canCancel = ["pending", "running"].includes(job.status); - - return ( - - - {relativeTime(new Date(job.created_at))} - - - {job.type} - - -
- - {metadata.template_display_name ?? - metadata.template_name} -
-
- - [foo=bar] - - - - - - {job.status} - - {job.status === "failed" && ( - - )} - {job.status === "pending" && - `(${job.queue_position}/${job.queue_size})`} - - - - - - - - - Cancel job - - - -
- ); - }) + jobs.map((j) => ) ) : ( ) @@ -209,6 +155,136 @@ const JobsTabContent: FC = ({ org }) => { ); }; +type JobRowProps = { + job: ProvisionerJob; +}; + +const JobRow: FC = ({ job }) => { + const metadata = job.metadata; + const canCancel = ["pending", "running"].includes(job.status); + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + + {job.type} + + + {job.metadata.template_name ? ( +
+ + {metadata.template_display_name ?? metadata.template_name} +
+ ) : ( + "Not linked to any template" + )} +
+ + [foo=bar] + + + + + {job.status} + {job.status === "failed" && ( + + )} + {job.status === "pending" && + `(${job.queue_position}/${job.queue_size})`} + + + + + + + + + Cancel job + + + +
+ + {isOpen && ( + + +
+ 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} + +
+
+
+ )} + + ); +}; + function statusIndicatorVariant( status: ProvisionerJobStatus, ): StatusIndicatorProps["variant"] { From 2bc6ccfe2d87c7bba15b8bb78e77e5704cd13eda Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 10 Feb 2025 14:51:34 +0000 Subject: [PATCH 05/15] Display tiny alert for error --- .../OrganizationProvisionersPage.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx index 2014369bad290..9c76989d2f0cb 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx @@ -247,6 +247,17 @@ const JobRow: FC = ({ job }) => { {isOpen && ( + {job.status === "failed" && ( +
+ + {job.error} +
+ )}
Date: Mon, 10 Feb 2025 16:47:49 +0000 Subject: [PATCH 06/15] Fix tags --- .../OrganizationProvisionersPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx index 9c76989d2f0cb..7fa761e022254 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx @@ -171,7 +171,7 @@ const JobRow: FC = ({ job }) => {
+ + + Last seen + Name + Template + Tags + Status + + + + {daemons ? ( + daemons.length > 0 ? ( + daemons.map((d) => ) + ) : ( + + ) + ) : isLoadingError ? ( + + ) : ( + + )} + +
+ + ); +}; + +type DaemonRowProps = { + daemon: ProvisionerDaemon; +}; + +const DaemonRow: FC = ({ daemon }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + + {daemon.name} + Template + +
+ {Object.entries(daemon.tags).map(([k, v]) => ( + + [{k} + {v && `=${v}`}] + + ))} +
+
+ + + + {daemon.status} + + +
+ + {isOpen && ( + + +
+ Last seen: + {daemon.last_seen_at} + + Creation time: + {daemon.created_at} + + Version: + {daemon.version} + + {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( + status: ProvisionerDaemonStatus | null, +): StatusIndicatorProps["variant"] { + switch (status) { + case "idle": + return "success"; + case "busy": + return "pending"; + case "offline": + case null: + return "inactive"; + } +} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx similarity index 63% rename from site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx rename to site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index 7fa761e022254..e0cccce8551c3 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -1,23 +1,9 @@ -import { buildInfo } from "api/queries/buildInfo"; -import { - provisionerDaemonGroups, - provisionerJobs, -} from "api/queries/organizations"; -import type { - Organization, - ProvisionerJob, - ProvisionerJobStatus, -} from "api/typesGenerated"; +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 { - StatusIndicator, - StatusIndicatorDot, - type StatusIndicatorProps, -} from "components/StatusIndicator/StatusIndicator"; import { Table, TableBody, @@ -28,95 +14,30 @@ import { } from "components/Table/Table"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; import { TableLoader } from "components/TableLoader/TableLoader"; -import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { BanIcon, ChevronDownIcon, ChevronRightIcon, - Tangent, TriangleAlertIcon, } from "lucide-react"; -import { useDashboard } from "modules/dashboard/useDashboard"; -import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { useState, type FC } from "react"; -import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; -import { useParams } from "react-router-dom"; import { cn } from "utils/cn"; import { docs } from "utils/docs"; -import { pageTitle } from "utils/page"; import { relativeTime } from "utils/time"; +import { JobStatusIndicator } from "./JobStatusIndicator"; -const OrganizationProvisionersPage: FC = () => { - // const { organization: organizationName } = useParams() as { - // organization: string; - // }; - const { organization } = useOrganizationSettings(); - const tab = useSearchParamsKey({ - key: "tab", - defaultValue: "jobs", - }); - // 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, - )} - - - -
-
-
-

Provisioners

-
-
- -
- - - - Jobs - - - Daemons - - - - -
- {tab.value === "jobs" && } -
-
-
- - ); -}; - -type JobsTabContentProps = { +type ProvisionerJobsPageProps = { org: Organization; }; -const JobsTabContent: FC = ({ org }) => { +export const ProvisionerJobsPage: FC = ({ org }) => { const { data: jobs, isLoadingError } = useQuery(provisionerJobs(org.id)); return ( @@ -135,6 +56,7 @@ const JobsTabContent: FC = ({ org }) => { Template Tags Status + @@ -219,18 +141,7 @@ const JobRow: FC = ({ job }) => {
- - - {job.status} - {job.status === "failed" && ( - - )} - {job.status === "pending" && - `(${job.queue_position}/${job.queue_size})`} - + @@ -302,23 +213,3 @@ const JobRow: FC = ({ job }) => { ); }; - -function statusIndicatorVariant( - status: ProvisionerJobStatus, -): StatusIndicatorProps["variant"] { - switch (status) { - case "succeeded": - return "success"; - case "failed": - return "failed"; - case "pending": - case "running": - case "canceling": - return "pending"; - case "canceled": - case "unknown": - return "inactive"; - } -} - -export default OrganizationProvisionersPage; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx new file mode 100644 index 0000000000000..3751015d1830f --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -0,0 +1,64 @@ +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 { ProvisionerJobsPage } from "./ProvisionerJobsPage"; +import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage"; + +const ProvisionersPage: FC = () => { + const { organization } = useOrganizationSettings(); + const tab = useSearchParamsKey({ + key: "tab", + defaultValue: "jobs", + }); + + if (!organization) { + return ; + } + + 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/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( } /> } /> - } - /> + } /> } /> } /> From d66141e74b7d3404466e2754b7fb2cce22534f11 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 11 Feb 2025 19:57:26 +0000 Subject: [PATCH 08/15] Display all daemon data from server --- site/src/api/queries/organizations.ts | 1 + .../ProvisionersPage/DataGrid.tsx | 28 ++++ .../ProvisionerDaemonsPage.tsx | 137 +++++++++++++----- .../ProvisionersPage/ProvisionerJobsPage.tsx | 53 ++++--- .../ProvisionersPage/Tags.tsx | 52 +++++++ 5 files changed, 212 insertions(+), 59 deletions(-) create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index f99976fcd2422..b288e5336e3bb 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -3,6 +3,7 @@ import type { AuthorizationResponse, CreateOrganizationRequest, GroupSyncSettings, + ProvisionerDaemon, RoleSyncSettings, UpdateOrganizationRequest, } from "api/typesGenerated"; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx new file mode 100644 index 0000000000000..bea97aebc466c --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/DataGrid.tsx @@ -0,0 +1,28 @@ +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/ProvisionerDaemonsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx index a01ac63680216..68453b02d30b5 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx @@ -1,10 +1,5 @@ import { provisionerDaemons } from "api/queries/organizations"; -import type { - Organization, - ProvisionerDaemon, - ProvisionerDaemonStatus, -} from "api/typesGenerated"; -import { Badge } from "components/Badge/Badge"; +import type { Organization, ProvisionerDaemon } from "api/typesGenerated"; import { Link } from "components/Link/Link"; import { StatusIndicator, @@ -20,7 +15,6 @@ import { TableRow, } from "components/Table/Table"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; -import { TableLoader } from "components/TableLoader/TableLoader"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { useState, type FC } from "react"; import { useQuery } from "react-query"; @@ -28,6 +22,11 @@ import { cn } from "utils/cn"; import { docs } from "utils/docs"; import { relativeTime } from "utils/time"; import { JobStatusIndicator } from "./JobStatusIndicator"; +import { Avatar } from "components/Avatar/Avatar"; +import { DataGrid, DataGridSpace } from "./DataGrid"; +import { ShrinkTags, Tag, Tags } from "./Tags"; +import { Loader } from "components/Loader/Loader"; +import { EmptyState } from "components/EmptyState/EmptyState"; type ProvisionerDaemonsPageProps = { org: Organization; @@ -36,9 +35,18 @@ type ProvisionerDaemonsPageProps = { export const ProvisionerDaemonsPage: FC = ({ org, }) => { - const { data: daemons, isLoadingError } = useQuery( - provisionerDaemons(org.id), - ); + const { data: daemons, isLoadingError } = useQuery({ + ...provisionerDaemons(org.id), + select: (data) => + data.toSorted((a, b) => { + 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 (
@@ -69,12 +77,24 @@ export const ProvisionerDaemonsPage: FC = ({ daemons.length > 0 ? ( daemons.map((d) => ) ) : ( - + + + + + ) ) : isLoadingError ? ( - + + + + + ) : ( - + + + + + )} @@ -116,25 +136,38 @@ const DaemonRow: FC = ({ daemon }) => { - {daemon.name} - Template -
- {Object.entries(daemon.tags).map(([k, v]) => ( - - [{k} - {v && `=${v}`}] - - ))} -
+ + {daemon.name} +
- + {daemon.current_job ? ( +
+ + {daemon.current_job.template_display_name ?? + daemon.current_job.template_name} +
+ ) : ( + Not linked + )} +
+ + + + + - {daemon.status} + + {statusLabel(daemon)} + @@ -142,13 +175,7 @@ const DaemonRow: FC = ({ daemon }) => { {isOpen && ( -
+ Last seen: {daemon.last_seen_at} @@ -158,8 +185,19 @@ const DaemonRow: FC = ({ daemon }) => { Version: {daemon.version} + Tags: + + + {Object.entries(daemon.tags).map(([key, value]) => ( + + ))} + + + {daemon.current_job && ( <> + + Last job: {daemon.current_job.id} @@ -172,6 +210,8 @@ const DaemonRow: FC = ({ daemon }) => { {daemon.previous_job && ( <> + + Previous job: {daemon.previous_job.id} @@ -181,7 +221,7 @@ const DaemonRow: FC = ({ daemon }) => { )} -
+
)} @@ -190,9 +230,13 @@ const DaemonRow: FC = ({ daemon }) => { }; function statusIndicatorVariant( - status: ProvisionerDaemonStatus | null, + daemon: ProvisionerDaemon, ): StatusIndicatorProps["variant"] { - switch (status) { + if (daemon.previous_job && daemon.previous_job.status === "failed") { + return "failed"; + } + + switch (daemon.status) { case "idle": return "success"; case "busy": @@ -202,3 +246,20 @@ function statusIndicatorVariant( 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"; + case null: + return "Unknown"; + } +} diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index e0cccce8551c3..95b42fc07bdef 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -13,7 +13,6 @@ import { TableRow, } from "components/Table/Table"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; -import { TableLoader } from "components/TableLoader/TableLoader"; import { Tooltip, TooltipContent, @@ -32,6 +31,10 @@ import { cn } from "utils/cn"; import { docs } from "utils/docs"; import { relativeTime } from "utils/time"; import { JobStatusIndicator } from "./JobStatusIndicator"; +import { DataGrid } from "./DataGrid"; +import { ShrinkTags, Tag, Tags } from "./Tags"; +import { Loader } from "components/Loader/Loader"; +import { EmptyState } from "components/EmptyState/EmptyState"; type ProvisionerJobsPageProps = { org: Organization; @@ -64,12 +67,24 @@ export const ProvisionerJobsPage: FC = ({ org }) => { jobs.length > 0 ? ( jobs.map((j) => ) ) : ( - + + + + + ) ) : isLoadingError ? ( - + + + + + ) : ( - + + + + + )} @@ -127,18 +142,11 @@ const JobRow: FC = ({ job }) => { {metadata.template_display_name ?? metadata.template_name}
) : ( - "Not linked to any template" + Not linked )} -
- {Object.entries(job.tags).map(([k, v]) => ( - - [{k} - {v && `=${v}`}] - - ))} -
+
@@ -176,13 +184,7 @@ const JobRow: FC = ({ job }) => { {job.error}
)} -
+ Job ID: {job.id} @@ -206,7 +208,16 @@ const JobRow: FC = ({ job }) => { {job.queue_position}/{job.queue_size} -
+ + Tags: + + + {Object.entries(job.tags).map(([key, value]) => ( + + ))} + + +
)} diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx new file mode 100644 index 0000000000000..7cb48b22df1a6 --- /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 ShrinkTags: FC = ({ tags }) => { + const keys = Object.keys(tags); + + if (keys.length === 0) { + return null; + } + + const firstKey = keys[0]; + const firstValue = tags[firstKey]; + const restKeys = keys.slice(1); + + return ( + + + {restKeys.length > 0 && +{restKeys.length}} + + ); +}; From 49a7ec7ab7e0458f02d3df398ac96340e3981371 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 11 Feb 2025 19:57:46 +0000 Subject: [PATCH 09/15] Remove unused imports --- .../ProvisionersPage/ProvisionerDaemonsPage.tsx | 1 - .../ProvisionersPage/ProvisionerJobsPage.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx index 68453b02d30b5..bce739417088e 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx @@ -14,7 +14,6 @@ import { TableHeader, TableRow, } from "components/Table/Table"; -import { TableEmpty } from "components/TableEmpty/TableEmpty"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { useState, type FC } from "react"; import { useQuery } from "react-query"; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index 95b42fc07bdef..ee5645585e145 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -12,7 +12,6 @@ import { TableHeader, TableRow, } from "components/Table/Table"; -import { TableEmpty } from "components/TableEmpty/TableEmpty"; import { Tooltip, TooltipContent, From ffee2ed2f8af00f2d3b5a529cd135b9e09946d3c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 11 Feb 2025 20:05:17 +0000 Subject: [PATCH 10/15] Run fmt --- .../ProvisionersPage/ProvisionerDaemonsPage.tsx | 10 +++++----- .../ProvisionersPage/ProvisionerJobsPage.tsx | 8 ++++---- .../ProvisionersPage/ProvisionersPage.tsx | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx index bce739417088e..ed1603092f3ac 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx @@ -1,6 +1,9 @@ import { provisionerDaemons } from "api/queries/organizations"; import type { Organization, ProvisionerDaemon } from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { EmptyState } from "components/EmptyState/EmptyState"; import { Link } from "components/Link/Link"; +import { Loader } from "components/Loader/Loader"; import { StatusIndicator, StatusIndicatorDot, @@ -15,17 +18,14 @@ import { TableRow, } from "components/Table/Table"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; -import { useState, type FC } from "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 { JobStatusIndicator } from "./JobStatusIndicator"; -import { Avatar } from "components/Avatar/Avatar"; import { DataGrid, DataGridSpace } from "./DataGrid"; +import { JobStatusIndicator } from "./JobStatusIndicator"; import { ShrinkTags, Tag, Tags } from "./Tags"; -import { Loader } from "components/Loader/Loader"; -import { EmptyState } from "components/EmptyState/EmptyState"; type ProvisionerDaemonsPageProps = { org: Organization; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index ee5645585e145..c64e84e6bbf48 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -3,7 +3,9 @@ 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, @@ -24,16 +26,14 @@ import { ChevronRightIcon, TriangleAlertIcon, } from "lucide-react"; -import { useState, type FC } from "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 { JobStatusIndicator } from "./JobStatusIndicator"; import { DataGrid } from "./DataGrid"; +import { JobStatusIndicator } from "./JobStatusIndicator"; import { ShrinkTags, Tag, Tags } from "./Tags"; -import { Loader } from "components/Loader/Loader"; -import { EmptyState } from "components/EmptyState/EmptyState"; type ProvisionerJobsPageProps = { org: Organization; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx index 3751015d1830f..1612073481ead 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -5,8 +5,8 @@ import { useOrganizationSettings } from "modules/management/OrganizationSettings import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { pageTitle } from "utils/page"; -import { ProvisionerJobsPage } from "./ProvisionerJobsPage"; import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage"; +import { ProvisionerJobsPage } from "./ProvisionerJobsPage"; const ProvisionersPage: FC = () => { const { organization } = useOrganizationSettings(); From 7802636aebe26a6f4a5d73bfbf2dbec25ada85b1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 12 Feb 2025 18:32:13 +0000 Subject: [PATCH 11/15] Add cancel provisioner job --- site/src/api/api.ts | 36 +++++++ site/src/api/queries/organizations.ts | 9 +- .../CancelJobButton.stories.tsx | 46 +++++++++ .../ProvisionersPage/CancelJobButton.tsx | 55 +++++++++++ .../CancelJobConfirmationDialog.stories.tsx | 98 +++++++++++++++++++ .../CancelJobConfirmationDialog.tsx | 69 +++++++++++++ .../ProvisionersPage/ProvisionerJobsPage.tsx | 26 +---- 7 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2561248c5bf00..0dcef564f563e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1247,6 +1247,17 @@ class ApiMethods { return response.data; }; + cancelTemplateVersionDryRun = async ( + templateVersionId: TypesGen.TemplateVersion["id"], + 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 => { @@ -2302,6 +2313,31 @@ class ApiMethods { ); 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 b288e5336e3bb..70cd57628f578 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -3,7 +3,6 @@ import type { AuthorizationResponse, CreateOrganizationRequest, GroupSyncSettings, - ProvisionerDaemon, RoleSyncSettings, UpdateOrganizationRequest, } from "api/typesGenerated"; @@ -245,9 +244,15 @@ export const organizationPermissions = (organizationId: string | undefined) => { }; }; +export const provisionerJobQueryKey = (orgId: string) => [ + "organization", + orgId, + "provisionerjobs", +]; + export const provisionerJobs = (orgId: string) => { return { - queryKey: ["organization", orgId, "provisionerjobs"], + queryKey: provisionerJobQueryKey(orgId), queryFn: () => API.getProvisionerJobs(orgId), }; }; 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..e5bb0d2ce4ff4 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CancelJobButton } from "./CancelJobButton"; +import { MockProvisionerJob } from "testHelpers/entities"; +import { userEvent, waitFor, within } from "@storybook/test"; + +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..a58fc568b0bad --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx @@ -0,0 +1,55 @@ +import { useState, type FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { Button } from "components/Button/Button"; +import { BanIcon } from "lucide-react"; +import type { ProvisionerJob } from "api/typesGenerated"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; + +type CancelJobButtonProps = { + job: ProvisionerJob; +}; + +export const CancelJobButton: FC = ({ job }) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const cancellable = ["pending", "running"].includes(job.status); + + return ( + <> + + + + + + Cancel job + + + + { + setIsDialogOpen(false); + }} + open={isDialogOpen} + title="Cancel provisioner job" + description={`Are you sure you want to cancel the provisioner job "${job.id}"? This operation will result in the associated workspaces not getting created.`} + confirmText="Confirm" + cancelText="Discard" + /> + + ); +}; 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..4dbe348b98b9d --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog"; +import { MockProvisionerJob } from "testHelpers/entities"; +import { fn, userEvent, within, expect, waitFor } from "@storybook/test"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import type { Response } from "api/typesGenerated"; + +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..bcfde3cf4f184 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx @@ -0,0 +1,69 @@ +import type { ProvisionerJob } from "api/typesGenerated"; +import { + ConfirmDialog, + type ConfirmDialogProps, +} from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import type { FC } from "react"; +import { API } from "api/api"; +import { useMutation, useQueryClient } from "react-query"; +import { + getProvisionerDaemonsKey, + provisionerJobQueryKey, +} from "api/queries/organizations"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; + +type CancelJobConfirmationDialogProps = Omit< + ConfirmDialogProps, + | "type" + | "title" + | "description" + | "confirmText" + | "cancelText" + | "onConfirm" + | "confirmLoading" +> & { + 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/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index c64e84e6bbf48..52a28b5444638 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -2,7 +2,6 @@ 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"; @@ -15,13 +14,6 @@ import { TableRow, } from "components/Table/Table"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "components/Tooltip/Tooltip"; -import { - BanIcon, ChevronDownIcon, ChevronRightIcon, TriangleAlertIcon, @@ -34,6 +26,7 @@ import { relativeTime } from "utils/time"; import { DataGrid } from "./DataGrid"; import { JobStatusIndicator } from "./JobStatusIndicator"; import { ShrinkTags, Tag, Tags } from "./Tags"; +import { CancelJobButton } from "./CancelJobButton"; type ProvisionerJobsPageProps = { org: Organization; @@ -97,7 +90,6 @@ type JobRowProps = { const JobRow: FC = ({ job }) => { const metadata = job.metadata; - const canCancel = ["pending", "running"].includes(job.status); const [isOpen, setIsOpen] = useState(false); return ( @@ -151,21 +143,7 @@ const JobRow: FC = ({ job }) => { - - - - - - Cancel job - - + From 4f9030fa63687c32b12c3142ba51a7e87db3d59e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 12 Feb 2025 18:34:32 +0000 Subject: [PATCH 12/15] Run fmt --- .../ProvisionersPage/CancelJobButton.stories.tsx | 4 ++-- .../ProvisionersPage/CancelJobButton.tsx | 8 ++++---- .../CancelJobConfirmationDialog.stories.tsx | 6 +++--- .../ProvisionersPage/CancelJobConfirmationDialog.tsx | 12 ++++++------ .../ProvisionersPage/ProvisionerJobsPage.tsx | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx index e5bb0d2ce4ff4..337149f17639c 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { CancelJobButton } from "./CancelJobButton"; -import { MockProvisionerJob } from "testHelpers/entities"; import { userEvent, waitFor, within } from "@storybook/test"; +import { MockProvisionerJob } from "testHelpers/entities"; +import { CancelJobButton } from "./CancelJobButton"; const meta: Meta = { title: "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton", diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx index a58fc568b0bad..7c20f4636dcf3 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx @@ -1,14 +1,14 @@ -import { useState, type FC } from "react"; +import type { ProvisionerJob } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { Button } from "components/Button/Button"; import { BanIcon } from "lucide-react"; -import type { ProvisionerJob } from "api/typesGenerated"; -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { type FC, useState } from "react"; type CancelJobButtonProps = { job: ProvisionerJob; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx index 4dbe348b98b9d..8d48fe6d80d1a 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog"; +import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; +import type { Response } from "api/typesGenerated"; import { MockProvisionerJob } from "testHelpers/entities"; -import { fn, userEvent, within, expect, waitFor } from "@storybook/test"; import { withGlobalSnackbar } from "testHelpers/storybook"; -import type { Response } from "api/typesGenerated"; +import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog"; const meta: Meta = { title: diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx index bcfde3cf4f184..b8741e0527ffe 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog.tsx @@ -1,16 +1,16 @@ +import { API } from "api/api"; +import { + getProvisionerDaemonsKey, + provisionerJobQueryKey, +} from "api/queries/organizations"; import type { ProvisionerJob } from "api/typesGenerated"; import { ConfirmDialog, type ConfirmDialogProps, } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; -import { API } from "api/api"; import { useMutation, useQueryClient } from "react-query"; -import { - getProvisionerDaemonsKey, - provisionerJobQueryKey, -} from "api/queries/organizations"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; type CancelJobConfirmationDialogProps = Omit< ConfirmDialogProps, diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index 52a28b5444638..e82fa302e02e9 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -23,10 +23,10 @@ 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 { ShrinkTags, Tag, Tags } from "./Tags"; -import { CancelJobButton } from "./CancelJobButton"; type ProvisionerJobsPageProps = { org: Organization; From 59539607bc6cd52c9cd3747c8b4cda674e2aa157 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 14 Feb 2025 17:19:49 +0000 Subject: [PATCH 13/15] Apply PR reviews --- site/src/api/api.ts | 4 +- .../ProvisionersPage/CancelJobButton.tsx | 20 +++-- .../CancelJobConfirmationDialog.tsx | 20 ++--- .../ProvisionersPage/DataGrid.tsx | 13 ++-- .../ProvisionersPage/JobStatusIndicator.tsx | 56 ++++++++------ .../ProvisionerDaemonsPage.tsx | 74 +++++++++++-------- .../ProvisionersPage/ProvisionerJobsPage.tsx | 58 +++++++++------ .../ProvisionersPage/ProvisionersPage.tsx | 15 +++- .../ProvisionersPage/Tags.tsx | 6 +- 9 files changed, 146 insertions(+), 120 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0dcef564f563e..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`, @@ -1248,7 +1248,7 @@ class ApiMethods { }; cancelTemplateVersionDryRun = async ( - templateVersionId: TypesGen.TemplateVersion["id"], + templateVersionId: string, jobId: string, ): Promise => { const response = await this.axios.patch( diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx index 7c20f4636dcf3..4c024911ee23f 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton.tsx @@ -1,6 +1,5 @@ import type { ProvisionerJob } from "api/typesGenerated"; import { Button } from "components/Button/Button"; -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { Tooltip, TooltipContent, @@ -9,6 +8,9 @@ import { } 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; @@ -16,7 +18,7 @@ type CancelJobButtonProps = { export const CancelJobButton: FC = ({ job }) => { const [isDialogOpen, setIsDialogOpen] = useState(false); - const cancellable = ["pending", "running"].includes(job.status); + const isCancellable = CANCELLABLE.includes(job.status); return ( <> @@ -24,7 +26,7 @@ export const CancelJobButton: FC = ({ job }) => { } + /> ) : ( @@ -128,6 +138,7 @@ const DaemonRow: FC = ({ daemon }) => { ) : ( )} + ({isOpen ? "Hide" : "Show more"}) {relativeTime( new Date(daemon.last_seen_at ?? new Date().toISOString()), @@ -159,7 +170,7 @@ const DaemonRow: FC = ({ daemon }) => { )} - + @@ -175,35 +186,35 @@ const DaemonRow: FC = ({ daemon }) => { - Last seen: - {daemon.last_seen_at} +
Last seen:
+
{daemon.last_seen_at}
- Creation time: - {daemon.created_at} +
Creation time:
+
{daemon.created_at}
- Version: - {daemon.version} +
Version:
+
{daemon.version}
- Tags: - +
Tags:
+
{Object.entries(daemon.tags).map(([key, value]) => ( ))} - +
{daemon.current_job && ( <> - Last job: - {daemon.current_job.id} +
Last job:
+
{daemon.current_job.id}
- Last job state: - - - +
Last job state:
+
+ +
)} @@ -211,13 +222,13 @@ const DaemonRow: FC = ({ daemon }) => { <> - Previous job: - {daemon.previous_job.id} +
Previous job:
+
{daemon.previous_job.id}
- Previous job state: - - - +
Previous job state:
+
+ +
)}
@@ -240,8 +251,7 @@ function statusIndicatorVariant( return "success"; case "busy": return "pending"; - case "offline": - case null: + default: return "inactive"; } } @@ -258,7 +268,7 @@ function statusLabel(daemon: ProvisionerDaemon) { return "Busy..."; case "offline": return "Disconnected"; - case null: + default: return "Unknown"; } } diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index e82fa302e02e9..93644d7b8be27 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -26,17 +26,25 @@ import { relativeTime } from "utils/time"; import { CancelJobButton } from "./CancelJobButton"; import { DataGrid } from "./DataGrid"; import { JobStatusIndicator } from "./JobStatusIndicator"; -import { ShrinkTags, Tag, Tags } from "./Tags"; +import { TruncateTags, Tag, Tags } from "./Tags"; +import { Button } from "components/Button/Button"; type ProvisionerJobsPageProps = { - org: Organization; + orgId: string; }; -export const ProvisionerJobsPage: FC = ({ org }) => { - const { data: jobs, isLoadingError } = useQuery(provisionerJobs(org.id)); +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.{" "} @@ -68,7 +76,10 @@ export const ProvisionerJobsPage: FC = ({ org }) => { ) : isLoadingError ? ( - + refetch()}>Retry} + /> ) : ( @@ -112,6 +123,7 @@ const JobRow: FC = ({ job }) => { ) : ( )} + ({isOpen ? "Hide" : "Show more"}) {relativeTime(new Date(job.created_at))} @@ -137,7 +149,7 @@ const JobRow: FC = ({ job }) => { )} - + @@ -162,38 +174,38 @@ const JobRow: FC = ({ job }) => {

)} - Job ID: - {job.id} +
Job ID:
+
{job.id}
- Available provisioners: - +
Available provisioners:
+
{job.available_workers ? JSON.stringify(job.available_workers) : "[]"} - +
- Completed by provisioner: - {job.worker_id} +
Completed by provisioner:
+
{job.worker_id}
- Associated workspace: - {job.metadata.workspace_name ?? "null"} +
Associated workspace:
+
{job.metadata.workspace_name ?? "null"}
- Creation time: - {job.created_at} +
Creation time:
+
{job.created_at}
- Queue: - +
Queue:
+
{job.queue_position}/{job.queue_size} - +
- Tags: - +
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 index 1612073481ead..871eb7b91fa0f 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -16,7 +16,14 @@ const ProvisionersPage: FC = () => { }); if (!organization) { - return ; + return ( + <> + + {pageTitle("Provisioners")} + + + + ); } return ( @@ -50,9 +57,11 @@ const ProvisionersPage: FC = () => {
- {tab.value === "jobs" && } + {tab.value === "jobs" && ( + + )} {tab.value === "daemons" && ( - + )}
diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx index 7cb48b22df1a6..449aa25593f1c 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/Tags.tsx @@ -32,7 +32,7 @@ type TagsProps = { tags: Record; }; -export const ShrinkTags: FC = ({ tags }) => { +export const TruncateTags: FC = ({ tags }) => { const keys = Object.keys(tags); if (keys.length === 0) { @@ -41,12 +41,12 @@ export const ShrinkTags: FC = ({ tags }) => { const firstKey = keys[0]; const firstValue = tags[firstKey]; - const restKeys = keys.slice(1); + const remainderCount = keys.length - 1; return ( - {restKeys.length > 0 && +{restKeys.length}} + {remainderCount > 0 && +{remainderCount}} ); }; From aabf8dfcc5ccab16fc6b9f462c0fd03193564f80 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 14 Feb 2025 17:20:08 +0000 Subject: [PATCH 14/15] FMT --- .../ProvisionersPage/ProvisionerDaemonsPage.tsx | 4 ++-- .../ProvisionersPage/ProvisionerJobsPage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx index 49b50471b6f30..93d670eb9b42a 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerDaemonsPage.tsx @@ -1,6 +1,7 @@ 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"; @@ -25,8 +26,7 @@ import { docs } from "utils/docs"; import { relativeTime } from "utils/time"; import { DataGrid, DataGridSpace } from "./DataGrid"; import { DaemonJobStatusIndicator } from "./JobStatusIndicator"; -import { TruncateTags, Tag, Tags } from "./Tags"; -import { Button } from "components/Button/Button"; +import { Tag, Tags, TruncateTags } from "./Tags"; type ProvisionerDaemonsPageProps = { orgId: string; diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx index 93644d7b8be27..e852e90f2cf7f 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionerJobsPage.tsx @@ -2,6 +2,7 @@ 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"; @@ -26,8 +27,7 @@ import { relativeTime } from "utils/time"; import { CancelJobButton } from "./CancelJobButton"; import { DataGrid } from "./DataGrid"; import { JobStatusIndicator } from "./JobStatusIndicator"; -import { TruncateTags, Tag, Tags } from "./Tags"; -import { Button } from "components/Button/Button"; +import { Tag, Tags, TruncateTags } from "./Tags"; type ProvisionerJobsPageProps = { orgId: string; From ed61ce785b4da779bc447a8a641f9f353a5679f1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 18 Feb 2025 14:17:58 +0000 Subject: [PATCH 15/15] Reset devcontainer.json --- .devcontainer/devcontainer.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index be0dbfd3e89e9..de550f174bc9f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,14 +9,5 @@ } }, // SYS_PTRACE to enable go debugging - "runArgs": [ - "--cap-add=SYS_PTRACE" - ], - "customizations": { - "vscode": { - "extensions": [ - "biomejs.biome" - ] - } - } + "runArgs": ["--cap-add=SYS_PTRACE"] }