diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx index 04481db7d..4118e7a0b 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx @@ -1,10 +1,10 @@ "use client"; -import type { Environment, Target } from "@ctrlplane/db/schema"; +import type { RouterOutputs } from "@ctrlplane/api"; +import type { Environment } from "@ctrlplane/db/schema"; import type { JobStatus } from "@ctrlplane/validators/jobs"; import React, { Fragment, useState } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; import { IconChevronRight, IconDots, @@ -12,14 +12,22 @@ import { IconFilter, } from "@tabler/icons-react"; import { capitalCase } from "change-case"; +import { formatDistanceToNowStrict } from "date-fns"; import _ from "lodash"; +import { isPresent } from "ts-is-present"; import { cn } from "@ctrlplane/ui"; import { Badge } from "@ctrlplane/ui/badge"; import { Button, buttonVariants } from "@ctrlplane/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@ctrlplane/ui/collapsible"; import { Skeleton } from "@ctrlplane/ui/skeleton"; import { Table, TableBody, TableCell, TableRow } from "@ctrlplane/ui/table"; import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; +import { JobStatusReadable } from "@ctrlplane/validators/jobs"; import { JobConditionBadge } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionBadge"; import { JobConditionDialog } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionDialog"; @@ -30,6 +38,8 @@ import { api } from "~/trpc/react"; import { JobDropdownMenu } from "./JobDropdownMenu"; import { PolicyApprovalRow } from "./PolicyApprovalRow"; +type Trigger = RouterOutputs["job"]["config"]["byReleaseId"][number]; + type CollapsibleTableRowProps = { environment: Environment; environmentCount: number; @@ -39,18 +49,7 @@ type CollapsibleTableRowProps = { version: string; name: string; }; - releaseJobTriggerData: Array<{ - id: string; - environmentId: string; - job: { - id: string; - status: JobStatus; - metadata: Array<{ key: string; value: string }>; - externalId: string | null; - }; - target: Target; - type: string; - }>; + triggersByTarget: Record; }; const CollapsibleTableRow: React.FC = ({ @@ -58,13 +57,9 @@ const CollapsibleTableRow: React.FC = ({ environmentCount, deploymentName, release, - releaseJobTriggerData, + triggersByTarget, }) => { - const pathname = usePathname(); const { setJobId } = useJobDrawer(); - const jobs = releaseJobTriggerData.filter( - (job) => job.environmentId === environment.id, - ); const approvalsQ = api.environment.policy.approval.byReleaseId.useQuery({ releaseId: release.id, @@ -75,10 +70,24 @@ const CollapsibleTableRow: React.FC = ({ (approval) => approval.policyId === environment.policyId, ); - const isOpen = jobs.length < 10 && environmentCount < 3; + const allTriggers = Object.values(triggersByTarget).flat(); + + const isOpen = allTriggers.length < 10 && environmentCount < 3; const [isExpanded, setIsExpanded] = useState(isOpen); - if (jobs.length === 0) return null; + const [expandedTargets, setExpandedTargets] = useState< + Record + >({}); + + const switchTargetExpandedState = (targetId: string) => + setExpandedTargets((prev) => { + const newState = { ...prev }; + const currentTargetState = newState[targetId] ?? false; + newState[targetId] = !currentTargetState; + return newState; + }); + + if (allTriggers.length === 0) return null; return ( @@ -86,7 +95,7 @@ const CollapsibleTableRow: React.FC = ({ className={cn("sticky cursor-pointer bg-neutral-800/40")} onClick={() => setIsExpanded((t) => !t)} > - +
= ({ /> {environment.name}
- {Object.entries(_.groupBy(jobs, (job) => job.job.status)).map( - ([status, groupedJobs]) => ( - - - {groupedJobs.length} - - ), - )} + {Object.entries( + _.groupBy(allTriggers, (t) => t.job.status), + ).map(([status, groupedTriggers]) => ( + + + {groupedTriggers.length} + + ))}
@@ -126,8 +135,10 @@ const CollapsibleTableRow: React.FC = ({ {isExpanded && ( <> - {jobs.map((job, idx) => { - const linksMetadata = job.job.metadata.find( + {Object.entries(triggersByTarget).map(([, triggers]) => { + const target = triggers[0]!.target; + const trigger = triggers[0]!; // triggers are already sorted by createdAt from the query + const linksMetadata = trigger.job.metadata.find( (m) => m.key === String(ReservedMetadataKey.Links), )?.value; @@ -137,81 +148,221 @@ const CollapsibleTableRow: React.FC = ({ : null; return ( - setJobId(job.job.id)} - > - e.stopPropagation()}> - + <> + setJobId(trigger.job.id)} > - {job.target.name} - - - -
- - {capitalCase(job.job.status)} -
-
- {job.type} - - {job.job.externalId != null ? ( - - {job.job.externalId} - - ) : ( - - No external ID - - )} - - e.stopPropagation()}> - {links != null && ( -
- {Object.entries(links).map(([label, url]) => ( - e.stopPropagation()}> + {triggers.length > 1 && ( + +
+ + {target.name} +
+
+ )} + + {triggers.length === 1 && ( +
{target.name}
+ )} + + +
+ + {capitalCase(trigger.job.status)} +
+
+ {trigger.type} + + {trigger.job.externalId != null ? ( + + {trigger.job.externalId} + + ) : ( + + No external ID + + )} + + e.stopPropagation()}> + {links != null && ( +
+ {Object.entries(links).map(([label, url]) => ( + + + {label} + + ))} +
+ )} +
+ + + {formatDistanceToNowStrict(trigger.createdAt, { + addSuffix: true, + })} + + + e.stopPropagation()}> +
+ - - {label} - - ))} -
- )} -
- e.stopPropagation()}> -
- - - -
-
- + + +
+
+
+ + <> + {triggers.map((trigger, idx) => { + if (idx === 0) return null; + const linksMetadata = trigger.job.metadata.find( + (m) => m.key === String(ReservedMetadataKey.Links), + )?.value; + + const links = + linksMetadata != null + ? (JSON.parse(linksMetadata) as Record< + string, + string + >) + : null; + + return ( + setJobId(trigger.job.id)} + > + +
+
+
+ + +
+ + {capitalCase(trigger.job.status)} +
+
+ {trigger.type} + + {trigger.job.externalId != null ? ( + + {trigger.job.externalId} + + ) : ( + + No external ID + + )} + + e.stopPropagation()}> + {links != null && ( +
+ {Object.entries(links).map(([label, url]) => ( + + + {label} + + ))} +
+ )} +
+ + + {formatDistanceToNowStrict(trigger.createdAt, { + addSuffix: true, + })} + + + e.stopPropagation()}> +
+ + + +
+
+ + ); + })} + + + + ); })} @@ -237,6 +388,16 @@ export const TargetReleaseTable: React.FC = ({ { refetchInterval: 5_000 }, ); const releaseJobTriggers = releaseJobTriggerQuery.data ?? []; + const groupedTriggers = _.chain(releaseJobTriggers) + .groupBy((t) => t.environmentId) + .map((triggers) => ({ + environment: environments.find( + (e) => e.id === triggers[0]!.environmentId, + ), + targets: _.groupBy(triggers, (t) => t.targetId), + })) + .filter((t) => isPresent(t.environment)) + .value(); return ( <> @@ -274,14 +435,14 @@ export const TargetReleaseTable: React.FC = ({ {!releaseJobTriggerQuery.isLoading && releaseJobTriggers.length > 0 && ( - {environments.map((environment) => ( + {groupedTriggers.map(({ environment, targets }) => ( ))}