mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add provisioner jobs into the UI (#16867)
- Add provisioner jobs back, but as a sub page of the organization settings - Add missing storybook tests to the components Related to https://github.com/coder/coder/issues/15192.
This commit is contained in:
@@ -12,7 +12,7 @@ export const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-surface-secondary text-content-secondary shadow hover:bg-surface-tertiary",
|
||||
"border-transparent bg-surface-secondary text-content-secondary shadow",
|
||||
},
|
||||
size: {
|
||||
sm: "text-2xs font-regular",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const OrganizationSettingsContext = createContext<
|
||||
OrganizationSettingsValue | undefined
|
||||
>(undefined);
|
||||
|
||||
type OrganizationSettingsValue = Readonly<{
|
||||
export type OrganizationSettingsValue = Readonly<{
|
||||
organizations: readonly Organization[];
|
||||
organizationPermissionsByOrganizationId: Record<
|
||||
string,
|
||||
@@ -36,9 +36,10 @@ type OrganizationSettingsValue = Readonly<{
|
||||
|
||||
export const useOrganizationSettings = (): OrganizationSettingsValue => {
|
||||
const context = useContext(OrganizationSettingsContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useOrganizationSettings should be used inside of OrganizationSettingsLayout",
|
||||
"useOrganizationSettings should be used inside of OrganizationSettingsLayout or with the default values in case of testing.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -186,11 +186,18 @@ const OrganizationSettingsNavigation: FC<
|
||||
)}
|
||||
{orgPermissions.viewProvisioners &&
|
||||
orgPermissions.viewProvisionerJobs && (
|
||||
<SettingsSidebarNavItem
|
||||
href={urlForSubpage(organization.name, "provisioners")}
|
||||
>
|
||||
Provisioners
|
||||
</SettingsSidebarNavItem>
|
||||
<>
|
||||
<SettingsSidebarNavItem
|
||||
href={urlForSubpage(organization.name, "provisioners")}
|
||||
>
|
||||
Provisioners
|
||||
</SettingsSidebarNavItem>
|
||||
<SettingsSidebarNavItem
|
||||
href={urlForSubpage(organization.name, "provisioner-jobs")}
|
||||
>
|
||||
Provisioner Jobs
|
||||
</SettingsSidebarNavItem>
|
||||
</>
|
||||
)}
|
||||
{orgPermissions.viewIdpSyncSettings && (
|
||||
<SettingsSidebarNavItem
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { MockProvisionerJob } from "testHelpers/entities";
|
||||
import { CancelJobButton } from "./CancelJobButton";
|
||||
|
||||
const meta: Meta<typeof CancelJobButton> = {
|
||||
title: "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton",
|
||||
title: "pages/OrganizationProvisionerJobsPage/CancelJobButton",
|
||||
component: CancelJobButton,
|
||||
args: {
|
||||
job: {
|
||||
@@ -28,7 +28,7 @@ export const NotCancellable: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OnClick: Story = {
|
||||
export const ConfirmOnClick: Story = {
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
+3
-4
@@ -6,8 +6,7 @@ import { withGlobalSnackbar } from "testHelpers/storybook";
|
||||
import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog";
|
||||
|
||||
const meta: Meta<typeof CancelJobConfirmationDialog> = {
|
||||
title:
|
||||
"pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog",
|
||||
title: "pages/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog",
|
||||
component: CancelJobConfirmationDialog,
|
||||
args: {
|
||||
open: true,
|
||||
@@ -40,7 +39,7 @@ export const OnCancel: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const onConfirmSuccess: Story = {
|
||||
export const OnConfirmSuccess: Story = {
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
@@ -60,7 +59,7 @@ export const onConfirmSuccess: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const onConfirmFailure: Story = {
|
||||
export const OnConfirmFailure: Story = {
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, waitFor, within } from "@storybook/test";
|
||||
import { Table, TableBody } from "components/Table/Table";
|
||||
import { MockProvisionerJob } from "testHelpers/entities";
|
||||
import { daysAgo } from "utils/time";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
const meta: Meta<typeof JobRow> = {
|
||||
title: "pages/OrganizationProvisionerJobsPage/JobRow",
|
||||
component: JobRow,
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
created_at: daysAgo(2),
|
||||
},
|
||||
},
|
||||
render: (args) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<JobRow {...args} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof JobRow>;
|
||||
|
||||
export const Close: Story = {};
|
||||
|
||||
export const OpenOnClick: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const showMoreButton = canvas.getByRole("button", { name: /show more/i });
|
||||
|
||||
await userEvent.click(showMoreButton);
|
||||
|
||||
const jobId = canvas.getByText(args.job.id);
|
||||
expect(jobId).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const HideOnClick: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const showMoreButton = canvas.getByRole("button", { name: /show more/i });
|
||||
await userEvent.click(showMoreButton);
|
||||
|
||||
const hideButton = canvas.getByRole("button", { name: /hide/i });
|
||||
await userEvent.click(hideButton);
|
||||
|
||||
const jobId = canvas.queryByText(args.job.id);
|
||||
expect(jobId).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
+20
-99
@@ -1,105 +1,24 @@
|
||||
import { provisionerJobs } from "api/queries/organizations";
|
||||
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 { 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 { TableCell, 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<ProvisionerJobsPageProps> = ({
|
||||
orgId,
|
||||
}) => {
|
||||
const {
|
||||
data: jobs,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
} = useQuery(provisionerJobs(orgId));
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-8">
|
||||
<h2 className="sr-only">Provisioner jobs</h2>
|
||||
<p className="text-sm text-content-secondary m-0 mt-2">
|
||||
Provisioner Jobs are the individual tasks assigned to Provisioners when
|
||||
the workspaces are being built.{" "}
|
||||
<Link href={docs("/admin/provisioners")}>View docs</Link>
|
||||
</p>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Template</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs ? (
|
||||
jobs.length > 0 ? (
|
||||
jobs.map((j) => <JobRow key={j.id} job={j} />)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No provisioner jobs found" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : isLoadingError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="Error loading the provisioner jobs"
|
||||
cta={<Button onClick={() => refetch()}>Retry</Button>}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type JobRowProps = {
|
||||
job: ProvisionerJob;
|
||||
};
|
||||
|
||||
const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
export const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
const metadata = job.metadata;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -133,20 +52,16 @@ const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
<Badge size="sm">{job.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{job.metadata.template_name ? (
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
src={metadata.template_icon}
|
||||
fallback={
|
||||
metadata.template_display_name || metadata.template_name
|
||||
}
|
||||
/>
|
||||
{metadata.template_display_name ?? metadata.template_name}
|
||||
</div>
|
||||
) : (
|
||||
<span className="whitespace-nowrap">Not linked</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
src={metadata.template_icon}
|
||||
fallback={
|
||||
metadata.template_display_name || metadata.template_name
|
||||
}
|
||||
/>
|
||||
{metadata.template_display_name || metadata.template_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TruncateTags tags={job.tags} />
|
||||
@@ -173,7 +88,13 @@ const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
<span className="[&:first-letter]:uppercase">{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<DataGrid>
|
||||
<dl
|
||||
className={cn([
|
||||
"text-xs text-content-secondary",
|
||||
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
|
||||
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
|
||||
])}
|
||||
>
|
||||
<dt>Job ID:</dt>
|
||||
<dd>{job.id}</dd>
|
||||
|
||||
@@ -206,7 +127,7 @@ const JobRow: FC<JobRowProps> = ({ job }) => {
|
||||
))}
|
||||
</Tags>
|
||||
</dd>
|
||||
</DataGrid>
|
||||
</dl>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockProvisionerJob } from "testHelpers/entities";
|
||||
import { JobStatusIndicator } from "./JobStatusIndicator";
|
||||
|
||||
const meta: Meta<typeof JobStatusIndicator> = {
|
||||
title: "pages/OrganizationProvisionerJobsPage/JobStatusIndicator",
|
||||
component: JobStatusIndicator,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof JobStatusIndicator>;
|
||||
|
||||
export const Succeeded: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "succeeded",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "failed",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "pending",
|
||||
queue_position: 1,
|
||||
queue_size: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Running: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "running",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Canceling: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "canceling",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Canceled: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "canceled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Unknown: Story = {
|
||||
args: {
|
||||
job: {
|
||||
...MockProvisionerJob,
|
||||
status: "unknown",
|
||||
},
|
||||
},
|
||||
};
|
||||
+1
-23
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ProvisionerDaemonJob,
|
||||
ProvisionerJob,
|
||||
ProvisionerJobStatus,
|
||||
} from "api/typesGenerated";
|
||||
import type { ProvisionerJob, ProvisionerJobStatus } from "api/typesGenerated";
|
||||
import {
|
||||
StatusIndicator,
|
||||
StatusIndicatorDot,
|
||||
@@ -40,21 +36,3 @@ export const JobStatusIndicator: FC<JobStatusIndicatorProps> = ({ job }) => {
|
||||
</StatusIndicator>
|
||||
);
|
||||
};
|
||||
|
||||
type DaemonJobStatusIndicatorProps = {
|
||||
job: ProvisionerDaemonJob;
|
||||
};
|
||||
|
||||
export const DaemonJobStatusIndicator: FC<DaemonJobStatusIndicatorProps> = ({
|
||||
job,
|
||||
}) => {
|
||||
return (
|
||||
<StatusIndicator size="sm" variant={variantByStatus[job.status]}>
|
||||
<StatusIndicatorDot />
|
||||
<span className="[&:first-letter]:uppercase">{job.status}</span>
|
||||
{job.status === "failed" && (
|
||||
<TriangleAlertIcon className="size-icon-xs p-[1px]" />
|
||||
)}
|
||||
</StatusIndicator>
|
||||
);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { provisionerJobs } from "api/queries/organizations";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import OrganizationProvisionerJobsPageView from "./OrganizationProvisionerJobsPageView";
|
||||
|
||||
const OrganizationProvisionerJobsPage: FC = () => {
|
||||
const { organization } = useOrganizationSettings();
|
||||
const {
|
||||
data: jobs,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
...provisionerJobs(organization?.id || ""),
|
||||
enabled: organization !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<OrganizationProvisionerJobsPageView
|
||||
jobs={jobs}
|
||||
organization={organization}
|
||||
error={isLoadingError}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationProvisionerJobsPage;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
|
||||
import type { ProvisionerJob } from "api/typesGenerated";
|
||||
import { MockOrganization, MockProvisionerJob } from "testHelpers/entities";
|
||||
import { daysAgo } from "utils/time";
|
||||
import OrganizationProvisionerJobsPageView from "./OrganizationProvisionerJobsPageView";
|
||||
|
||||
const MockProvisionerJobs: ProvisionerJob[] = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => ({
|
||||
...MockProvisionerJob,
|
||||
id: i.toString(),
|
||||
created_at: daysAgo(2),
|
||||
}),
|
||||
);
|
||||
|
||||
const meta: Meta<typeof OrganizationProvisionerJobsPageView> = {
|
||||
title: "pages/OrganizationProvisionerJobsPage",
|
||||
component: OrganizationProvisionerJobsPageView,
|
||||
args: {
|
||||
organization: MockOrganization,
|
||||
jobs: MockProvisionerJobs,
|
||||
onRetry: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OrganizationProvisionerJobsPageView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const OrganizationNotFound: Story = {
|
||||
args: {
|
||||
organization: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
jobs: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingError: Story = {
|
||||
args: {
|
||||
jobs: undefined,
|
||||
error: new Error("Failed to load jobs"),
|
||||
},
|
||||
};
|
||||
|
||||
export const RetryAfterError: Story = {
|
||||
args: {
|
||||
jobs: undefined,
|
||||
error: new Error("Failed to load jobs"),
|
||||
onRetry: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const retryButton = await canvas.findByRole("button", { name: "Retry" });
|
||||
userEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onRetry).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
jobs: [],
|
||||
},
|
||||
};
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import type { Organization, ProvisionerJob } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
type OrganizationProvisionerJobsPageViewProps = {
|
||||
jobs: ProvisionerJob[] | undefined;
|
||||
organization: Organization | undefined;
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
const OrganizationProvisionerJobsPageView: FC<
|
||||
OrganizationProvisionerJobsPageViewProps
|
||||
> = ({ jobs, organization, error, onRetry }) => {
|
||||
if (!organization) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Provisioner Jobs")}</title>
|
||||
</Helmet>
|
||||
<EmptyState message="Organization not found" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioner Jobs",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<section className="flex flex-col gap-8">
|
||||
<header className="flex flex-row items-baseline justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl m-0">Provisioner Jobs</h1>
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
Provisioner Jobs are the individual tasks assigned to Provisioners
|
||||
when the workspaces are being built.{" "}
|
||||
<Link href={docs("/admin/provisioners")}>View docs</Link>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Template</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs ? (
|
||||
jobs.length > 0 ? (
|
||||
jobs.map((j) => <JobRow key={j.id} job={j} />)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No provisioner jobs found" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : error ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="Error loading the provisioner jobs"
|
||||
cta={
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationProvisionerJobsPageView;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
Tag as TagComponent,
|
||||
Tags as TagsComponent,
|
||||
TruncateTags as TruncateTagsComponent,
|
||||
} from "./Tags";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "pages/OrganizationProvisionerJobsPage/Tags",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Tag: Story = {
|
||||
render: () => {
|
||||
return <TagComponent label="cluster" value="dogfood-v2" />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Tags: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<TagsComponent>
|
||||
<TagComponent label="cluster" value="dogfood-v2" />
|
||||
<TagComponent label="env" value="gke" />
|
||||
<TagComponent label="scope" value="organization" />
|
||||
</TagsComponent>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TruncateTags: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<TruncateTagsComponent
|
||||
tags={{
|
||||
cluster: "dogfood-v2",
|
||||
env: "gke",
|
||||
scope: "organization",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { FC, HTMLProps } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const DataGrid: FC<HTMLProps<HTMLDListElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<dl
|
||||
{...props}
|
||||
className={cn([
|
||||
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
|
||||
"[&_dt]:text-content-primary [&_dt]:font-mono [&_dt]:leading-[22px]",
|
||||
className,
|
||||
])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DataGridSpace: FC<HTMLProps<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return <div {...props} className={cn(["h-6 col-span-2", className])} />;
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
import { provisionerDaemons } from "api/queries/organizations";
|
||||
import type { 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<ProvisionerDaemonsPageProps> = ({
|
||||
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 (
|
||||
<section className="flex flex-col gap-8">
|
||||
<h2 className="sr-only">Provisioner daemons</h2>
|
||||
<p className="text-sm text-content-secondary m-0 mt-2">
|
||||
Coder server runs provisioner daemons which execute terraform during
|
||||
workspace and template builds.{" "}
|
||||
<Link
|
||||
href={docs(
|
||||
"/tutorials/best-practices/security-best-practices#provisioner-daemons",
|
||||
)}
|
||||
>
|
||||
View docs
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Template</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daemons ? (
|
||||
daemons.length > 0 ? (
|
||||
daemons.map((d) => <DaemonRow key={d.id} daemon={d} />)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No provisioner daemons found" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : isLoadingError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="Error loading the provisioner daemons"
|
||||
cta={<Button onClick={() => refetch()}>Retry</Button>}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type DaemonRowProps = {
|
||||
daemon: ProvisionerDaemon;
|
||||
};
|
||||
|
||||
const DaemonRow: FC<DaemonRowProps> = ({ daemon }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow key={daemon.id}>
|
||||
<TableCell>
|
||||
<button
|
||||
className={cn([
|
||||
"flex items-center gap-1 p-0 bg-transparent border-0 text-inherit text-xs cursor-pointer",
|
||||
"transition-colors hover:text-content-primary font-medium whitespace-nowrap",
|
||||
isOpen && "text-content-primary",
|
||||
])}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDownIcon className="size-icon-sm p-0.5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-icon-sm p-0.5" />
|
||||
)}
|
||||
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
|
||||
<span className="[&:first-letter]:uppercase">
|
||||
{relativeTime(
|
||||
new Date(daemon.last_seen_at ?? new Date().toISOString()),
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="block whitespace-nowrap text-ellipsis overflow-hidden">
|
||||
{daemon.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{daemon.current_job ? (
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
src={daemon.current_job.template_icon}
|
||||
fallback={
|
||||
daemon.current_job.template_display_name ||
|
||||
daemon.current_job.template_name
|
||||
}
|
||||
/>
|
||||
{daemon.current_job.template_display_name ??
|
||||
daemon.current_job.template_name}
|
||||
</div>
|
||||
) : (
|
||||
<span className="whitespace-nowrap">Not linked</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TruncateTags tags={daemon.tags} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusIndicator size="sm" variant={statusIndicatorVariant(daemon)}>
|
||||
<StatusIndicatorDot />
|
||||
<span className="[&:first-letter]:uppercase">
|
||||
{statusLabel(daemon)}
|
||||
</span>
|
||||
</StatusIndicator>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isOpen && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999} className="p-4 border-t-0">
|
||||
<DataGrid>
|
||||
<dt>Last seen:</dt>
|
||||
<dd>{daemon.last_seen_at}</dd>
|
||||
|
||||
<dt>Creation time:</dt>
|
||||
<dd>{daemon.created_at}</dd>
|
||||
|
||||
<dt>Version:</dt>
|
||||
<dd>{daemon.version}</dd>
|
||||
|
||||
<dt>Tags:</dt>
|
||||
<dd>
|
||||
<Tags>
|
||||
{Object.entries(daemon.tags).map(([key, value]) => (
|
||||
<Tag key={key} label={key} value={value} />
|
||||
))}
|
||||
</Tags>
|
||||
</dd>
|
||||
|
||||
{daemon.current_job && (
|
||||
<>
|
||||
<DataGridSpace />
|
||||
|
||||
<dt>Last job:</dt>
|
||||
<dd>{daemon.current_job.id}</dd>
|
||||
|
||||
<dt>Last job state:</dt>
|
||||
<dd>
|
||||
<DaemonJobStatusIndicator job={daemon.current_job} />
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{daemon.previous_job && (
|
||||
<>
|
||||
<DataGridSpace />
|
||||
|
||||
<dt>Previous job:</dt>
|
||||
<dd>{daemon.previous_job.id}</dd>
|
||||
|
||||
<dt>Previous job state:</dt>
|
||||
<dd>
|
||||
<DaemonJobStatusIndicator job={daemon.previous_job} />
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</DataGrid>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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 { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
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, organizationPermissions } = useOrganizationSettings();
|
||||
const tab = useSearchParamsKey({
|
||||
key: "tab",
|
||||
defaultValue: "jobs",
|
||||
});
|
||||
|
||||
if (!organization || !organizationPermissions?.viewProvisionerJobs) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioners",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions?.viewProvisioners) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
|
||||
<div className="flex flex-col gap-12">
|
||||
<header className="flex flex-row items-baseline justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl m-0">Provisioners</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<Tabs active={tab.value}>
|
||||
<TabsList>
|
||||
<TabLink value="jobs" to="?tab=jobs">
|
||||
Jobs
|
||||
</TabLink>
|
||||
<TabLink value="daemons" to="?tab=daemons">
|
||||
Daemons
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6">
|
||||
{tab.value === "jobs" && (
|
||||
<ProvisionerJobsPage orgId={organization.id} />
|
||||
)}
|
||||
{tab.value === "daemons" && (
|
||||
<ProvisionerDaemonsPage orgId={organization.id} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProvisionersPage;
|
||||
@@ -306,6 +306,12 @@ const ChangePasswordPage = lazy(
|
||||
const IdpOrgSyncPage = lazy(
|
||||
() => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"),
|
||||
);
|
||||
const ProvisionerJobsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
||||
),
|
||||
);
|
||||
|
||||
const RoutesWithSuspense = () => {
|
||||
return (
|
||||
@@ -426,6 +432,10 @@ export const router = createBrowserRouter(
|
||||
<Route path=":roleName" element={<CreateEditRolePage />} />
|
||||
</Route>
|
||||
<Route path="provisioners" element={<ProvisionersPage />} />
|
||||
<Route
|
||||
path="provisioner-jobs"
|
||||
element={<ProvisionerJobsPage />}
|
||||
/>
|
||||
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
|
||||
<Route path="settings" element={<OrganizationSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -40,3 +40,9 @@ export function durationInDays(duration: number): number {
|
||||
export function relativeTime(date: Date) {
|
||||
return dayjs(date).fromNow();
|
||||
}
|
||||
|
||||
export function daysAgo(count: number) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - count);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user