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:
Bruno Quaresma
2025-03-14 08:22:00 -03:00
committed by GitHub
parent cf7d143e43
commit 564b387262
21 changed files with 455 additions and 515 deletions
+1 -1
View File
@@ -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
@@ -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 },
},
@@ -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 },
},
@@ -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();
},
};
@@ -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>
)}
@@ -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,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>
);
};
@@ -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;
@@ -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: [],
},
};
@@ -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;
@@ -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;
+10
View File
@@ -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>
+6
View File
@@ -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();
}