diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx index fcf8c2710..3a77595ce 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx @@ -6,7 +6,7 @@ import { Handle, Position } from "reactflow"; import { cn } from "@ctrlplane/ui"; import { JobStatus, JobStatusReadable } from "@ctrlplane/validators/jobs"; -import { useJobDrawer } from "~/app/[workspaceSlug]/(app)/_components/job-drawer/useJobDrawer"; +import { useDeploymentEnvResourceDrawer } from "~/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/useDeploymentResourceDrawer"; import { api } from "~/trpc/react"; import { ReleaseIcon } from "../../ReleaseCell"; @@ -19,7 +19,7 @@ type DeploymentNodeProps = NodeProps<{ export const DeploymentNode: React.FC = ({ data }) => { const { deployment, environment, resource } = data; - const { setJobId } = useJobDrawer(); + const { setDeploymentEnvResourceId } = useDeploymentEnvResourceDrawer(); const resourceId = resource.id; const environmentId = environment.id; @@ -49,16 +49,15 @@ export const DeploymentNode: React.FC = ({ data }) => { <>
{ - if (releaseJobTrigger != null) setJobId(releaseJobTrigger.job.id); - }} + onClick={() => + setDeploymentEnvResourceId(deployment.id, environment.id, resource.id) + } >
diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/DeploymentResourceDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/DeploymentResourceDrawer.tsx new file mode 100644 index 000000000..81976dca8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/DeploymentResourceDrawer.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { IconLoader2, IconShip } from "@tabler/icons-react"; + +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerTitle, +} from "@ctrlplane/ui/drawer"; + +import { api } from "~/trpc/react"; +import { ReleaseTable } from "./ReleaseTable"; +import { useDeploymentEnvResourceDrawer } from "./useDeploymentResourceDrawer"; + +export const DeploymentResourceDrawer: React.FC = () => { + const { + deploymentId, + environmentId, + resourceId, + setDeploymentEnvResourceId, + } = useDeploymentEnvResourceDrawer(); + const isOpen = + deploymentId != null && environmentId != null && resourceId != null; + const setIsOpen = () => setDeploymentEnvResourceId(null, null, null); + + const { data: deployment, ...deploymentQ } = api.deployment.byId.useQuery( + deploymentId ?? "", + { enabled: isOpen }, + ); + + const { data: resource, ...resourceQ } = api.resource.byId.useQuery( + resourceId ?? "", + { enabled: isOpen }, + ); + + const { data: environment, ...environmentQ } = api.environment.byId.useQuery( + environmentId ?? "", + { enabled: isOpen }, + ); + + const { data: releaseWithTriggersData, ...releaseWithTriggersQ } = + api.job.config.byDeploymentEnvAndResource.useQuery( + { + deploymentId: deploymentId ?? "", + environmentId: environmentId ?? "", + resourceId: resourceId ?? "", + }, + { enabled: isOpen, refetchInterval: 5_000 }, + ); + const releaseWithTriggers = releaseWithTriggersData ?? []; + + const loading = + deploymentQ.isLoading || + resourceQ.isLoading || + environmentQ.isLoading || + releaseWithTriggersQ.isLoading; + + return ( + + + {loading && ( +
+ + {/* + Drawer component throws an error if the title and description are not present, so just render empty elements. + Technically shadcn recommends using the VisuallyHidden radix component, but this fixes without any additional dependencies. + */} + + +
+ )} + {!loading && + deployment != null && + resource != null && + environment != null && ( + <> + +
+
+ +
+ {deployment.name} +
+
+ Resource: {resource.name} + Environment: {environment.name} +
+
+ +
+ +
+ + )} +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/ReleaseTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/ReleaseTable.tsx new file mode 100644 index 000000000..d94722cfa --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/ReleaseTable.tsx @@ -0,0 +1,65 @@ +import type { RouterOutputs } from "@ctrlplane/api"; +import type * as SCHEMA from "@ctrlplane/db/schema"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +import { ReleaseRows } from "./TableRow"; + +type ReleaseWithTriggers = + RouterOutputs["job"]["config"]["byDeploymentEnvAndResource"][number]; + +type ReleaseTableProps = { + releasesWithTriggers: ReleaseWithTriggers[]; + environment: SCHEMA.Environment & { system: SCHEMA.System }; + deployment: SCHEMA.Deployment; + resource: SCHEMA.Resource; +}; + +export const ReleaseTable: React.FC = ({ + releasesWithTriggers, + environment, + deployment, + resource, +}) => ( +
+ + + + Release + Status + Created + Links + + + + + {releasesWithTriggers.map((release) => ( + + ))} + {releasesWithTriggers.length === 0 && ( + + + No releases found + + + )} + +
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/TableRow.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/TableRow.tsx new file mode 100644 index 000000000..f962794a0 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/TableRow.tsx @@ -0,0 +1,405 @@ +import type { RouterOutputs } from "@ctrlplane/api"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { JobStatus } from "@ctrlplane/validators/jobs"; +import { useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { + IconChevronRight, + IconDots, + IconExternalLink, + IconLoader2, +} from "@tabler/icons-react"; +import { formatDistanceToNowStrict } from "date-fns"; + +import { cn } from "@ctrlplane/ui"; +import { Button, buttonVariants } from "@ctrlplane/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@ctrlplane/ui/collapsible"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@ctrlplane/ui/hover-card"; +import { TableCell, TableRow } from "@ctrlplane/ui/table"; +import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; +import { JobStatusReadable } from "@ctrlplane/validators/jobs"; + +import { api } from "~/trpc/react"; +import { JobDropdownMenu } from "../../systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/JobDropdownMenu"; +import { DeployButton } from "../../systems/[systemSlug]/deployments/DeployButton"; +import { JobTableStatusIcon } from "../JobTableStatusIcon"; + +type ReleaseJobTrigger = SCHEMA.ReleaseJobTrigger & { + job: SCHEMA.Job; +}; + +type StatusCellProps = { + releaseJobTrigger?: ReleaseJobTrigger; + releaseId: string; + environmentId: string; +}; + +const StatusCell: React.FC = ({ + releaseJobTrigger, + releaseId, + environmentId, +}) => ( + + {releaseJobTrigger != null && ( +
+ + + {JobStatusReadable[releaseJobTrigger.job.status]} + +
+ )} + {releaseJobTrigger == null && ( +
e.stopPropagation()}> + +
+ )} +
+); + +type CreatedCellProps = { + releaseJobTrigger?: ReleaseJobTrigger; +}; + +const CreatedCell: React.FC = ({ releaseJobTrigger }) => ( + + {releaseJobTrigger != null && ( + + {formatDistanceToNowStrict(releaseJobTrigger.createdAt, { + addSuffix: true, + })} + + )} + {releaseJobTrigger == null && ( + Not deployed + )} + +); + +type LinksCellProps = { + releaseJobTrigger?: ReleaseJobTrigger; +}; + +const LinksCell: React.FC = ({ releaseJobTrigger }) => { + const jobQ = api.job.config.byId.useQuery(releaseJobTrigger?.job.id ?? "", { + enabled: releaseJobTrigger != null, + refetchInterval: 5_000, + }); + const job = jobQ.data; + const linksMetadata = job?.job.metadata.find( + (m) => m.key === String(ReservedMetadataKey.Links), + ); + const links = + linksMetadata != null + ? (JSON.parse(linksMetadata.value) as Record) + : null; + + if (jobQ.isLoading) + return ( + + + + ); + + if (links == null) return ; + + const numLinks = Object.keys(links).length; + if (numLinks <= 3) + return ( + +
e.stopPropagation()} + > + {Object.entries(links).map(([label, url]) => ( + + + {label} + + ))} +
+
+ ); + + const firstThreeLinks = Object.entries(links).slice(0, 3); + const remainingLinks = Object.entries(links).slice(3); + + return ( + +
e.stopPropagation()} + > + {firstThreeLinks.map(([label, url]) => ( + + + {label} + + ))} + + + + + + {remainingLinks.map(([label, url]) => ( + + {label} + + ))} + + +
+
+ ); +}; + +type DropdownCellProps = { + releaseJobTrigger?: ReleaseJobTrigger; + release: SCHEMA.Release; + environmentId: string; + resource: SCHEMA.Resource; + deployment: SCHEMA.Deployment; +}; + +const DropdownCell: React.FC = ({ + releaseJobTrigger, + release, + environmentId, + resource, + deployment, +}) => ( + +
+ {releaseJobTrigger != null && ( + + + + )} +
+
+); + +type ReleaseJobTriggerRowProps = { + releaseJobTrigger?: ReleaseJobTrigger; + release: SCHEMA.Release; + environment: SCHEMA.Environment & { system: SCHEMA.System }; + deployment: SCHEMA.Deployment; + resource: SCHEMA.Resource; +}; + +type ReleaseJobTriggerParentRowProps = ReleaseJobTriggerRowProps & { + isExpandable: boolean; + isExpanded: boolean; +}; + +const ReleaseJobTriggerParentRow: React.FC = ({ + releaseJobTrigger, + release, + environment, + deployment, + resource, + isExpandable, + isExpanded, +}) => { + const router = useRouter(); + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + + return ( + + router.push( + `/${workspaceSlug}/systems/${environment.system.slug}/deployments/${deployment.slug}/releases/${release.id}`, + ) + } + className="cursor-pointer" + > + + {isExpandable && ( +
e.stopPropagation()}> + +
+ + {release.name} +
+
+
+ )} + {!isExpandable && {release.name}} +
+ + + + +
+ ); +}; + +const ReleaseJobTriggerChildRow: React.FC = ({ + releaseJobTrigger, + release, + environment, + deployment, + resource, +}) => { + const router = useRouter(); + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + + return ( + + router.push( + `/${workspaceSlug}/systems/${environment.system.slug}/deployments/${deployment.slug}/releases/${release.id}`, + ) + } + className="cursor-pointer" + > + + + + + + + ); +}; + +type Release = + RouterOutputs["job"]["config"]["byDeploymentEnvAndResource"][number]; + +type ReleaseRowsProps = { + release: Release; + environment: SCHEMA.Environment & { system: SCHEMA.System }; + deployment: SCHEMA.Deployment; + resource: SCHEMA.Resource; +}; + +export const ReleaseRows: React.FC = ({ + release, + environment, + deployment, + resource, +}) => { + const [open, setOpen] = useState(false); + const { releaseJobTriggers } = release; + const hasOtherReleaseJobTriggers = releaseJobTriggers.length > 1; + + return ( + + <> + + + <> + {releaseJobTriggers.map((trigger, idx) => { + if (idx === 0) return null; + return ( + + ); + })} + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/useDeploymentResourceDrawer.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/useDeploymentResourceDrawer.ts new file mode 100644 index 000000000..fa35d68a6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/deployment-resource-drawer/useDeploymentResourceDrawer.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import { z } from "zod"; + +import { useQueryParams } from "../useQueryParams"; + +const param = "deployment_env_resource_id"; + +const DELIMITER = "--"; + +export const useDeploymentEnvResourceDrawer = () => { + const { getParam, setParams } = useQueryParams(); + const deploymentResourceId = getParam(param); + + const { deploymentId, environmentId, resourceId } = useMemo(() => { + if (deploymentResourceId == null) + return { deploymentId: null, environmentId: null, resourceId: null }; + + const [rawDeploymentId, rawEnvironmentId, rawResourceId] = + decodeURIComponent(deploymentResourceId).split(DELIMITER); + if ( + rawDeploymentId == null || + rawEnvironmentId == null || + rawResourceId == null + ) + return { deploymentId: null, environmentId: null, resourceId: null }; + + if ( + !z.string().uuid().safeParse(rawDeploymentId).success || + !z.string().uuid().safeParse(rawEnvironmentId).success || + !z.string().uuid().safeParse(rawResourceId).success + ) + return { deploymentId: null, environmentId: null, resourceId: null }; + + return { + deploymentId: rawDeploymentId, + environmentId: rawEnvironmentId, + resourceId: rawResourceId, + }; + }, [deploymentResourceId]); + + /** + * Will set param to null if either deploymentId, environmentId, or resourceId is null. + * + * @param deploymentId - The deployment ID to set. + * @param environmentId - The environment ID to set. + * @param resourceId - The resource ID to set. + */ + const setDeploymentEnvResourceId = ( + deploymentId: string | null, + environmentId: string | null, + resourceId: string | null, + ) => + setParams({ + [param]: + deploymentId == null || environmentId == null || resourceId == null + ? null + : encodeURIComponent( + [deploymentId, environmentId, resourceId].join(DELIMITER), + ), + }); + return { + deploymentId, + environmentId, + resourceId, + setDeploymentEnvResourceId, + }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx index 2bf48e6e6..f38d49a07 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx @@ -5,6 +5,7 @@ import { auth } from "@ctrlplane/auth"; import { SidebarInset } from "@ctrlplane/ui/sidebar"; import { api } from "~/trpc/server"; +import { DeploymentResourceDrawer } from "./_components/deployment-resource-drawer/DeploymentResourceDrawer"; import { EnvironmentDrawer } from "./_components/environment-drawer/EnvironmentDrawer"; import { EnvironmentPolicyDrawer } from "./_components/environment-policy-drawer/EnvironmentPolicyDrawer"; import { JobDrawer } from "./_components/job-drawer/JobDrawer"; @@ -46,7 +47,7 @@ export default async function WorkspaceLayout({ - + ); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/DeployButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/DeployButton.tsx index b109d61b4..f83d12e2a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/DeployButton.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/DeployButton.tsx @@ -10,7 +10,8 @@ import { api } from "~/trpc/react"; export const DeployButton: React.FC<{ releaseId: string; environmentId: string; -}> = ({ releaseId, environmentId }) => { + className?: string; +}> = ({ releaseId, environmentId, className }) => { const deploy = api.release.deploy.toEnvironment.useMutation(); const router = useRouter(); @@ -18,6 +19,7 @@ export const DeployButton: React.FC<{