feat: add link to provisioner jobs and daemons (#17509)

Close https://github.com/coder/coder/issues/17314

**Demo**


https://github.com/user-attachments/assets/db37aa67-4755-4b72-a54d-2c3f0c297b7d

**Changes**
- Added the `xs` button variant
- Display all the daemons - idle and offline - and set a size limit to
100 results (explanation in the demo)
- Filter daemons and jobs by ID
This commit is contained in:
Bruno Quaresma
2025-04-28 11:38:32 -03:00
committed by GitHub
parent 5ca90aeb59
commit 3ab3ef865c
16 changed files with 244 additions and 75 deletions
+14 -13
View File
@@ -396,7 +396,17 @@ export class MissingBuildParameters extends Error {
}
export type GetProvisionerJobsParams = {
status?: TypesGen.ProvisionerJobStatus;
status?: string;
limit?: number;
// IDs separated by comma
ids?: string;
};
export type GetProvisionerDaemonsParams = {
// IDs separated by comma
ids?: string;
// Stringified JSON Object
tags?: string;
limit?: number;
};
@@ -711,22 +721,13 @@ class ApiMethods {
return response.data;
};
/**
* @param organization Can be the organization's ID or name
* @param tags to filter provisioner daemons by.
*/
getProvisionerDaemonsByOrganization = async (
organization: string,
tags?: Record<string, string>,
params?: GetProvisionerDaemonsParams,
): Promise<TypesGen.ProvisionerDaemon[]> => {
const params = new URLSearchParams();
if (tags) {
params.append("tags", JSON.stringify(tags));
}
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
`/api/v2/organizations/${organization}/provisionerdaemons?${params}`,
`/api/v2/organizations/${organization}/provisionerdaemons`,
{ params },
);
return response.data;
};
+11 -6
View File
@@ -1,4 +1,8 @@
import { API, type GetProvisionerJobsParams } from "api/api";
import {
API,
type GetProvisionerDaemonsParams,
type GetProvisionerJobsParams,
} from "api/api";
import type {
CreateOrganizationRequest,
GroupSyncSettings,
@@ -164,16 +168,17 @@ export const organizations = () => {
export const getProvisionerDaemonsKey = (
organization: string,
tags?: Record<string, string>,
) => ["organization", organization, tags, "provisionerDaemons"];
params?: GetProvisionerDaemonsParams,
) => ["organization", organization, "provisionerDaemons", params];
export const provisionerDaemons = (
organization: string,
tags?: Record<string, string>,
params?: GetProvisionerDaemonsParams,
) => {
return {
queryKey: getProvisionerDaemonsKey(organization, tags),
queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags),
queryKey: getProvisionerDaemonsKey(organization, params),
queryFn: () =>
API.getProvisionerDaemonsByOrganization(organization, params),
};
};
+2 -1
View File
@@ -8,7 +8,7 @@ import { forwardRef } from "react";
import { cn } from "utils/cn";
export const buttonVariants = cva(
`inline-flex items-center justify-center gap-1 whitespace-nowrap
`inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
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
@@ -30,6 +30,7 @@ export const buttonVariants = cva(
size: {
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",
xs: "min-w-8 py-1 px-2 text-2xs rounded-md",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg",
},
@@ -119,7 +119,6 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
{lifetimeDays === "custom" && (
<TextField
data-chromatic="ignore"
type="date"
label="Expires on"
defaultValue={dayjs().add(expDays, "day").format("YYYY-MM-DD")}
@@ -130,6 +129,7 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
setExpDays(lt);
}}
inputProps={{
"data-chromatic": "ignore",
min: dayjs().add(1, "day").format("YYYY-MM-DD"),
max: maxTokenLifetime
? dayjs()
@@ -15,7 +15,7 @@ const meta: Meta<typeof PermissionPillsList> = {
],
parameters: {
chromatic: {
diffThreshold: 0.5,
diffThreshold: 0.6,
},
},
};
@@ -15,17 +15,19 @@ import {
ProvisionerTruncateTags,
} from "modules/provisioners/ProvisionerTags";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { relativeTime } from "utils/time";
import { CancelJobButton } from "./CancelJobButton";
type JobRowProps = {
job: ProvisionerJob;
defaultIsOpen: boolean;
};
export const JobRow: FC<JobRowProps> = ({ job }) => {
export const JobRow: FC<JobRowProps> = ({ job, defaultIsOpen = false }) => {
const metadata = job.metadata;
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const queue = {
size: job.queue_size,
position: job.queue_position,
@@ -114,8 +116,21 @@ export const JobRow: FC<JobRowProps> = ({ job }) => {
: "[]"}
</dd>
<dt>Completed by provisioner:</dt>
<dd>{job.worker_id}</dd>
{job.worker_id && (
<>
<dt>Completed by provisioner:</dt>
<dd className="flex items-center gap-2">
<span>{job.worker_id}</span>
<Button size="xs" variant="outline" asChild>
<RouterLink
to={`../provisioners?${new URLSearchParams({ ids: job.worker_id })}`}
>
View provisioner
</RouterLink>
</Button>
</dd>
</>
)}
<dt>Associated workspace:</dt>
<dd>{job.metadata.workspace_name ?? "null"}</dd>
@@ -123,10 +138,14 @@ export const JobRow: FC<JobRowProps> = ({ job }) => {
<dt>Creation time:</dt>
<dd data-chromatic="ignore">{job.created_at}</dd>
<dt>Queue:</dt>
<dd>
{job.queue_position}/{job.queue_size}
</dd>
{job.queue_position > 0 && (
<>
<dt>Queue:</dt>
<dd>
{job.queue_position}/{job.queue_size}
</dd>
</>
)}
<dt>Tags:</dt>
<dd>
@@ -11,18 +11,18 @@ const OrganizationProvisionerJobsPage: FC = () => {
const { organization } = useOrganizationSettings();
const [searchParams, setSearchParams] = useSearchParams();
const filter = {
status: searchParams.get("status") || "",
status: searchParams.get("status") ?? "",
ids: searchParams.get("ids") ?? "",
};
const queryParams = {
...filter,
limit: 100,
} as GetProvisionerJobsParams;
const {
data: jobs,
isLoadingError,
refetch,
} = useQuery({
...provisionerJobs(organization?.id || "", queryParams),
...provisionerJobs(organization?.id ?? "", {
...filter,
limit: 100,
}),
enabled: organization !== undefined,
});
@@ -21,7 +21,7 @@ const meta: Meta<typeof OrganizationProvisionerJobsPageView> = {
args: {
organization: MockOrganization,
jobs: MockProvisionerJobs,
filter: { status: "" },
filter: { status: "", ids: "" },
onRetry: fn(),
},
};
@@ -81,8 +81,8 @@ export const Empty: Story = {
export const OnFilter: Story = {
render: function FilterWithState({ ...args }) {
const [jobs, setJobs] = useState<ProvisionerJob[]>([]);
const [filter, setFilter] = useState({ status: "pending" });
const handleFilterChange = (newFilter: { status: string }) => {
const [filter, setFilter] = useState({ status: "pending", ids: "" });
const handleFilterChange = (newFilter: { status: string; ids: string }) => {
setFilter(newFilter);
const filteredJobs = MockProvisionerJobs.filter((job) =>
newFilter.status ? job.status === newFilter.status : true,
@@ -109,3 +109,13 @@ export const OnFilter: Story = {
await userEvent.click(option);
},
};
export const FilterByID: Story = {
args: {
jobs: [MockProvisionerJob],
filter: {
ids: MockProvisionerJob.id,
status: "",
},
},
};
@@ -3,6 +3,7 @@ import type {
ProvisionerJob,
ProvisionerJobStatus,
} from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
@@ -33,6 +34,13 @@ import {
TableHeader,
TableRow,
} from "components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { XIcon } from "lucide-react";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { docs } from "utils/docs";
@@ -64,6 +72,7 @@ const StatusFilters: ProvisionerJobStatus[] = [
type JobProvisionersFilter = {
status: string;
ids: string;
};
type OrganizationProvisionerJobsPageViewProps = {
@@ -110,30 +119,62 @@ const OrganizationProvisionerJobsPageView: FC<
</SettingsHeaderDescription>
</SettingsHeader>
<Select
value={filter.status}
onValueChange={(status) => {
onFilterChange({ status: status as ProvisionerJobStatus });
}}
>
<SelectTrigger className="w-[180px]" data-testid="status-filter">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{StatusFilters.map((status) => (
<SelectItem key={status} value={status}>
<StatusIndicator variant={variantByStatus[status]}>
<StatusIndicatorDot />
<span className="block first-letter:uppercase">
{status}
</span>
</StatusIndicator>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
{filter.ids && (
<div className="relative">
<Badge className="h-10 text-sm pl-3 pr-10 font-mono">
{filter.ids}
</Badge>
<div className="size-10 flex items-center justify-center absolute top-0 right-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={() => {
onFilterChange({ ...filter, ids: "" });
}}
>
<span className="sr-only">Clear ID</span>
<XIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Clear ID</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)}
<Select
value={filter.status}
onValueChange={(status) => {
onFilterChange({
...filter,
status,
});
}}
>
<SelectTrigger className="w-[180px]" data-testid="status-filter">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{StatusFilters.map((status) => (
<SelectItem key={status} value={status}>
<StatusIndicator variant={variantByStatus[status]}>
<StatusIndicatorDot />
<span className="block first-letter:uppercase">
{status}
</span>
</StatusIndicator>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Table className="mt-6">
<TableHeader>
@@ -149,7 +190,13 @@ const OrganizationProvisionerJobsPageView: FC<
<TableBody>
{jobs ? (
jobs.length > 0 ? (
jobs.map((j) => <JobRow key={j.id} job={j} />)
jobs.map((j) => (
<JobRow
defaultIsOpen={filter.ids.includes(j.id)}
key={j.id}
job={j}
/>
))
) : (
<TableRow>
<TableCell colSpan={999}>
@@ -8,7 +8,7 @@ import { RequirePermission } from "modules/permissions/RequirePermission";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { pageTitle } from "utils/page";
import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView";
@@ -16,14 +16,20 @@ const OrganizationProvisionersPage: FC = () => {
const { organization: organizationName } = useParams() as {
organization: string;
};
const [searchParams, setSearchParams] = useSearchParams();
const queryParams = {
ids: searchParams.get("ids") ?? "",
tags: searchParams.get("tags") ?? "",
};
const { organization, organizationPermissions } = useOrganizationSettings();
const { entitlements } = useDashboard();
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const provisionersQuery = useQuery({
...provisionerDaemons(organizationName),
select: (provisioners) =>
provisioners.filter((p) => p.status !== "offline"),
...provisionerDaemons(organizationName, {
...queryParams,
limit: 100,
}),
});
if (!organization) {
@@ -59,6 +65,8 @@ const OrganizationProvisionersPage: FC = () => {
provisioners={provisionersQuery.data}
buildVersion={buildInfoQuery.data?.version}
onRetry={provisionersQuery.refetch}
filter={queryParams}
onFilterChange={setSearchParams}
/>
</>
);
@@ -24,6 +24,9 @@ const meta: Meta<typeof OrganizationProvisionersPageView> = {
version: "0.0.0",
},
],
filter: {
ids: "",
},
},
};
@@ -60,3 +63,12 @@ export const Paywall: Story = {
showPaywall: true,
},
};
export const FilterByID: Story = {
args: {
provisioners: [MockProvisioner],
filter: {
ids: MockProvisioner.id,
},
},
};
@@ -1,4 +1,5 @@
import type { ProvisionerDaemon } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
@@ -17,23 +18,43 @@ import {
TableHeader,
TableRow,
} from "components/Table/Table";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { SquareArrowOutUpRightIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { docs } from "utils/docs";
import { LastConnectionHead } from "./LastConnectionHead";
import { ProvisionerRow } from "./ProvisionerRow";
type ProvisionersFilter = {
ids: string;
};
interface OrganizationProvisionersPageViewProps {
showPaywall: boolean | undefined;
provisioners: readonly ProvisionerDaemon[] | undefined;
buildVersion: string | undefined;
error: unknown;
filter: ProvisionersFilter;
onRetry: () => void;
onFilterChange: (filter: ProvisionersFilter) => void;
}
export const OrganizationProvisionersPageView: FC<
OrganizationProvisionersPageViewProps
> = ({ showPaywall, error, provisioners, buildVersion, onRetry }) => {
> = ({
showPaywall,
error,
provisioners,
buildVersion,
filter,
onFilterChange,
onRetry,
}) => {
return (
<section>
<SettingsHeader>
@@ -45,6 +66,35 @@ export const OrganizationProvisionersPageView: FC<
</SettingsHeaderDescription>
</SettingsHeader>
{filter.ids && (
<div className="flex items-center gap-2 mb-6">
<div className="relative">
<Badge className="h-10 text-sm pl-3 pr-10 font-mono">
{filter.ids}
</Badge>
<div className="size-10 flex items-center justify-center absolute top-0 right-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={() => {
onFilterChange({ ...filter, ids: "" });
}}
>
<span className="sr-only">Clear ID</span>
<XIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Clear ID</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
)}
{showPaywall ? (
<Paywall
message="Provisioners"
@@ -73,6 +123,7 @@ export const OrganizationProvisionersPageView: FC<
provisioner={provisioner}
key={provisioner.id}
buildVersion={buildVersion}
defaultIsOpen={filter.ids.includes(provisioner.id)}
/>
))
) : (
@@ -18,6 +18,7 @@ import {
} from "modules/provisioners/ProvisionerTags";
import { ProvisionerKey } from "pages/OrganizationSettingsPage/OrganizationProvisionersPage/ProvisionerKey";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { relativeTime } from "utils/time";
import { ProvisionerVersion } from "./ProvisionerVersion";
@@ -34,13 +35,15 @@ const variantByStatus: Record<
type ProvisionerRowProps = {
provisioner: ProvisionerDaemon;
buildVersion: string | undefined;
defaultIsOpen: boolean;
};
export const ProvisionerRow: FC<ProvisionerRowProps> = ({
provisioner,
buildVersion,
defaultIsOpen = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(defaultIsOpen);
return (
<>
@@ -151,7 +154,16 @@ export const ProvisionerRow: FC<ProvisionerRowProps> = ({
{provisioner.previous_job && (
<>
<dt>Previous job:</dt>
<dd>{provisioner.previous_job.id}</dd>
<dd className="flex items-center gap-2">
<span>{provisioner.previous_job.id}</span>
<Button size="xs" variant="outline" asChild>
<RouterLink
to={`../provisioner-jobs?${new URLSearchParams({ ids: provisioner.previous_job.id })}`}
>
View job
</RouterLink>
</Button>
</dd>
<dt>Previous job status:</dt>
<dd>
@@ -91,7 +91,7 @@ const meta = {
},
],
chromatic: {
diffThreshold: 0.5,
diffThreshold: 0.8,
},
},
decorators: [
@@ -39,7 +39,7 @@ const meta: Meta<typeof WorkspaceTopbar> = {
layout: "fullscreen",
features: ["advanced_template_scheduling"],
chromatic: {
diffThreshold: 0.3,
diffThreshold: 0.6,
},
},
};
@@ -321,7 +321,7 @@ export const TemplateInfoPopover: Story = {
},
parameters: {
chromatic: {
diffThreshold: 0.3,
diffThreshold: 0.6,
},
},
};
+3
View File
@@ -8,6 +8,9 @@ module.exports = {
important: ["#root", "#storybook-root"],
theme: {
extend: {
fontFamily: {
sans: `"Inter Variable", system-ui, sans-serif`,
},
size: {
"icon-lg": "1.5rem",
"icon-sm": "1.125rem",