Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import type * as SCHEMA from "@ctrlplane/db/schema";
import type { NodeTypes } from "reactflow";
import ReactFlow, { MarkerType, useEdgesState, useNodesState } from "reactflow";
import colors from "tailwindcss/colors";

import { ArrowEdge } from "~/app/[workspaceSlug]/(app)/_components/reactflow/ArrowEdge";
import { useLayoutAndFitView } from "~/app/[workspaceSlug]/(app)/_components/reactflow/layout";
import { EnvironmentNode } from "./nodes/EnvironmentNode";
import { TriggerNode } from "./nodes/TriggerNode";

const nodeTypes: NodeTypes = {
environment: EnvironmentNode,
trigger: TriggerNode,
};

const markerEnd = {
type: MarkerType.Arrow,
color: colors.neutral[700],
};

export const FlowDiagram: React.FC<{
workspace: SCHEMA.Workspace;
deploymentVersion: SCHEMA.DeploymentVersion;
envs: Array<SCHEMA.Environment>;
}> = ({ workspace, deploymentVersion, envs }) => {
const [nodes, _, onNodesChange] = useNodesState<{ label: string }>([
{
id: "trigger",
type: "trigger",
position: { x: 0, y: 0 },
data: { ...deploymentVersion, label: deploymentVersion.name },
},
...envs.map((env) => {
return {
id: env.id,
type: "environment",
position: { x: 0, y: 0 },
data: {
workspaceId: workspace.id,
versionId: deploymentVersion.id,
versionTag: deploymentVersion.tag,
deploymentId: deploymentVersion.deploymentId,
environmentId: env.id,
environmentName: env.name,
label: env.name,
},
};
}),
]);

const [edges, __, onEdgesChange] = useEdgesState([
...envs.map((env) => ({
id: env.id,
source: "trigger",
target: env.id,
markerEnd,
})),
]);

const { setReactFlowInstance } = useLayoutAndFitView(nodes, {
direction: "LR",
padding: 0.16,
});

return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setReactFlowInstance}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
edgeTypes={{ default: ArrowEdge }}
fitView
proOptions={{ hideAttribution: true }}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { IconCheck, IconLoader2, IconMinus, IconX } from "@tabler/icons-react";

import { cn } from "@ctrlplane/ui";

export const Passing: React.FC = () => (
<div className="rounded-full bg-green-400 p-0.5 dark:text-black">
<IconCheck strokeWidth={3} className="h-3 w-3" />
</div>
);

export const Failing: React.FC = () => (
<div className="rounded-full bg-red-400 p-0.5 dark:text-black">
<IconX strokeWidth={3} className="h-3 w-3" />
</div>
);

export const Waiting: React.FC<{ className?: string }> = ({ className }) => (
<div
className={cn(
"animate-spin rounded-full bg-blue-400 p-0.5 dark:text-black",
className,
)}
>
<IconLoader2 strokeWidth={3} className="h-3 w-3" />
</div>
);

export const Loading: React.FC = () => (
<div className="rounded-full bg-muted-foreground p-0.5 dark:text-black">
<IconLoader2 strokeWidth={3} className="h-3 w-3 animate-spin" />
</div>
);

