diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_components/EnvironmentTabs.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_components/EnvironmentTabs.tsx new file mode 100644 index 000000000..029729cec --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_components/EnvironmentTabs.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import { useParams, usePathname, useRouter } from "next/navigation"; + +import { Tabs, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs"; + +import { urls } from "~/app/urls"; + +export const EnvironmentTabs: React.FC = () => { + const { workspaceSlug, systemSlug, environmentId } = useParams<{ + workspaceSlug: string; + systemSlug: string; + environmentId: string; + }>(); + + const environmentUrls = urls + .workspace(workspaceSlug) + .system(systemSlug) + .environment(environmentId); + const baseUrl = environmentUrls.baseUrl(); + const overviewUrl = environmentUrls.overview(); + const deploymentsUrl = environmentUrls.deployments(); + const resourcesUrl = environmentUrls.resources(); + const policiesUrl = environmentUrls.policies(); + + const pathname = usePathname(); + const getInitialTab = () => { + if (pathname === policiesUrl) return "policies"; + if (pathname === resourcesUrl) return "resources"; + if (pathname === deploymentsUrl) return "deployments"; + if (pathname === baseUrl) return "overview"; + return "overview"; + }; + + const [activeTab, setActiveTab] = useState(getInitialTab()); + + const router = useRouter(); + + const onTabChange = (value: string) => { + if (value === "overview") router.push(overviewUrl); + if (value === "deployments") router.push(deploymentsUrl); + if (value === "resources") router.push(resourcesUrl); + if (value === "policies") router.push(policiesUrl); + setActiveTab(value); + }; + + return ( + + + Overview + Deployments + Resources + Policies + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/config/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_config/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/config/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_config/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_deployments/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_deployments/page.tsx new file mode 100644 index 000000000..f83f3f5e7 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_deployments/page.tsx @@ -0,0 +1,13 @@ +import { DeploymentsCard } from "~/app/[workspaceSlug]/(app)/_components/deployments/Card"; + +export default async function DeploymentsPage(props: { + params: Promise<{ environmentId: string }>; +}) { + const { environmentId } = await props.params; + + return ( +
+ +
+ ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/insights/DailyResourcesCountGraph.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_insights/DailyResourcesCountGraph.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/insights/DailyResourcesCountGraph.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_insights/DailyResourcesCountGraph.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PolicyTabs.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/PolicyTabs.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PolicyTabs.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/PolicyTabs.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/approval/ApprovalAndGovernance.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/approval/ApprovalAndGovernance.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/approval/ApprovalAndGovernance.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/approval/ApprovalAndGovernance.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/approval/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/approval/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/approval/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/approval/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/channels/DeploymentVersionChannels.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/channels/DeploymentVersionChannels.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/channels/DeploymentVersionChannels.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/channels/DeploymentVersionChannels.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/channels/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/channels/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/channels/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/channels/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/control/DeploymentControl.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/control/DeploymentControl.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/control/DeploymentControl.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/control/DeploymentControl.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/control/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/control/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/control/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/control/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/layout.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/layout.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/layout.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/management/VersionManagement.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/management/VersionManagement.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/management/VersionManagement.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/management/VersionManagement.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/management/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/management/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/management/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/management/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/page.tsx new file mode 100644 index 000000000..e26b25bda --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +export default function PoliciesPage(props: { + params: { + workspaceSlug: string; + systemSlug: string; + environmentId: string; + }; +}) { + return redirect( + `/${props.params.workspaceSlug}/systems/${props.params.systemSlug}/environments/${props.params.environmentId}/policies/approval`, + ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/rollout/RolloutAndTiming.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/rollout/RolloutAndTiming.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/rollout/RolloutAndTiming.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/rollout/RolloutAndTiming.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/rollout/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/rollout/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/rollout/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/rollout/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/useUpdatePolicy.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/useUpdatePolicy.ts similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/useUpdatePolicy.ts rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/useUpdatePolicy.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/EditFilterForm.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/EditFilterForm.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/EditFilterForm.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/EditFilterForm.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/EnvironmentResourcesTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/EnvironmentResourcesTable.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/EnvironmentResourcesTable.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/EnvironmentResourcesTable.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/page.tsx new file mode 100644 index 000000000..7d3910b8e --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { EditFilterForm } from "./EditFilterForm"; + +export default async function ResourcesPage(props: { + params: Promise<{ workspaceSlug: string; environmentId: string }>; +}) { + const params = await props.params; + const workspace = await api.workspace.bySlug(params.workspaceSlug); + if (workspace == null) notFound(); + + const environment = await api.environment.byId(params.environmentId); + if (environment == null) notFound(); + + return ( +
+ +
+ ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/settings/Overview.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_settings/Overview.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/settings/Overview.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_settings/Overview.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/settings/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_settings/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/settings/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_settings/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/variables/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_variables/page.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/variables/page.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_variables/page.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx new file mode 100644 index 000000000..78a1069fc --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useState } from "react"; +import { IconFilter, IconSearch } from "@tabler/icons-react"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Input } from "@ctrlplane/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +import { DeploymentDetail } from "./_components/DeploymentDetail"; + +// Helper function for rendering status badges +const StatusBadge: React.FC<{ status: string }> = ({ status }) => { + const statusLower = status.toLowerCase(); + if (statusLower === "success") + return ( + + Success + + ); + if (statusLower === "running") + return ( + + Running + + ); + if (statusLower === "deploying") + return ( + + Deploying + + ); + if (statusLower === "pending") + return ( + + Pending + + ); + if (statusLower === "failed") + return ( + + Failed + + ); + return ( + + {status} + + ); +}; + +export const EnvironmentDeploymentsPageContent: React.FC<{ + environmentId: string; +}> = () => { + const [selectedDeployment, setSelectedDeployment] = useState(null); + // Sample static deployment data - would be replaced with API data in a real implementation + const deployments = [ + { + id: "dep-123", + name: "Frontend Service", + status: "success", + version: "v2.1.0", + deployedAt: new Date("2024-03-15T14:30:00"), + duration: 248, // in seconds + resources: 5, + initiatedBy: "Jane Smith", + successRate: 100, + }, + { + id: "dep-122", + name: "API Gateway", + status: "pending", + version: "v3.4.1", + deployedAt: new Date("2024-03-14T10:15:00"), + duration: null, // pending + resources: 12, + initiatedBy: "CI/CD Pipeline", + successRate: null, + }, + { + id: "dep-121", + name: "Database Service", + status: "success", + version: "v3.4.1", + deployedAt: new Date("2024-03-12T09:45:00"), + duration: 183, // in seconds + resources: 9, + initiatedBy: "John Doe", + successRate: 100, + }, + { + id: "dep-120", + name: "Cache Service", + status: "failed", + version: "v2.0.0", + deployedAt: new Date("2024-03-10T16:20:00"), + duration: 127, // in seconds + resources: 4, + initiatedBy: "CI/CD Pipeline", + successRate: 25, + }, + { + id: "dep-119", + name: "Backend Service", + status: "success", + version: "v4.1.0", + deployedAt: new Date("2024-03-05T11:30:00"), + duration: 312, // in seconds + resources: 7, + initiatedBy: "Jane Smith", + successRate: 100, + }, + ]; + + const formatDuration = (seconds: number | null) => { + if (seconds === null) return "—"; + + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + }; + + const formatTimeAgo = (date: Date) => { + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return `${diffInSeconds} seconds ago`; + if (diffInSeconds < 3600) + return `${Math.floor(diffInSeconds / 60)} minutes ago`; + if (diffInSeconds < 86400) + return `${Math.floor(diffInSeconds / 3600)} hours ago`; + if (diffInSeconds < 2592000) + return `${Math.floor(diffInSeconds / 86400)} days ago`; + return date.toLocaleDateString(); + }; + + return ( +
+ {/* Deployment Summary Cards */} +
+
+
+
Total Deployments
+
+ Last 30 days +
+
+
42
+
+ ↑ 8% from previous period +
+
+ +
+
+
Success Rate
+
+ Last 30 days +
+
+
+ 89.7% +
+
+ ↑ 3.2% from previous period +
+
+
+
+
+ +
+
+
Avg. Duration
+
+ Last 30 days +
+
+
+ 3m 42s +
+
+ ↑ 12% from previous period +
+
+ +
+
+
Deployment Frequency
+
+ Last 30 days +
+
+
+ 1.4/day +
+
+ ↑ 15% from previous period +
+
+
+ + {/* Search and Filters */} +
+
+ + +
+
+ + + Filter + + + + +
+
+ +
+ + + + + Component + + + Version + + + Status + + + Resources + + + Duration + + + Success Rate + + + Deployed By + + + Timestamp + + + + + {deployments.map((deployment) => ( + setSelectedDeployment(deployment)} + > + + {deployment.name} + + + {deployment.version} + + + + + + {deployment.resources} + + + + {formatDuration(deployment.duration)} + + + {deployment.successRate !== null ? ( +
+
+
90 + ? "bg-green-500" + : deployment.successRate > 70 + ? "bg-amber-500" + : "bg-red-500" + }`} + style={{ width: `${deployment.successRate}%` }} + /> +
+ {deployment.successRate}% +
+ ) : ( + + )} + + + {deployment.initiatedBy} + + + {formatTimeAgo(deployment.deployedAt)} + + + ))} + +
+
+ +
+
Showing 5 of 42 deployments
+
+ + +
+
+ + {selectedDeployment && ( + setSelectedDeployment(null)} + /> + )} +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/_components/DeploymentDetail.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/_components/DeploymentDetail.tsx new file mode 100644 index 000000000..67499166f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/_components/DeploymentDetail.tsx @@ -0,0 +1,646 @@ +import { formatDuration } from "date-fns"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +export const DeploymentDetail: React.FC<{ + deployment: any; + onClose: () => void; +}> = ({ deployment, onClose }) => { + if (!deployment) return null; + + return ( +
+
+
+

+ Deployment Details: {deployment.name} +

+ +
+ +
+ {/* Deployment Header with Status Banner */} +
+
+

+ {deployment.name} • {deployment.version} +

+

+ Deployed {formatTimeAgo(deployment.deployedAt)} +

+
+
{renderStatusBadge(deployment.status)}
+
+ + {/* Deployment Info Grid */} +
+
+
+
+

+ Started +

+

+ {deployment.deployedAt.toLocaleString()} +

+
+ +
+

+ Duration +

+

+ {formatDuration(deployment.duration)} +

+
+ +
+

+ Initiated By +

+

{deployment.initiatedBy}

+
+ +
+

+ Resources +

+

{deployment.resources}

+
+
+ +
+

+ Configuration +

+
+
+
Release Channel
+
production
+ +
Target Environment
+
Production
+ +
Rollout Strategy
+
Gradual (30min)
+ +
Required Approval
+
Manual
+ +
Trigger
+
Manual
+ +
Commit
+
8fc12a3
+
+
+
+ +
+

+ Success Rate +

+ {deployment.successRate !== null ? ( +
+
+ + Overall Status + + 90 + ? "text-green-400" + : deployment.successRate > 70 + ? "text-amber-400" + : "text-red-400" + }`} + > + {deployment.successRate}% Success + +
+
+
90 + ? "bg-green-500" + : deployment.successRate > 70 + ? "bg-amber-500" + : "bg-red-500" + }`} + style={{ width: `${deployment.successRate}%` }} + /> +
+ {deployment.status === "failed" && ( +

+ Failure occurred during resource configuration step. See + logs for more details. +

+ )} +
+ ) : ( + + Deployment still in progress + + )} +
+
+ +
+
+

+ Deployment Timeline +

+
+
+
+
+
+ 1 +
+
+

Validation

+

+ Configuration validated successfully +

+
+
+
+
+ 2 +
+
+

+ Resource Preparation +

+

+ Resources prepared for deployment +

+
+
+
+
+ 3 +
+
+

+ Deployment Execution +

+

+ {deployment.status === "success" + ? "Completed successfully" + : deployment.status === "failed" + ? "Failed with errors" + : deployment.status === "pending" + ? "Waiting to start" + : "In progress..."} +

+
+
+
+
+ 4 +
+
+

Health Check

+

+ {deployment.status === "success" + ? "All resources healthy" + : "Pending completion"} +

+
+
+
+
+
+ +
+

+ Deployment Logs +

+
+

+ [ + {new Date( + deployment.deployedAt.getTime(), + ).toLocaleTimeString()} + ] Starting deployment of {deployment.name} version{" "} + {deployment.version}... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 15000, + ).toLocaleTimeString()} + ] Connecting to resource cluster... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 32000, + ).toLocaleTimeString()} + ] Validation checks passed. +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 48000, + ).toLocaleTimeString()} + ] Creating deployment plan for {deployment.resources}{" "} + resources... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 62000, + ).toLocaleTimeString()} + ] Updating configuration... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 95000, + ).toLocaleTimeString()} + ] Applying changes to resources... +

+ {deployment.status === "success" ? ( + <> +

+ [ + {new Date( + deployment.deployedAt.getTime() + 145000, + ).toLocaleTimeString()} + ] Running post-deployment verification... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 180000, + ).toLocaleTimeString()} + ] All health checks passed. +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 185000, + ).toLocaleTimeString()} + ] Deployment completed successfully! +

+ + ) : deployment.status === "failed" ? ( + <> +

+ [ + {new Date( + deployment.deployedAt.getTime() + 110000, + ).toLocaleTimeString()} + ] Updating resource '{deployment.name}-1'... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 125000, + ).toLocaleTimeString()} + ] Error: Failed to update resource '{deployment.name} + -1'. +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 126000, + ).toLocaleTimeString()} + ] Error details: Configuration validation failed - + insufficient permissions. +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 127000, + ).toLocaleTimeString()} + ] Rolling back changes... +

+

+ [ + {new Date( + deployment.deployedAt.getTime() + 135000, + ).toLocaleTimeString()} + ] Deployment failed. See detailed logs for more + information. +

+ + ) : ( + <> +

+ [ + {new Date( + deployment.deployedAt.getTime() + 105000, + ).toLocaleTimeString()} + ] Currently updating resource {deployment.name}-3... +

+

+ [{new Date().toLocaleTimeString()}] Deployment in + progress (2/{deployment.resources} resources + completed)... +

+ + )} +
+
+
+
+ +
+

+ Affected Resources +

+
+ + + + + Resource Name + + + Type + + + Region + + + Previous Version + + + Current Version + + + Status + + + + + {Array.from({ + length: Math.min(3, deployment.resources), + }).map((_, i) => ( + + + {deployment.name}-{i + 1} + + + {deployment.name.includes("Database") + ? "Database" + : deployment.name.includes("Cache") + ? "Cache" + : "Service"} + + + us-west-{i + 1} + + + {i === 0 && deployment.name.includes("Frontend") + ? "v2.0.0" + : i === 0 && deployment.name.includes("Database") + ? "v3.3.0" + : i === 0 && deployment.name.includes("API") + ? "v2.8.5" + : i === 0 && deployment.name.includes("Cache") + ? "v1.9.2" + : i === 0 && deployment.name.includes("Backend") + ? "v4.0.0" + : "v1.0.0"} + + + {deployment.version} + + + {deployment.status === "failed" && i === 0 ? ( + + Failed + + ) : deployment.status === "pending" ? ( + + Pending + + ) : deployment.status === "running" && i > 1 ? ( + + Pending + + ) : deployment.status === "running" && i <= 1 ? ( + + In Progress + + ) : ( + + Success + + )} + + + ))} + +
+
+
+
+ +
+
+ + {deployment.status === "success" && ( + + )} + {deployment.status === "failed" && ( + + )} +
+ +
+ + {deployment.status === "failed" && ( + + )} + {deployment.status === "success" && ( + + )} + {deployment.status === "pending" && ( + + )} +
+
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/page.tsx index f83f3f5e7..d29dcb1b3 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/page.tsx @@ -1,13 +1,26 @@ -import { DeploymentsCard } from "~/app/[workspaceSlug]/(app)/_components/deployments/Card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + +import { EnvironmentDeploymentsPageContent } from "./EnvironmentDeploymentsPageContent"; export default async function DeploymentsPage(props: { params: Promise<{ environmentId: string }>; }) { const { environmentId } = await props.params; - return ( -
- -
+ + + Deployments + View detailed deployment information + + + + + ); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/layout.tsx index cfc535212..4b2f0f188 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/layout.tsx @@ -1,7 +1,7 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import Link from "next/link"; import { notFound } from "next/navigation"; import { IconArrowLeft, IconChartBar } from "@tabler/icons-react"; -import { subMonths } from "date-fns"; import { Breadcrumb, @@ -12,22 +12,13 @@ import { BreadcrumbSeparator, } from "@ctrlplane/ui/breadcrumb"; import { Separator } from "@ctrlplane/ui/separator"; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarInset, - SidebarMenu, - SidebarProvider, - SidebarTrigger, -} from "@ctrlplane/ui/sidebar"; +import { SidebarTrigger } from "@ctrlplane/ui/sidebar"; import { PageHeader } from "~/app/[workspaceSlug]/(app)/_components/PageHeader"; -import { SidebarLink } from "~/app/[workspaceSlug]/(app)/resources/(sidebar)/SidebarLink"; import { Sidebars } from "~/app/[workspaceSlug]/sidebars"; import { urls } from "~/app/urls"; import { api } from "~/trpc/server"; -import { DailyResourceCountGraph } from "./insights/DailyResourcesCountGraph"; +import { EnvironmentTabs } from "./_components/EnvironmentTabs"; export default async function EnvironmentLayout(props: { children: React.ReactNode; @@ -41,31 +32,29 @@ export default async function EnvironmentLayout(props: { const environment = await api.environment.byId(environmentId); if (environment == null) notFound(); - const endDate = new Date(); - const startDate = subMonths(endDate, 1); - - const resourceCounts = await api.resource.stats.dailyCount.byEnvironmentId({ - environmentId: environment.id, - startDate, - endDate, + const system = await api.system.bySlug({ + workspaceSlug, + systemSlug, }); const systemUrls = urls.workspace(workspaceSlug).system(systemSlug); - const environmentUrls = systemUrls.environment(environmentId); + return ( - - +
+
- + + + + {system.name} + + + Environments @@ -84,53 +73,20 @@ export default async function EnvironmentLayout(props: { -
- - - - - - Deployments - - - Policies - - - Resources - - - Variables - - - - - - - {props.children} - - - - -
-

Resources over 30 days

-
- -
-
-
-
-
+
+
+

+ {environment.name} Environment +

+

+ {environment.description || "No description provided"} +

+
+ + + + {props.children}
- +
); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/CopyEnvIdButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/CopyEnvIdButton.tsx new file mode 100644 index 000000000..b86f471fa --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/CopyEnvIdButton.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { IconCopy } from "@tabler/icons-react"; + +import { Button } from "@ctrlplane/ui/button"; +import { toast } from "@ctrlplane/ui/toast"; + +export const CopyEnvIdButton: React.FC<{ + environmentId: string; +}> = ({ environmentId }) => { + const copyEnvironmentId = () => { + navigator.clipboard.writeText(environmentId); + toast.success("Environment ID copied", { + description: environmentId, + duration: 2000, + }); + }; + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/DeploymentTelemetryTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/DeploymentTelemetryTable.tsx new file mode 100644 index 000000000..f740085e4 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/DeploymentTelemetryTable.tsx @@ -0,0 +1,273 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React from "react"; +import { useParams } from "next/navigation"; +import _ from "lodash"; + +import { cn } from "@ctrlplane/ui"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +import { api } from "~/trpc/react"; + +const colors = [ + "bg-teal-500", + "bg-orange-500", + "bg-indigo-500", + "bg-rose-500", + "bg-cyan-500", +]; + +const OtherTooltip: React.FC<{ + distros: { version: string; percentage: number }[]; + children: React.ReactNode; +}> = ({ distros, children }) => ( + + + {children} + + {distros.reverse().map(({ version, percentage }) => ( +
+ {version} + {Number(percentage * 100).toFixed(1)}% +
+ ))} +
+
+
+); + +const DistroTooltip: React.FC<{ + version: string; + percentage: number; + children: React.ReactNode; +}> = ({ version, percentage, children }) => ( + + + {children} + +
{version}
+
{Number(percentage * 100).toFixed(1)}%
+
+
+
+); + +const DistroBar: React.FC<{ + distrosOver5Percent: { version: string; percentage: number }[]; + other: { + percentage: number; + distros: { version: string; percentage: number }[]; + }; + isLoading: boolean; +}> = ({ distrosOver5Percent, other, isLoading }) => { + if (isLoading) return ; + + if (distrosOver5Percent.length === 0 && other.distros.length === 0) + return ( +
+ ); + + return ( +
+ +
+ + + {distrosOver5Percent.map((distro, index) => ( + +
+ + ))} +
+ ); +}; + +const getCleanedDistro = ( + versionDistro: Record, +) => { + const distrosUnder5Percent = Object.entries(versionDistro).filter( + ([, { percentage }]) => percentage < 0.05, + ); + const distrosOver5Percent = Object.entries(versionDistro) + .filter(([, { percentage }]) => percentage >= 0.05) + .map(([version, { percentage }]) => ({ + version, + percentage, + })); + + const other = { + percentage: _.sumBy( + distrosUnder5Percent, + ([, { percentage }]) => percentage, + ), + distros: distrosUnder5Percent.map(([version, { percentage }]) => ({ + version, + percentage, + })), + }; + + return { distrosOver5Percent, other }; +}; + +const DeploymentRow: React.FC<{ + deployment: SCHEMA.Deployment; +}> = ({ deployment }) => { + const { environmentId } = useParams<{ environmentId: string }>(); + const { data: telemetry, isLoading } = + api.environment.page.overview.telemetry.byDeploymentId.useQuery( + { environmentId, deploymentId: deployment.id }, + { refetchInterval: 60_000 }, + ); + + const resourceCount = telemetry?.resourceCount ?? 0; + const versionDistro = telemetry?.versionDistro ?? {}; + const desiredVersion = telemetry?.desiredVersion ?? null; + const tag = desiredVersion?.tag ?? "No version released"; + + const cleanedDistro = getCleanedDistro(versionDistro); + const { other, distrosOver5Percent } = cleanedDistro; + const isEmpty = + cleanedDistro.distrosOver5Percent.length === 0 && + cleanedDistro.other.distros.length === 0; + + return ( + + +
+
+ {deployment.name} + + ({isLoading ? "-" : resourceCount}) + +
+
+ +
+ +
+ {isEmpty && ( + + {isLoading + ? "Loading distribution..." + : "No resources deployed"} + + )} + {other.percentage > 0 && ( +
+ + Other + +
+ )} + {distrosOver5Percent.map((distro) => ( +
+ + {distro.version} + +
+ ))} +
+
+
+ + + {tag} + + + + {desiredVersion != null && ( + +
+ {desiredVersion.status} + + )} + + + ); +}; + +export const DeploymentTelemetryTable: React.FC<{ + deployments: SCHEMA.Deployment[]; +}> = ({ deployments }) => { + return ( +
+

+ Deployment Versions +

+
+ + + + + Deployments + + + Current Distribution + + + Desired Version + + + Deployment Status + + + + + {deployments.map((deployment) => ( + + ))} + +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx new file mode 100644 index 000000000..612dc91c0 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { notFound } from "next/navigation"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + +import { api } from "~/trpc/server"; +import { CopyEnvIdButton } from "./_components/CopyEnvIdButton"; +import { DeploymentTelemetryTable } from "./_components/DeploymentTelemetryTable"; + +export default async function EnvironmentOverviewPage(props: { + params: Promise<{ + workspaceSlug: string; + systemSlug: string; + environmentId: string; + }>; +}) { + const { environmentId } = await props.params; + const environment = await api.environment.byId(environmentId); + if (environment == null) return notFound(); + + const stats = + await api.environment.page.overview.latestDeploymentStats(environmentId); + + const deploymentSuccess = ( + (stats.deployments.successful / (stats.deployments.total || 1)) * + 100 + ).toFixed(1); + + const deployments = await api.deployment.bySystemId(environment.systemId); + + return ( +
+
+ {/* Environment Overview Card */} + + + Environment Details + + +
+
Environment ID
+
+ + {environment.id.substring(0, 8)}... + + +
+ +
Name
+
+ {environment.name} +
+ +
Directory
+ + {environment.directory === "" ? "/" : environment.directory} + + +
Created
+
+ {environment.createdAt.toLocaleDateString()} +
+
+ +
+ {Object.keys(environment.metadata).length > 0 ? ( + <> +
+
+

+ Metadata +

+
+ {Object.entries(environment.metadata).map( + ([key, value]) => ( + + {key} + {value} + + ), + )} +
+
+ + ) : ( +
No metadata
+ )} +
+
+
+ + {/* Deployment Stats Card */} + + + Deployment Statistics + + Overview of deployment performance + + + +
+
+ + Success Rate + + + {deploymentSuccess}% + +
+
+
+
+
+ +
+
+
+ {stats.deployments.total} +
+
+ Total Deployments +
+
+
+
+ {stats.deployments.successful} +
+
Successful
+
+
+
+ {stats.deployments.failed} +
+
Failed
+
+
+
+ {stats.deployments.inProgress + stats.deployments.pending} +
+
In Progress
+
+
+
+
+ + {/* Resources Card */} + + + Resource Overview + Currently managed resources + + +
+
+
+ {stats.resources} +
+
+ Total Resources +
+
+
+
+
+
+ +
+ + + Resource Telemetry + + Real-time deployment status and version distribution across + environment. + + + +
+
+
+
+ + Deployment Status + +
+ + 75% Complete + +
+
+
+
+
+ Started 24 minutes ago + ETA: ~8 minutes +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/page.tsx index 1452a9777..86543a5ea 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; -export default async function EnvironmentPage(props: { +import { urls } from "~/app/urls"; + +export default async function EnvironmentOverviewPage(props: { params: Promise<{ workspaceSlug: string; systemSlug: string; @@ -8,7 +10,10 @@ export default async function EnvironmentPage(props: { }>; }) { const { workspaceSlug, systemSlug, environmentId } = await props.params; - return redirect( - `/${workspaceSlug}/systems/${systemSlug}/environments/${environmentId}/deployments`, - ); + const overviewUrl = urls + .workspace(workspaceSlug) + .system(systemSlug) + .environment(environmentId) + .overview(); + return redirect(overviewUrl); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PoliciesPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PoliciesPageContent.tsx new file mode 100644 index 000000000..941341fbd --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PoliciesPageContent.tsx @@ -0,0 +1,522 @@ +"use client"; + +import type { RouterOutputs } from "@ctrlplane/api"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { + IconAdjustments, + IconArrowUpRight, + IconClock, + IconHelpCircle, + IconInfoCircle, + IconShield, + IconShieldCheck, + IconSwitchHorizontal, +} from "@tabler/icons-react"; +import prettyMs from "pretty-ms"; + +import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert"; +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +import { + EnvironmentPolicyDrawerTab, + param, + useEnvironmentPolicyDrawer, +} from "~/app/[workspaceSlug]/(app)/_components/policy/drawer/EnvironmentPolicyDrawer"; +import { urls } from "~/app/urls"; + +type Environment = NonNullable; + +export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({ + environment, +}) => { + const { isDefaultPolicy } = environment.policy; + const hasParentPolicy = !isDefaultPolicy; + const { policy: environmentPolicy } = environment; + + const formatDurationText = (ms: number) => { + if (ms === 0) return "None"; + return prettyMs(ms, { compact: true, verbose: false }); + }; + + const { workspaceSlug, systemSlug } = useParams<{ + workspaceSlug: string; + systemSlug: string; + }>(); + + const externalPolicyUrl = `${urls.workspace(workspaceSlug).system(systemSlug).policies()}?${param}=${environmentPolicy.id}`; + const router = useRouter(); + + const { setEnvironmentPolicyId } = useEnvironmentPolicyDrawer(); + + const onConfigurePolicyClick = (tab: EnvironmentPolicyDrawerTab) => { + if (hasParentPolicy) router.push(`${externalPolicyUrl}&tab=${tab}`); + if (!hasParentPolicy) setEnvironmentPolicyId(environmentPolicy.id, tab); + }; + + return ( +
+ + + Environment Policies + + Policies control how and when deployments can occur in this + environment + + + + {hasParentPolicy && ( + + +
+ Inherited Parent Policies + +
+

+ These policies are inherited from a parent configuration. + You can override specific settings at the environment + level while maintaining the parent policy structure. +

+
+
+
+ +
+ +
+
+ )} +
+ {/* Approval & Governance */} +
+
+
+

+ + Approval & Governance + + + + + + +

+ Controls who can approve deployments and what + validation criteria must be met before a deployment + can proceed to this environment. +

+
+
+
+

+
+
+
+
+
+ Approval Required + + + + + + +

+ Approval required for deployments to this + environment.{" "} + + Learn more + +

+
+
+
+
+ +
+ + {environmentPolicy.approvalRequirement === "manual" + ? "Yes" + : "No"} + +
+ +
+ Success Criteria + + + + + + +

+ Defines the success requirements for deployments. + Can be set to require all resources to succeed, a + minimum number of resources, or no validation. +

+
+
+
+
+
+
+
+ +
+
+ + {/* Deployment Control */} +
+
+
+

+ + Deployment Control + + + + + + +

+ Settings that control how deployments are executed + and managed in this environment, including + concurrency and resource limits. +

+
+
+
+

+
+
+
+
+
Concurrency Limit
+
+ {environmentPolicy.concurrencyLimit + ? `Max ${environmentPolicy.concurrencyLimit} jobs` + : "Unlimited"} +
+
+
+
+ +
+
+ + {/* Release Management */} +
+
+
+

+ + Release Management + + + + + + +

+ Controls how releases are managed, including how new + versions are handled and how deployments are + sequenced in this environment. +

+
+
+
+

+
+
+
+
+
+ Job Sequencing + + + + + + +

+ Controls what happens to pending jobs when a new + version is created. You can either keep pending jobs + in the queue or cancel them in favor of the new + version. +

+
+
+
+
+
+ {environmentPolicy.releaseSequencing === "wait" + ? "Keep pending jobs" + : "Cancel pending jobs"} +
+
+
+
+ +
+
+ + {/* Deployment Version Channels */} +
+
+
+

+ + Version Channels + + + + + + +

+ Manages which version channels are available and how + versions flow through different stages in this + environment. +

+
+
+
+

+
+
+
+
+
+ Channels Configured + + + + + + +

+ Deployment version channels let you establish a + consistent flow of versions. For example, versions + might flow from beta → stable. +

+
+
+
+
+
+ {environmentPolicy.versionChannels.length} +
+ + {environmentPolicy.versionChannels.length > 0 && ( + <> +
+ Assigned Channels + + + + + + +

+ Channels assigned to this environment control + which versions can be deployed. Only versions + published to these channels will be deployed + here. +

+
+
+
+
+
+
+ {environmentPolicy.versionChannels.map((channel) => ( + + {channel.name} + + ))} +
+
+ + )} +
+
+
+ +
+
+ + {/* Rollout & Timing */} +
+
+
+

+ + Rollout & Timing + + + + + + +

+ Controls the timing aspects of deployments, + including rollout duration, release intervals, and + deployment windows. +

+
+
+
+

+
+
+
+
+
+ Rollout Duration + + + + + + +

+ The time over which deployments will be gradually + rolled out to this environment. A longer duration + provides more time to monitor and catch issues + during deployment. +

+
+
+
+
+
+ {formatDurationText(environmentPolicy.rolloutDuration)} +
+ +
+ Release Interval + + + + + + +

+ Minimum time that must pass between active releases + to this environment. This "cooling period" helps + ensure stability between deployments. +

+
+
+
+
+
+ {environmentPolicy.releaseWindows.length} +
+
+
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/page.tsx index e26b25bda..40a7d4dcb 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/page.tsx @@ -1,13 +1,13 @@ -import { redirect } from "next/navigation"; +import { notFound } from "next/navigation"; -export default function PoliciesPage(props: { - params: { - workspaceSlug: string; - systemSlug: string; - environmentId: string; - }; +import { api } from "~/trpc/server"; +import { PoliciesPageContent } from "./PoliciesPageContent"; + +export default async function PoliciesPage(props: { + params: Promise<{ environmentId: string }>; }) { - return redirect( - `/${props.params.workspaceSlug}/systems/${props.params.systemSlug}/environments/${props.params.environmentId}/policies/approval`, - ); + const { environmentId } = await props.params; + const environment = await api.environment.byId(environmentId); + if (environment == null) return notFound(); + return ; } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourcesPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourcesPageContent.tsx new file mode 100644 index 000000000..28f2cde09 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourcesPageContent.tsx @@ -0,0 +1,467 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { + ComparisonCondition, + ResourceCondition, +} from "@ctrlplane/validators/resources"; +import React, { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + IconFilter, + IconGrid3x3, + IconList, + IconSearch, +} from "@tabler/icons-react"; +import _ from "lodash"; +import { useDebounce } from "react-use"; +import { isPresent } from "ts-is-present"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { Input } from "@ctrlplane/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { + ColumnOperator, + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; +import { + ResourceFilterType, + ResourceOperator, +} from "@ctrlplane/validators/resources"; + +import { ResourceConditionDialog } from "~/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionDialog"; +import { ResourceCard } from "./_components/ResourceCard"; +import { ResourceTable } from "./_components/ResourceTable"; +import { useFilteredResources } from "./_hooks/useFilteredResources"; + +const PAGE_SIZE = 16; + +const safeParseInt = (value: string, total: number) => { + try { + const page = parseInt(value); + if (Number.isNaN(page) || page < 0 || page * PAGE_SIZE >= total) return 0; + return page; + } catch { + return 0; + } +}; + +const usePagination = (total: number) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const page = safeParseInt(searchParams.get("page") ?? "0", total); + const setPage = (page: number) => { + const url = new URL(window.location.href); + url.searchParams.set("page", page.toString()); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + }; + return { page, setPage }; +}; + +const parseResourceFilter = ( + filter: ResourceCondition | null, +): ComparisonCondition | null => { + if (filter == null) return null; + + if (filter.type === "comparison") + return filter.conditions.length > 0 ? filter : null; + + return { + type: "comparison", + operator: "and", + not: false, + conditions: [filter], + }; +}; + +const getResourceFilterFromDropdownChange = ( + resourceFilter: ComparisonCondition | null, + value: string, + type: ResourceFilterType.Kind | ResourceFilterType.Version, +): ComparisonCondition | null => { + if (value === "all") { + if (resourceFilter == null) return null; + + const conditionsExcludingType = resourceFilter.conditions.filter( + (c) => c.type !== type, + ); + + return { ...resourceFilter, conditions: conditionsExcludingType }; + } + + const condition: ResourceCondition = { + type, + operator: ResourceOperator.Equals, + value, + }; + + if (resourceFilter == null) { + return { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [condition], + }; + } + + const conditionsExcludingType = resourceFilter.conditions.filter( + (c) => c.type !== type, + ); + + const newResourceFilter: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [...conditionsExcludingType, condition], + }; + + return parseResourceFilter(newResourceFilter); +}; + +const getResourceFilterWithSearch = ( + resourceFilter: ComparisonCondition | null, + searchTerm: string, +): ComparisonCondition | null => { + if (searchTerm.length === 0) { + if (resourceFilter == null) return null; + + const conditionsExcludingSearch = resourceFilter.conditions.filter( + (c) => c.type !== ResourceFilterType.Name, + ); + + return parseResourceFilter({ + ...resourceFilter, + conditions: conditionsExcludingSearch, + }); + } + + const newNameCondition: ResourceCondition = { + type: ResourceFilterType.Name, + operator: ColumnOperator.Contains, + value: searchTerm, + }; + + if (resourceFilter == null) { + return parseResourceFilter({ + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [newNameCondition], + }); + } + + const conditionsExcludingSearch = resourceFilter.conditions.filter( + (c) => c.type !== ResourceFilterType.Name, + ); + + return parseResourceFilter({ + ...resourceFilter, + conditions: [...conditionsExcludingSearch, newNameCondition], + }); +}; + +export const ResourcesPageContent: React.FC<{ + environment: SCHEMA.Environment; +}> = ({ environment }) => { + const allResourcesQ = useFilteredResources( + environment.id, + environment.resourceFilter, + ); + + const totalResources = allResourcesQ.resources.length; + const healthyResources = allResourcesQ.resources.filter( + (r) => r.status === "healthy", + ).length; + const healthyPercentage = + totalResources > 0 ? (healthyResources / totalResources) * 100 : 0; + const unhealthyResources = allResourcesQ.resources.filter( + (r) => r.status === "unhealthy", + ).length; + const deployingResources = allResourcesQ.resources.filter( + (r) => r.status === "deploying", + ).length; + + const { page, setPage } = usePagination(totalResources); + + const hasPreviousPage = page > 0; + const hasNextPage = (page + 1) * PAGE_SIZE < totalResources; + + const [selectedView, setSelectedView] = useState("grid"); + const [resourceFilter, setResourceFilter] = + useState(null); + + const finalFilter: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [environment.resourceFilter, resourceFilter].filter(isPresent), + }; + + const { resources, isLoading } = useFilteredResources( + environment.id, + finalFilter, + PAGE_SIZE, + page * PAGE_SIZE, + ); + + const handleFilterDropdownChange = ( + value: string, + type: ResourceFilterType.Kind | ResourceFilterType.Version, + ) => { + const newResourceFilter = getResourceFilterFromDropdownChange( + resourceFilter, + value, + type, + ); + setResourceFilter(parseResourceFilter(newResourceFilter)); + }; + + const [search, setSearch] = useState(""); + + useDebounce( + () => { + setResourceFilter(getResourceFilterWithSearch(resourceFilter, search)); + }, + 500, + [search], + ); + + // Group resources by component + const resourcesByVersion = _(resources) + .groupBy((t) => t.version) + .value() as Record; + const resourcesByKind = _(resources) + .groupBy((t) => t.version + ": " + t.kind) + .value() as Record; + + return ( +
+ {/* Resource Summary Cards */} +
+
+
Total Resources
+
+ {resources.length} +
+
+ + Across {Object.keys(resourcesByKind).length} kinds + +
+
+ +
+
+
+ Healthy +
+
+ {healthyResources} +
+
+ + {Number(healthyPercentage).toFixed(0)}% of resources + +
+
+ +
+
+
+ Unhealthy +
+
+ {unhealthyResources} +
+
+ + {unhealthyResources > 0 + ? "Action required" + : "No issues detected"} + +
+
+ +
+
+
+ Deploying +
+
+ {deployingResources} +
+
+ + {deployingResources > 0 + ? "Updates in progress" + : "No active deployments"} + +
+
+
+ + {/* Search and Filters */} +
+
+ + setSearch(e.target.value)} + /> +
+
+ + setResourceFilter(parseResourceFilter(condition)) + } + > + + + + + + + +
+ + +
+
+
+ + {/* Resource Content */} + {selectedView === "grid" && ( +
+ {!isLoading && + resources.map((resource) => ( + + ))} + {isLoading && + Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ )} + {selectedView === "list" && } + +
+
+ {resources.length === resources.length ? ( + <>Showing all {resources.length} resources + ) : ( + <> + Showing {resources.length} of {resources.length} resources + + )} + {resourceFilter != null && resourceFilter.conditions.length > 0 && ( + <> + {" "} + • Filtered + + )} +
+
+ + +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceCard.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceCard.tsx new file mode 100644 index 000000000..d213ee09f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceCard.tsx @@ -0,0 +1,110 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import { IconCopy } from "@tabler/icons-react"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { toast } from "@ctrlplane/ui/toast"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +const statusColor = { + healthy: "bg-green-500", + unhealthy: "bg-red-500", + deploying: "bg-blue-500", +}; + +type ResourceStatus = keyof typeof statusColor; + +const PropertyWithTooltip: React.FC<{ + content: string; +}> = ({ content }) => { + return ( + + + +
{content}
+
+ {content} +
+
+ ); +}; + +type Resource = SCHEMA.Resource & { + status: ResourceStatus; + successRate: number; + provider: SCHEMA.ResourceProvider | null; +}; + +export const ResourceCard: React.FC<{ + resource: Resource; +}> = ({ resource }) => { + const handleCopyId = () => { + navigator.clipboard.writeText(resource.id); + toast("Resource ID copied", { + description: resource.id, + duration: 2000, + }); + }; + + return ( +
+
+
+
+ +
+ + {resource.kind} + +
+ +
+
ID
+
+ + {resource.id.split("-").at(0)}... + + +
+ +
Version
+ + +
Identifier
+ + +
Provider
+ + +
Updated
+
+ {resource.updatedAt?.toLocaleDateString() ?? + resource.createdAt.toLocaleDateString()} +
+ +
Deployment Success
+
+ {(resource.successRate * 100).toFixed(0)}% +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceTable.tsx new file mode 100644 index 000000000..6fcd8a3a8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceTable.tsx @@ -0,0 +1,119 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React from "react"; + +import { cn } from "@ctrlplane/ui"; +import { Badge } from "@ctrlplane/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +const statusColor = { + healthy: "bg-green-500/30 text-green-400 border-green-400", + unhealthy: "bg-red-500/30 text-red-400 border-red-400", + deploying: "bg-blue-500/30 text-blue-400 border-blue-400", +}; + +type ResourceStatus = keyof typeof statusColor; + +type Resource = SCHEMA.Resource & { + status: ResourceStatus; + successRate: number; + provider: SCHEMA.ResourceProvider | null; +}; + +export const ResourceRow: React.FC<{ + resource: Resource; +}> = ({ resource }) => ( + + + {resource.name} + + + {resource.kind} + + + {resource.version} + + + {resource.provider?.name ?? ""} + + +
+
+
= 0.9 + ? "bg-green-500" + : resource.successRate >= 0.5 + ? "bg-yellow-500" + : "bg-red-500", + )} + /> +
+ + {(resource.successRate * 100).toFixed(0)}% + +
+ + + {resource.updatedAt?.toLocaleString()} + + + + {resource.status} + + + +); + +export const ResourceTable: React.FC<{ + resources: Resource[]; +}> = ({ resources }) => ( +
+ + + + + Name + + + Kind + + + + Version + + + + Provider + + + + Success Rate + + + Last Updated + + + Status + + + + + {resources.map((resource) => ( + + ))} + +
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useEnvResourceEditor.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useEnvResourceEditor.ts new file mode 100644 index 000000000..c9e1a354f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useEnvResourceEditor.ts @@ -0,0 +1,70 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { ResourceCondition } from "@ctrlplane/validators/resources"; +import { z } from "zod"; + +import { useForm } from "@ctrlplane/ui/form"; +import { + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; +import { + isComparisonCondition, + resourceCondition, +} from "@ctrlplane/validators/resources"; + +import { api } from "~/trpc/react"; + +const getSelector = ( + resourceFilter: ResourceCondition | null, +): ResourceCondition | undefined => { + if (resourceFilter == null) return undefined; + if (!isComparisonCondition(resourceFilter)) + return { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [resourceFilter], + }; + return resourceFilter; +}; + +const selectorForm = z.object({ + resourceFilter: resourceCondition.optional(), +}); + +/** + * Hook for managing resource selector editing functionality for an environment + * + * @param environment - The environment object containing selector configuration + * @returns Object containing: + * - form: Form instance for managing selector state + * - onSubmit: Handler for submitting selector changes that: + * 1. Updates the environment with new selector + * 2. Resets form with new data + * 3. Invalidates relevant environment queries + */ +export const useEnvResourceEditor = (environment: SCHEMA.Environment) => { + const update = api.environment.update.useMutation(); + + const form = useForm({ + schema: selectorForm, + defaultValues: { + resourceFilter: getSelector(environment.resourceFilter), + }, + }); + const utils = api.useUtils(); + const { resourceFilter } = form.watch(); + const onSubmit = form.handleSubmit((data) => + update + .mutateAsync({ + id: environment.id, + data: { ...data, resourceFilter: resourceFilter ?? null }, + }) + .then(() => form.reset(data)) + .then(() => utils.environment.bySystemId.invalidate(environment.systemId)) + .then(() => utils.environment.byId.invalidate(environment.id)), + ); + return { form, onSubmit }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts new file mode 100644 index 000000000..dd58cabcb --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts @@ -0,0 +1,25 @@ +"use client"; + +import type { ResourceCondition } from "@ctrlplane/validators/resources"; + +import { api } from "~/trpc/react"; + +/** + * Hook for fetching resources based on a filter condition + * + * @param workspaceId - ID of the workspace to fetch resources from + * @param filter - Optional resource filter condition + * @returns Query result containing filtered resources + */ +export const useFilteredResources = ( + environmentId: string, + filter?: ResourceCondition | null, + limit?: number, + offset?: number, +) => { + const resourcesQ = api.environment.page.resources.list.useQuery( + { environmentId, filter: filter ?? undefined, limit, offset }, + { enabled: environmentId !== "" }, + ); + return { ...resourcesQ, resources: resourcesQ.data ?? [] }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/page.tsx index 7d3910b8e..c8c9be904 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/page.tsx @@ -1,21 +1,48 @@ import { notFound } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + import { api } from "~/trpc/server"; -import { EditFilterForm } from "./EditFilterForm"; +import { ResourcesPageContent } from "./ResourcesPageContent"; export default async function ResourcesPage(props: { params: Promise<{ workspaceSlug: string; environmentId: string }>; }) { - const params = await props.params; - const workspace = await api.workspace.bySlug(params.workspaceSlug); - if (workspace == null) notFound(); + const { environmentId } = await props.params; + const environment = await api.environment.byId(environmentId); + if (environment == null) return notFound(); - const environment = await api.environment.byId(params.environmentId); - if (environment == null) notFound(); + const { resourceFilter } = environment; + if (resourceFilter == null) + return ( + + + Resources + + Resources managed in this environment + + + +

No resource filter set for this environment

+
+
+ ); return ( -
- -
+ + + Resources + Resources managed in this environment + + + + + ); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer.tsx index 112006307..904da32e9 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer.tsx @@ -98,7 +98,7 @@ export const EnvironmentDrawer: React.FC = () => { const loading = environmentQ.isLoading || workspaceQ.isLoading || deploymentsQ.isLoading; - const isUsingOverridePolicy = environment?.policy.isOverride ?? false; + const isUsingOverridePolicy = environment?.policy.isDefaultPolicy ?? false; return ( @@ -224,7 +224,7 @@ export const EnvironmentDrawer: React.FC = () => { workspaceId={workspace.id} /> )} - {environment.policy.isOverride && ( + {environment.policy.isDefaultPolicy && ( { return { tab, setTab }; }; -const param = "environment_policy_id"; +export const param = "environment_policy_id"; export const useEnvironmentPolicyDrawer = () => { const router = useRouter(); const params = useSearchParams(); const environmentPolicyId = params.get(param); const { tab, setTab } = useEnvironmentPolicyDrawerTab(); - const setEnvironmentPolicyId = (id: string | null) => { + const setEnvironmentPolicyId = ( + id: string | null, + tab?: EnvironmentPolicyDrawerTab, + ) => { const url = new URL(window.location.href); if (id === null) { url.searchParams.delete(param); @@ -79,6 +82,7 @@ export const useEnvironmentPolicyDrawer = () => { return; } url.searchParams.set(param, id); + if (tab != null) url.searchParams.set(tabParam, tab); router.replace(`${url.pathname}?${url.searchParams.toString()}`); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ComparisonConditionRender.tsx index 7e90879f0..dec4fbe92 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ComparisonConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ComparisonConditionRender.tsx @@ -343,6 +343,17 @@ export const ComparisonConditionRender: React.FC< > Last sync + + addCondition({ + type: ResourceFilterType.Version, + operator: ResourceOperator.Equals, + value: "", + }) + } + > + Version + {depth < 2 && ( diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionBadge.tsx index b76fdb2c3..502cbf4d4 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionBadge.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionBadge.tsx @@ -10,6 +10,7 @@ import type { NameCondition, ProviderCondition, ResourceCondition, + VersionCondition, } from "@ctrlplane/validators/resources"; import React from "react"; import { format } from "date-fns"; @@ -32,6 +33,7 @@ import { isMetadataCondition, isNameCondition, isProviderCondition, + isVersionCondition, ResourceOperator, } from "@ctrlplane/validators/resources"; @@ -246,6 +248,18 @@ const StringifiedLastSyncCondition: React.FC<{ ); +const StringifiedVersionCondition: React.FC<{ + condition: VersionCondition; +}> = ({ condition }) => ( + + version + + {operatorVerbs[condition.operator]} + + {condition.value} + +); + const StringifiedResourceCondition: React.FC<{ condition: ResourceCondition; depth?: number; @@ -286,6 +300,9 @@ const StringifiedResourceCondition: React.FC<{ if (isLastSyncCondition(condition)) return ; + + if (isVersionCondition(condition)) + return ; }; export const ResourceConditionBadge: React.FC<{ diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionRender.tsx index 4a7b16c45..e6838112f 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceConditionRender.tsx @@ -10,6 +10,7 @@ import { isMetadataCondition, isNameCondition, isProviderCondition, + isVersionCondition, } from "@ctrlplane/validators/resources"; import type { ResourceConditionRenderProps } from "./resource-condition-props"; @@ -21,6 +22,7 @@ import { ProviderConditionRender } from "./ProviderConditionRender"; import { ResourceCreatedAtConditionRender } from "./ResourceCreatedAtConditionRender"; import { ResourceLastSyncConditionRender } from "./ResourceLastSyncConditionRender"; import { ResourceMetadataConditionRender } from "./ResourceMetadataConditionRender"; +import { ResourceVersionConditionRender } from "./ResourceVersionConditionRender"; /** * The parent container should have min width of 1000px @@ -102,5 +104,13 @@ export const ResourceConditionRender: React.FC< /> ); + if (isVersionCondition(condition)) + return ( + + ); return null; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceVersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceVersionConditionRender.tsx new file mode 100644 index 000000000..d02e504fe --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceVersionConditionRender.tsx @@ -0,0 +1,40 @@ +import type { VersionCondition } from "@ctrlplane/validators/resources"; +import { useParams } from "next/navigation"; + +import type { ResourceConditionRenderProps } from "./resource-condition-props"; +import { api } from "~/trpc/react"; +import { ChoiceConditionRender } from "../../filter/ChoiceConditionRender"; + +export const ResourceVersionConditionRender: React.FC< + ResourceConditionRenderProps +> = ({ condition, onChange, className }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const workspace = api.workspace.bySlug.useQuery(workspaceSlug); + const versions = api.resource.versions.useQuery(workspace.data?.id ?? "", { + enabled: workspace.isSuccess && workspace.data != null, + }); + + const setVersion = (version: string) => + onChange({ ...condition, value: version }); + + const selectedVersion = versions.data?.find((v) => v === condition.value); + + const options = (versions.data ?? []).map((version) => ({ + key: version, + value: version, + display: version, + })); + + const loading = workspace.isLoading || versions.isLoading; + + return ( + + ); +}; diff --git a/apps/webservice/src/app/urls.ts b/apps/webservice/src/app/urls.ts index ef6c7d854..d3976f27c 100644 --- a/apps/webservice/src/app/urls.ts +++ b/apps/webservice/src/app/urls.ts @@ -106,6 +106,7 @@ const system = (params: SystemParams) => { deployment({ ...params, deploymentSlug }), environments: () => buildUrl(...base, "environments"), environment: (id: string) => environment({ ...params, environmentId: id }), + policies: () => buildUrl(...base, "policies"), runbooks: () => runbooks(params), }; }; @@ -141,6 +142,7 @@ const environment = (params: EnvironmentParams) => { resources: () => buildUrl(...base, "resources"), variables: () => buildUrl(...base, "variables"), settings: () => buildUrl(...base, "settings"), + overview: () => buildUrl(...base, "overview"), }; }; type DeploymentParams = SystemParams & { diff --git a/packages/api/src/router/environment-page/overview/deployment-stats.ts b/packages/api/src/router/environment-page/overview/deployment-stats.ts new file mode 100644 index 000000000..beb3eccdd --- /dev/null +++ b/packages/api/src/router/environment-page/overview/deployment-stats.ts @@ -0,0 +1,117 @@ +import type { Tx } from "@ctrlplane/db"; +import type { JobStatusType } from "@ctrlplane/validators/jobs"; + +import { and, count, desc, eq, inArray, sql, takeFirst } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +const failureStatuses: JobStatusType[] = [ + JobStatus.Failure, + JobStatus.InvalidIntegration, + JobStatus.InvalidJobAgent, + JobStatus.ExternalRunNotFound, +]; + +const pendingStatuses: JobStatusType[] = [ + JobStatus.Pending, + JobStatus.ActionRequired, +]; + +const deployedStatuses = [ + ...failureStatuses, + ...pendingStatuses, + JobStatus.Successful, + JobStatus.InProgress, +]; + +/** + * Get the deployment stats for a given environment and deployment. + * @param db - The database connection. + * @param environment - The environment to get the deployment stats for. + * @param deployment - The deployment to get the deployment stats for. + * @param resourceIds - The resource IDs to get the deployment stats for. + * @returns The count of successful, in progress, pending, and failed deployments across the resources + * for the given environment and deployment. + */ +export const getDeploymentStats = async ( + db: Tx, + environment: SCHEMA.Environment, + deployment: SCHEMA.Deployment, + resourceIds: string[], +) => { + const deploymentResourceIds = await db + .select({ id: SCHEMA.resource.id }) + .from(SCHEMA.resource) + .where( + and( + inArray(SCHEMA.resource.id, resourceIds), + SCHEMA.resourceMatchesMetadata(db, deployment.resourceFilter), + ), + ) + .then((resources) => resources.map((r) => r.id)); + const { length: numResources } = deploymentResourceIds; + + const latestJobsPerResourceAndDeploymentSubquery = db + .selectDistinctOn([SCHEMA.releaseJobTrigger.resourceId], { + resourceId: SCHEMA.releaseJobTrigger.resourceId, + jobId: SCHEMA.job.id, + status: SCHEMA.job.status, + }) + .from(SCHEMA.releaseJobTrigger) + .innerJoin(SCHEMA.job, eq(SCHEMA.releaseJobTrigger.jobId, SCHEMA.job.id)) + .innerJoin( + SCHEMA.resource, + eq(SCHEMA.releaseJobTrigger.resourceId, SCHEMA.resource.id), + ) + .innerJoin( + SCHEMA.deploymentVersion, + eq(SCHEMA.releaseJobTrigger.versionId, SCHEMA.deploymentVersion.id), + ) + .where( + and( + inArray(SCHEMA.releaseJobTrigger.resourceId, deploymentResourceIds), + eq(SCHEMA.releaseJobTrigger.environmentId, environment.id), + eq(SCHEMA.deploymentVersion.deploymentId, deployment.id), + inArray(SCHEMA.job.status, deployedStatuses), + SCHEMA.resourceMatchesMetadata(db, deployment.resourceFilter), + ), + ) + .orderBy( + SCHEMA.releaseJobTrigger.resourceId, + desc(SCHEMA.job.createdAt), + desc(SCHEMA.deploymentVersion.createdAt), + ) + .as("latest_jobs"); + + const statsByJobStatus = await db + .select({ + successful: count( + sql`CASE WHEN ${latestJobsPerResourceAndDeploymentSubquery.status} = ${JobStatus.Successful} THEN 1 ELSE NULL END`, + ), + inProgress: count( + sql`CASE WHEN ${latestJobsPerResourceAndDeploymentSubquery.status} = ${JobStatus.InProgress} THEN 1 ELSE NULL END`, + ), + pending: count( + sql`CASE WHEN ${latestJobsPerResourceAndDeploymentSubquery.status} IN (${sql.raw(pendingStatuses.map((s) => `'${s}'`).join(", "))}) THEN 1 ELSE NULL END`, + ), + failed: count( + sql`CASE WHEN ${latestJobsPerResourceAndDeploymentSubquery.status} IN (${sql.raw(failureStatuses.map((s) => `'${s}'`).join(", "))}) THEN 1 ELSE NULL END`, + ), + }) + .from(latestJobsPerResourceAndDeploymentSubquery) + .then(takeFirst); + + const total = numResources; + const { successful, inProgress, pending, failed } = statsByJobStatus; + const notDeployed = numResources - successful - failed - inProgress - pending; + + return { + deploymentId: deployment.id, + total, + successful, + inProgress, + pending, + failed, + notDeployed, + }; +}; diff --git a/packages/api/src/router/environment-page/overview/desired-version.ts b/packages/api/src/router/environment-page/overview/desired-version.ts new file mode 100644 index 000000000..c6287b219 --- /dev/null +++ b/packages/api/src/router/environment-page/overview/desired-version.ts @@ -0,0 +1,104 @@ +import type { Tx } from "@ctrlplane/db"; + +import { and, desc, eq, inArray, takeFirstOrNull } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +export const getDesiredVersion = async ( + db: Tx, + environment: SCHEMA.Environment, + deployment: SCHEMA.Deployment, + resourceIds: string[], +) => { + const versionChannelSelector = await db + .select({ + versionSelector: SCHEMA.deploymentVersionChannel.versionSelector, + }) + .from(SCHEMA.environmentPolicyDeploymentVersionChannel) + .innerJoin( + SCHEMA.deploymentVersionChannel, + eq( + SCHEMA.environmentPolicyDeploymentVersionChannel.channelId, + SCHEMA.deploymentVersionChannel.id, + ), + ) + .where( + and( + eq( + SCHEMA.environmentPolicyDeploymentVersionChannel.policyId, + environment.policyId, + ), + eq(SCHEMA.deploymentVersionChannel.deploymentId, deployment.id), + ), + ) + .then(takeFirstOrNull) + .then((v) => v?.versionSelector ?? null); + + const desiredVersion = await db + .select() + .from(SCHEMA.deploymentVersion) + .leftJoin( + SCHEMA.environmentPolicyApproval, + and( + eq(SCHEMA.environmentPolicyApproval.policyId, environment.policyId), + eq( + SCHEMA.environmentPolicyApproval.deploymentVersionId, + SCHEMA.deploymentVersion.id, + ), + ), + ) + .where( + and( + eq(SCHEMA.deploymentVersion.deploymentId, deployment.id), + SCHEMA.deploymentVersionMatchesCondition(db, versionChannelSelector), + ), + ) + .orderBy(desc(SCHEMA.deploymentVersion.createdAt)) + .limit(1) + .then(takeFirstOrNull) + .then((v) => { + if (v == null) return null; + return { + ...v.deployment_version, + approval: v.environment_policy_approval, + }; + }); + + if (desiredVersion == null) return null; + + /** + * This needs to be separated from the desired version query + * because subqueries execute independently first. If combined, + * we'd get "latest job per resource" regardless of version, + * then filter by version, missing resources whose latest job + * is for a different version than desired. + */ + const jobs = await db + .selectDistinctOn([SCHEMA.releaseJobTrigger.resourceId]) + .from(SCHEMA.releaseJobTrigger) + .innerJoin(SCHEMA.job, eq(SCHEMA.releaseJobTrigger.jobId, SCHEMA.job.id)) + .where( + and( + inArray(SCHEMA.releaseJobTrigger.resourceId, resourceIds), + eq(SCHEMA.releaseJobTrigger.versionId, desiredVersion.id), + ), + ) + .orderBy(SCHEMA.releaseJobTrigger.resourceId, desc(SCHEMA.job.createdAt)); + + const getDeploymentStatus = () => { + const isPendingApproval = desiredVersion.approval?.status === "pending"; + if (isPendingApproval) return "Pending Approval"; + + const isFailed = jobs.some((j) => j.job.status == JobStatus.Failure); + if (isFailed) return "Failed"; + + const isDeployed = + jobs.every((j) => j.job.status == JobStatus.Successful) && + jobs.length === resourceIds.length; + if (isDeployed) return "Deployed"; + + return "Deploying"; + }; + + return { ...desiredVersion, status: getDeploymentStatus() }; +}; diff --git a/packages/api/src/router/environment-page/overview/router.ts b/packages/api/src/router/environment-page/overview/router.ts new file mode 100644 index 000000000..e7d2f2e7c --- /dev/null +++ b/packages/api/src/router/environment-page/overview/router.ts @@ -0,0 +1,169 @@ +import type { ResourceCondition } from "@ctrlplane/validators/resources"; +import _ from "lodash"; +import { isPresent } from "ts-is-present"; +import { z } from "zod"; + +import { and, eq, isNull, takeFirst } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; +import { + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; + +import { createTRPCRouter, protectedProcedure } from "../../../trpc"; +import { getDeploymentStats } from "./deployment-stats"; +import { getDesiredVersion } from "./desired-version"; +import { getVersionDistro } from "./version-distro"; + +export const overviewRouter = createTRPCRouter({ + latestDeploymentStats: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.EnvironmentGet) + .on({ type: "environment", id: input }), + }) + .query(async ({ ctx, input }) => { + const environment = await ctx.db + .select() + .from(SCHEMA.environment) + .where(eq(SCHEMA.environment.id, input)) + .then(takeFirst); + + const deployments = await ctx.db + .select() + .from(SCHEMA.deployment) + .where(eq(SCHEMA.deployment.systemId, environment.systemId)); + + if (environment.resourceFilter == null) { + return { + deployments: { + total: 0, + successful: 0, + failed: 0, + inProgress: 0, + pending: 0, + notDeployed: 0, + }, + resources: 0, + }; + } + + const resources = await ctx.db + .select({ id: SCHEMA.resource.id }) + .from(SCHEMA.resource) + .where( + and( + isNull(SCHEMA.resource.deletedAt), + SCHEMA.resourceMatchesMetadata(ctx.db, environment.resourceFilter), + ), + ); + + const deploymentPromises = deployments.map((deployment) => + getDeploymentStats( + ctx.db, + environment, + deployment, + resources.map((r) => r.id), + ), + ); + const deploymentStats = await Promise.all(deploymentPromises); + + return { + deployments: { + total: _.sumBy(deploymentStats, (s) => s.total), + successful: _.sumBy(deploymentStats, (s) => s.successful), + failed: _.sumBy(deploymentStats, (s) => s.failed), + inProgress: _.sumBy(deploymentStats, (s) => s.inProgress), + pending: _.sumBy(deploymentStats, (s) => s.pending), + notDeployed: _.sumBy(deploymentStats, (s) => s.notDeployed), + }, + resources: resources.length, + }; + }), + + telemetry: createTRPCRouter({ + byDeploymentId: protectedProcedure + .input( + z.object({ + environmentId: z.string().uuid(), + deploymentId: z.string().uuid(), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.EnvironmentGet) + .on({ type: "environment", id: input.environmentId }), + }) + .query(async ({ ctx, input }) => { + const { environmentId, deploymentId } = input; + + const envPromise = ctx.db + .select() + .from(SCHEMA.environment) + .where(eq(SCHEMA.environment.id, environmentId)) + .then(takeFirst); + + const deploymentPromise = ctx.db + .select() + .from(SCHEMA.deployment) + .where(eq(SCHEMA.deployment.id, deploymentId)) + .then(takeFirst); + + const [environment, deployment] = await Promise.all([ + envPromise, + deploymentPromise, + ]); + + if (environment.resourceFilter == null) return undefined; + + const resourceSelector: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [ + environment.resourceFilter, + deployment.resourceFilter, + ].filter(isPresent), + }; + + const resourceIds = await ctx.db + .select({ id: SCHEMA.resource.id }) + .from(SCHEMA.resource) + .where( + and( + SCHEMA.resourceMatchesMetadata(ctx.db, resourceSelector), + isNull(SCHEMA.resource.deletedAt), + ), + ) + .then((rs) => rs.map((r) => r.id)); + + const versionDistroPromise = getVersionDistro( + ctx.db, + environment, + deployment, + resourceIds, + ); + + const desiredVersionPromise = getDesiredVersion( + ctx.db, + environment, + deployment, + resourceIds, + ); + + const [versionDistro, desiredVersion] = await Promise.all([ + versionDistroPromise, + desiredVersionPromise, + ]); + + return { + resourceCount: resourceIds.length, + versionDistro, + desiredVersion, + }; + }), + }), +}); diff --git a/packages/api/src/router/environment-page/overview/version-distro.ts b/packages/api/src/router/environment-page/overview/version-distro.ts new file mode 100644 index 000000000..ffcdc7419 --- /dev/null +++ b/packages/api/src/router/environment-page/overview/version-distro.ts @@ -0,0 +1,67 @@ +import type { Tx } from "@ctrlplane/db"; +import _ from "lodash"; + +import { and, asc, count, desc, eq, inArray } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +/** + * Get the version distro for a given environment and deployment. + * @param db - The database connection. + * @param environment - The environment to get the version distro for. + * @param deployment - The deployment to get the version distro for. + * @param resourceIds - The resource IDs to get the version distro for. + * @returns The distribution of latest versions across resources for the given environment and deployment. + */ +export const getVersionDistro = async ( + db: Tx, + environment: SCHEMA.Environment, + deployment: SCHEMA.Deployment, + resourceIds: string[], +) => { + const latestVersionByResourceSubquery = db + .selectDistinctOn([SCHEMA.releaseJobTrigger.resourceId], { + tag: SCHEMA.deploymentVersion.tag, + tagResourceId: SCHEMA.releaseJobTrigger.resourceId, + versionCreatedAt: SCHEMA.deploymentVersion.createdAt, + }) + .from(SCHEMA.releaseJobTrigger) + .innerJoin(SCHEMA.job, eq(SCHEMA.releaseJobTrigger.jobId, SCHEMA.job.id)) + .innerJoin( + SCHEMA.deploymentVersion, + eq(SCHEMA.releaseJobTrigger.versionId, SCHEMA.deploymentVersion.id), + ) + .where( + and( + eq(SCHEMA.job.status, JobStatus.Successful), + eq(SCHEMA.releaseJobTrigger.environmentId, environment.id), + eq(SCHEMA.deploymentVersion.deploymentId, deployment.id), + inArray(SCHEMA.releaseJobTrigger.resourceId, resourceIds), + ), + ) + .orderBy(SCHEMA.releaseJobTrigger.resourceId, desc(SCHEMA.job.createdAt)) + .as("latestVersionByResource"); + + const versionCounts = await db + .select({ tag: latestVersionByResourceSubquery.tag, count: count() }) + .from(SCHEMA.resource) + .innerJoin( + latestVersionByResourceSubquery, + eq(SCHEMA.resource.id, latestVersionByResourceSubquery.tagResourceId), + ) + .where(inArray(SCHEMA.resource.id, resourceIds)) + .groupBy( + latestVersionByResourceSubquery.tag, + latestVersionByResourceSubquery.versionCreatedAt, + ) + .orderBy(asc(latestVersionByResourceSubquery.versionCreatedAt)); + + const total = _.sumBy(versionCounts, (v) => v.count); + + return Object.fromEntries( + versionCounts.map((v) => [ + v.tag, + { count: v.count, percentage: v.count / total }, + ]), + ); +}; diff --git a/packages/api/src/router/environment-page/resources/router.ts b/packages/api/src/router/environment-page/resources/router.ts new file mode 100644 index 000000000..0a60dc4f9 --- /dev/null +++ b/packages/api/src/router/environment-page/resources/router.ts @@ -0,0 +1,146 @@ +import type { ResourceCondition } from "@ctrlplane/validators/resources"; +import _ from "lodash"; +import { isPresent } from "ts-is-present"; +import { z } from "zod"; + +import { and, desc, eq, inArray, isNull, takeFirst } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; +import { + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; +import { + activeStatus, + analyticsStatuses, + failedStatuses, + JobStatus, +} from "@ctrlplane/validators/jobs"; +import { resourceCondition } from "@ctrlplane/validators/resources"; + +import { createTRPCRouter, protectedProcedure } from "../../../trpc"; + +export const resourcesRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + environmentId: z.string().uuid(), + filter: resourceCondition.optional(), + limit: z.number().min(1).max(1000).default(500), + offset: z.number().min(0).default(0), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.EnvironmentGet) + .on({ type: "environment", id: input.environmentId }), + }) + .query(async ({ ctx, input }) => { + const environment = await ctx.db + .select() + .from(SCHEMA.environment) + .where(eq(SCHEMA.environment.id, input.environmentId)) + .then(takeFirst); + + const selector: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [environment.resourceFilter, input.filter].filter( + isPresent, + ), + }; + + const resources = await ctx.db + .select() + .from(SCHEMA.resource) + .leftJoin( + SCHEMA.resourceProvider, + eq(SCHEMA.resource.providerId, SCHEMA.resourceProvider.id), + ) + .where( + and( + SCHEMA.resourceMatchesMetadata(ctx.db, selector), + isNull(SCHEMA.resource.deletedAt), + ), + ) + .limit(input.limit) + .offset(input.offset) + .then((rows) => + rows.map((row) => ({ + ...row.resource, + provider: row.resource_provider, + })), + ); + + const rows = await ctx.db + .selectDistinctOn([ + SCHEMA.releaseJobTrigger.resourceId, + SCHEMA.deploymentVersion.deploymentId, + ]) + .from(SCHEMA.releaseJobTrigger) + .innerJoin( + SCHEMA.job, + eq(SCHEMA.releaseJobTrigger.jobId, SCHEMA.job.id), + ) + .innerJoin( + SCHEMA.deploymentVersion, + eq(SCHEMA.releaseJobTrigger.versionId, SCHEMA.deploymentVersion.id), + ) + .orderBy( + SCHEMA.releaseJobTrigger.resourceId, + SCHEMA.deploymentVersion.deploymentId, + desc(SCHEMA.job.createdAt), + ) + .where( + and( + inArray( + SCHEMA.releaseJobTrigger.resourceId, + resources.map((r) => r.id), + ), + eq(SCHEMA.releaseJobTrigger.environmentId, input.environmentId), + ), + ); + + const healthByResource: Record< + string, + { status: "healthy" | "unhealthy" | "deploying"; successRate: number } + > = _.chain(rows) + .groupBy((row) => row.release_job_trigger.resourceId) + .map((groupedRows) => { + const { resourceId } = groupedRows[0]!.release_job_trigger; + const statuses = groupedRows.map((r) => r.job.status); + + const completedStatuses = statuses.filter((status) => + analyticsStatuses.includes(status as JobStatus), + ); + const numSuccess = completedStatuses.filter( + (status) => status === JobStatus.Successful, + ).length; + const successRate = + completedStatuses.length > 0 + ? numSuccess / completedStatuses.length + : 0; + + const isUnhealthy = completedStatuses.some((status) => + failedStatuses.includes(status as JobStatus), + ); + if (isUnhealthy) return [resourceId, "unhealthy"]; + + const isDeploying = statuses.some((status) => + activeStatus.includes(status as JobStatus), + ); + if (isDeploying) return [resourceId, "deploying"]; + + return [resourceId, { status: "healthy", successRate }]; + }) + .fromPairs() + .value(); + + return resources.map((r) => ({ + ...r, + status: healthByResource[r.id]?.status ?? "healthy", + successRate: healthByResource[r.id]?.successRate ?? 0, + })); + }), +}); diff --git a/packages/api/src/router/environment-page/router.ts b/packages/api/src/router/environment-page/router.ts new file mode 100644 index 000000000..9aba8659e --- /dev/null +++ b/packages/api/src/router/environment-page/router.ts @@ -0,0 +1,8 @@ +import { createTRPCRouter } from "../../trpc"; +import { overviewRouter } from "./overview/router"; +import { resourcesRouter } from "./resources/router"; + +export const environmentPageRouter = createTRPCRouter({ + overview: overviewRouter, + resources: resourcesRouter, +}); diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index edec45bbd..a1f9c1416 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -39,12 +39,14 @@ import { } from "@ctrlplane/validators/conditions"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { environmentPageRouter } from "./environment-page/router"; import { policyRouter } from "./environment-policy"; import { environmentStatsRouter } from "./environment-stats"; export const environmentRouter = createTRPCRouter({ policy: policyRouter, stats: environmentStatsRouter, + page: environmentPageRouter, byId: protectedProcedure .meta({ @@ -125,7 +127,7 @@ export const environmentRouter = createTRPCRouter({ .filter(isPresent) .uniqBy((r) => r.id) .value(), - isOverride: + isDefaultPolicy: env.environment_policy.environmentId === env.environment.id, }; diff --git a/packages/api/src/router/resources.ts b/packages/api/src/router/resources.ts index bc3e5520a..e0991db69 100644 --- a/packages/api/src/router/resources.ts +++ b/packages/api/src/router/resources.ts @@ -723,6 +723,22 @@ export const resourceRouter = createTRPCRouter({ .then((r) => r.map((row) => row.key)), ), + versions: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.ResourceList) + .on({ type: "workspace", id: input }), + }) + .query(({ ctx, input }) => + ctx.db + .selectDistinct({ version: schema.resource.version }) + .from(schema.resource) + .where(and(eq(schema.resource.workspaceId, input), isNotDeleted)) + .then((r) => r.map((row) => row.version)), + ), + lock: protectedProcedure .meta({ authorizationCheck: ({ canUser, input }) => diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index c404cc87b..6677f906d 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -302,6 +302,8 @@ const buildCondition = (tx: Tx, cond: ResourceCondition): SQL => { return buildCreatedAtCondition(tx, cond); if (cond.type === ResourceFilterType.LastSync) return buildLastSyncCondition(tx, cond); + if (cond.type === ResourceFilterType.Version) + return eq(resource.version, cond.value); if (cond.conditions.length === 0) return sql`FALSE`; diff --git a/packages/validators/src/jobs/index.ts b/packages/validators/src/jobs/index.ts index faa48bbe2..756f5f60a 100644 --- a/packages/validators/src/jobs/index.ts +++ b/packages/validators/src/jobs/index.ts @@ -50,3 +50,12 @@ export const analyticsStatuses = [ JobStatus.ExternalRunNotFound, JobStatus.Successful, ]; + +export const failedStatuses = [ + JobStatus.Failure, + JobStatus.InvalidJobAgent, + JobStatus.InvalidIntegration, + JobStatus.ExternalRunNotFound, +]; + +export const notDeployedStatuses = [JobStatus.Skipped, JobStatus.Cancelled]; diff --git a/packages/validators/src/resources/conditions/comparison-condition.ts b/packages/validators/src/resources/conditions/comparison-condition.ts index 3464b2d99..ad8ad0a4a 100644 --- a/packages/validators/src/resources/conditions/comparison-condition.ts +++ b/packages/validators/src/resources/conditions/comparison-condition.ts @@ -9,6 +9,7 @@ import type { KindCondition } from "./kind-condition.js"; import type { LastSyncCondition } from "./last-sync-condition.js"; import type { NameCondition } from "./name-condition.js"; import type { ProviderCondition } from "./provider-condition.js"; +import type { VersionCondition } from "./version-condition.js"; import { createdAtCondition, metadataCondition, @@ -18,6 +19,7 @@ import { kindCondition } from "./kind-condition.js"; import { lastSyncCondition } from "./last-sync-condition.js"; import { nameCondition } from "./name-condition.js"; import { providerCondition } from "./provider-condition.js"; +import { versionCondition } from "./version-condition.js"; export const comparisonCondition: z.ZodType = z.lazy(() => z.object({ @@ -34,6 +36,7 @@ export const comparisonCondition: z.ZodType = z.lazy(() => identifierCondition, createdAtCondition, lastSyncCondition, + versionCondition, ]), ), }), @@ -52,5 +55,6 @@ export type ComparisonCondition = { | IdentifierCondition | CreatedAtCondition | LastSyncCondition + | VersionCondition >; }; diff --git a/packages/validators/src/resources/conditions/index.ts b/packages/validators/src/resources/conditions/index.ts index 3cf01a84d..4643d845e 100644 --- a/packages/validators/src/resources/conditions/index.ts +++ b/packages/validators/src/resources/conditions/index.ts @@ -5,3 +5,4 @@ export * from "./resource-condition.js"; export * from "./provider-condition.js"; export * from "./identifier-condition.js"; export * from "./last-sync-condition.js"; +export * from "./version-condition.js"; diff --git a/packages/validators/src/resources/conditions/resource-condition.ts b/packages/validators/src/resources/conditions/resource-condition.ts index 38aa574fa..82eaeb60b 100644 --- a/packages/validators/src/resources/conditions/resource-condition.ts +++ b/packages/validators/src/resources/conditions/resource-condition.ts @@ -10,6 +10,7 @@ import type { KindCondition } from "./kind-condition.js"; import type { LastSyncCondition } from "./last-sync-condition.js"; import type { NameCondition } from "./name-condition.js"; import type { ProviderCondition } from "./provider-condition.js"; +import type { VersionCondition } from "./version-condition.js"; import { createdAtCondition, FilterType, @@ -21,6 +22,7 @@ import { kindCondition } from "./kind-condition.js"; import { lastSyncCondition } from "./last-sync-condition.js"; import { nameCondition } from "./name-condition.js"; import { providerCondition } from "./provider-condition.js"; +import { versionCondition } from "./version-condition.js"; export type ResourceCondition = | ComparisonCondition @@ -30,7 +32,8 @@ export type ResourceCondition = | ProviderCondition | IdentifierCondition | CreatedAtCondition - | LastSyncCondition; + | LastSyncCondition + | VersionCondition; export const resourceCondition = z.union([ comparisonCondition, @@ -41,6 +44,7 @@ export const resourceCondition = z.union([ identifierCondition, createdAtCondition, lastSyncCondition, + versionCondition, ]); export enum ResourceOperator { @@ -59,6 +63,7 @@ export enum ResourceFilterType { Provider = "provider", Comparison = "comparison", LastSync = "last-sync", + Version = "version", } export const defaultCondition: ResourceCondition = { @@ -126,6 +131,11 @@ export const isLastSyncCondition = ( ): condition is LastSyncCondition => condition.type === ResourceFilterType.LastSync; +export const isVersionCondition = ( + condition: ResourceCondition, +): condition is VersionCondition => + condition.type === ResourceFilterType.Version; + export const isValidResourceCondition = ( condition: ResourceCondition, ): boolean => { diff --git a/packages/validators/src/resources/conditions/version-condition.ts b/packages/validators/src/resources/conditions/version-condition.ts new file mode 100644 index 000000000..79e5b806c --- /dev/null +++ b/packages/validators/src/resources/conditions/version-condition.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const versionCondition = z.object({ + type: z.literal("version"), + operator: z.literal("equals"), + value: z.string().min(1), +}); + +export type VersionCondition = z.infer;