export const Cancelled: React.FC = () => (
<div className="rounded-full bg-neutral-400 p-0.5 dark:text-black">
<IconMinus strokeWidth={3} className="h-3 w-3" />
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@ctrlplane/ui/tooltip";

import { api } from "~/trpc/react";
import { Loading, Passing, Waiting } from "../StatusIcons";

export const ApprovalCheck: React.FC<{
workspaceId: string;
environmentId: string;
versionId: string;
}> = ({ workspaceId, environmentId, versionId }) => {
const { data, isLoading } =
api.deployment.version.checks.approval.status.useQuery({
workspaceId,
environmentId,
versionId,
});

const isApproved = data?.approved ?? false;
const rejectionReasonEntries = Array.from(
data?.rejectionReasons.entries() ?? [],
);

if (isLoading)
return (
<div className="flex items-center gap-2">
<Loading /> Loading approval status
</div>
);

if (isApproved)
return (
<div className="flex items-center gap-2">
<Passing /> Approved
</div>
);

if (rejectionReasonEntries.length > 0) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="flex items-center gap-2">
<Waiting /> Not enough approvals
</div>
</TooltipTrigger>
<TooltipContent>
<ul>
{rejectionReasonEntries.map(([reason, comment]) => (
<li key={reason}>
{reason}: {comment}
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

return (
<div className="flex items-center gap-2">
<Waiting /> Not enough approvals
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type * as SCHEMA from "@ctrlplane/db/schema";
import type { NodeProps } from "reactflow";
import { IconPlant } from "@tabler/icons-react";
import { Handle, Position } from "reactflow";
import colors from "tailwindcss/colors";

import { cn } from "@ctrlplane/ui";
import { Separator } from "@ctrlplane/ui/separator";

import { ApprovalCheck } from "../checks/Approval";

type EnvironmentNodeProps = NodeProps<{
workspaceId: string;
policy?: SCHEMA.EnvironmentPolicy;
versionId: string;
versionTag: string;
deploymentId: string;
environmentId: string;
environmentName: string;
}>;

export const EnvironmentNode: React.FC<EnvironmentNodeProps> = ({ data }) => (
<>
<div
className={cn("relative w-[350px] space-y-1 rounded-md border text-sm")}
>
<div className="flex items-center gap-2 p-2">
<div className="flex-shrink-0 rounded bg-green-500/20 p-1 text-green-400">
<IconPlant className="h-3 w-3" />
</div>
{data.environmentName}
</div>
<Separator className="!m-0 bg-neutral-800" />
<div className="space-y-1 px-2 pb-2">
<ApprovalCheck
workspaceId={data.workspaceId}
environmentId={data.environmentId}
versionId={data.versionId}
/>
</div>
</div>
<Handle
type="target"
className="h-2 w-2 rounded-full border border-neutral-500"
style={{ background: colors.neutral[800] }}
position={Position.Left}
/>
<Handle
type="source"
className="h-2 w-2 rounded-full border border-neutral-500"
style={{ background: colors.neutral[800] }}
position={Position.Right}
/>
</>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type * as SCHEMA from "@ctrlplane/db/schema";
import type { NodeProps } from "reactflow";
import React from "react";
import { IconBolt, IconTarget } from "@tabler/icons-react";
import { Handle, Position } from "reactflow";

type TriggerNodeProps = NodeProps<SCHEMA.DeploymentVersion & { label: string }>;

export const TriggerNode: React.FC<TriggerNodeProps> = ({ data }) => (
<>
<div className="relative w-[250px]">
<div className="absolute bottom-[100%] -z-10 flex items-center gap-1 rounded-t bg-blue-500/20 p-1 text-xs">
<IconTarget className="h-3 w-3 text-blue-500" /> Trigger
</div>
<div className="relative rounded-b-md rounded-r-md border bg-neutral-900">
<div className="flex items-center gap-2 border-b p-2">
<div className="rounded-md bg-blue-500/20 p-1">
<IconBolt className="h-4 w-4 text-blue-500" />
</div>
{data.label}
</div>
</div>
</div>
<Handle
type="source"
className="h-2 w-2 rounded-full border border-blue-500"
position={Position.Right}
/>
</>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IconMenu2 } from "@tabler/icons-react";

import { SidebarTrigger } from "@ctrlplane/ui/sidebar";

import { ReactFlowProvider } from "~/app/[workspaceSlug]/(app)/_components/reactflow/ReactFlowProvider";
import { Sidebars } from "~/app/[workspaceSlug]/sidebars";
import { api } from "~/trpc/server";
import { FlowDiagram } from "./_components/flow-diagram/FlowDiagram";

type PageProps = {
params: Promise<{
workspaceSlug: string;
systemSlug: string;
deploymentSlug: string;
releaseId: string;
}>;
};

export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const deployment = await api.deployment.bySlug(params);
if (deployment == null) return notFound();

const deploymentVersion = await api.deployment.version.byId(params.releaseId);
if (deploymentVersion == null) return notFound();

return {
title: `${deploymentVersion.tag} | ${deployment.name} | ${deployment.system.name} | ${deployment.system.workspace.name}`,
};
}

export default async function ChecksPage(props: PageProps) {
const params = await props.params;
const deploymentVersionPromise = api.deployment.version.byId(
params.releaseId,
);
const deploymentPromise = api.deployment.bySlug(params);
const [deploymentVersion, deployment] = await Promise.all([
deploymentVersionPromise,
deploymentPromise,
]);
if (deploymentVersion == null || deployment == null) return notFound();

const { system } = deployment;
const environments = await api.deployment.version.checks.environmentsToCheck(
deployment.id,
);
return (
<div className="relative h-full">
<SidebarTrigger
name={Sidebars.Release}
className="absolute left-2 top-2 z-10"
>
<IconMenu2 className="h-4 w-4" />
</SidebarTrigger>
<ReactFlowProvider>
<FlowDiagram
workspace={system.workspace}
deploymentVersion={deploymentVersion}
envs={environments}
/>
</ReactFlowProvider>
</div>
);
}
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@ctrlplane/events": "workspace:*",
"@ctrlplane/job-dispatch": "workspace:*",
"@ctrlplane/logger": "workspace:*",
"@ctrlplane/rule-engine": "workspace:*",
"@ctrlplane/secrets": "workspace:*",
"@ctrlplane/validators": "workspace:*",
"@octokit/auth-app": "catalog:",
Expand Down
Loading
Loading