From d1c7d4a7a91682c38b27a4fa355584715464681a Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Tue, 18 Mar 2025 03:11:35 -0400 Subject: [PATCH 01/18] init view --- .../environments/[environmentId]/page.tsx | 485 +++++++++++++++++- 1 file changed, 477 insertions(+), 8 deletions(-) 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..6e1e0d401 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,14 +1,483 @@ -import { redirect } from "next/navigation"; +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs"; -export default async function EnvironmentPage(props: { - params: Promise<{ +export default function EnvironmentOverviewPage(props: { + params: { workspaceSlug: string; systemSlug: string; environmentId: string; - }>; + }; }) { - const { workspaceSlug, systemSlug, environmentId } = await props.params; - return redirect( - `/${workspaceSlug}/systems/${systemSlug}/environments/${environmentId}/deployments`, + // Sample static data + const environmentData = { + id: "env-123", + name: "Production", + directory: "prod", + description: "Production environment for customer-facing applications", + createdAt: new Date("2024-01-15"), + metadata: [ + { key: "region", value: "us-west-2" }, + { key: "cluster", value: "main-cluster" }, + { key: "tier", value: "premium" } + ], + policy: { + id: "pol-123", + name: "Production Policy", + approvalRequirement: "manual", + successType: "all", + successMinimum: 1, + concurrencyLimit: 2, + rolloutDuration: 1800000, // 30 minutes + minimumReleaseInterval: 3600000, // 1 hour + releaseWindows: [ + { + recurrence: "daily", + startTime: new Date("2023-01-01T10:00:00"), + endTime: new Date("2023-01-01T16:00:00") + }, + { + recurrence: "weekly", + startTime: new Date("2023-01-01T09:00:00"), + endTime: new Date("2023-01-01T17:00:00") + } + ] + } + }; + + const stats = { + deployments: { + total: 156, + successful: 124, + failed: 18, + inProgress: 10, + pending: 4 + }, + resources: 42 + }; + + const recentReleases = [ + { + id: "rel-123", + tag: "v1.5.0", + createdAt: new Date("2023-12-15T14:30:00"), + metadata: { commitSha: "8fc12a3b923e4b96812f7a8e" } + }, + { + id: "rel-122", + tag: "v1.4.2", + createdAt: new Date("2023-12-10T09:15:00"), + metadata: { commitSha: "3e4b2d1c923e4b96812f7a8e" } + }, + { + id: "rel-121", + tag: "v1.4.1", + createdAt: new Date("2023-12-05T16:45:00"), + metadata: { commitSha: "a7b9c4d3923e4b96812f7a8e" } + } + ]; + + const deploymentSuccess = Math.round((stats.deployments.successful / stats.deployments.total) * 100); + + return ( +
+
+

{environmentData.name} Environment

+

{environmentData.description}

+
+ + + + Overview + Deployments + Resources + Policies + + + +
+ {/* Environment Overview Card */} + + + Environment Details + + +
+
Name
+
+ {environmentData.name} +
+ +
Directory
+
+ {environmentData.directory} +
+ +
Created
+
+ {environmentData.createdAt.toLocaleDateString()} +
+
+ + {environmentData.metadata.length > 0 && ( + <> +
+
+

+ Metadata +

+
+ {environmentData.metadata.map((meta, i) => ( + +
{meta.key}
+
{meta.value}
+
+ ))} +
+
+ + )} +
+
+ + {/* 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 Card */} + + + Resource Telemetry + + Real-time deployment status and version distribution across environment. + + + +
+
+
+
+ Deployment Status +
+ + 75% Complete + +
+
+
+
+
+ Started 24 minutes ago + ETA: ~8 minutes +
+
+ +
+

+ Component Versions +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentCurrent DistributionDesired VersionDeployment Status
Database (9) +
+
+
+
+
+
v3.4.1
+
v3.3.0
+
+
v3.4.1 + + Deployed + + +
API Server (12) +
+
+
+
+
+
v2.8.5
+
v2.7.0
+
+
v3.0.0 + + Pending Approval + + +
Backend (7) +
+
+
+
+
v4.1.0
+
+
v4.1.0 + + Deployed + + +
Frontend (5) +
+
+
+
+
+
v2.0.0
+
v2.1.0-β
+
+
v2.1.0 + + Deploying + + +
Cache (4) +
+
+
+
+
+
+
v1.9.2
+
v2.0.0
+
v1.8.0
+
+
v2.0.0 + + Failed + + +
Monitoring (5) +
+
+
+
+
+
v3.0.1
+
v2.9.5
+
+
v3.0.1 + + Deployed + + +
+
+
+ +
+
+ + {/* Policy Information */} + + + Policy Settings + Deployment governance for this environment + + +
+
+

Approval

+
+
+ Required + + {environmentData.policy.approvalRequirement === "manual" ? "Manual" : "Automatic"} + +
+
+
+ +
+

Rollout Settings

+
+
+ Duration + {environmentData.policy.rolloutDuration > 0 ? `${environmentData.policy.rolloutDuration / 60000} minutes` : "Immediate"} + + Min Interval + {environmentData.policy.minimumReleaseInterval > 0 ? `${environmentData.policy.minimumReleaseInterval / 60000} minutes` : "None"} + + Concurrency + {environmentData.policy.concurrencyLimit || "Unlimited"} +
+
+
+
+ + {environmentData.policy.releaseWindows && environmentData.policy.releaseWindows.length > 0 && ( +
+

Release Windows

+
+
+ {environmentData.policy.releaseWindows.map((window, i) => ( +
+ {window.recurrence} + + {window.startTime.toLocaleTimeString()} - {window.endTime.toLocaleTimeString()} + +
+ ))} +
+
+
+ )} +
+
+ +
+ + + + + Deployments + View detailed deployment information + + +

Deployment details will be displayed here.

+
+
+
+ + + + + Resources + Resources managed in this environment + + +

Resource details will be displayed here.

+
+
+
+ + + + + Policies + Deployment policies and governance + + +

Policy details will be displayed here.

+
+
+
+
+
); -} +} \ No newline at end of file From 0ebe5305289d179348d9e4d6540a61ee31e00948 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Tue, 18 Mar 2025 15:47:47 -0400 Subject: [PATCH 02/18] add more layout --- .../environments/[environmentId]/layout.tsx | 91 +- .../environments/[environmentId]/page.tsx | 2753 ++++++++++++++++- 2 files changed, 2609 insertions(+), 235 deletions(-) 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..002c9f042 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,6 @@ 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 +11,12 @@ 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"; export default async function EnvironmentLayout(props: { children: React.ReactNode; @@ -41,31 +30,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 +71,7 @@ export default async function EnvironmentLayout(props: { -
- - - - - - Deployments - - - Policies - - - Resources - - - Variables - - - - - - - {props.children} - - - - -
-

Resources over 30 days

-
- -
-
-
-
-
-
- +
{props.children}
+
); } 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 6e1e0d401..970e08243 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,2423 @@ +"use client"; + import React from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import Link from "next/link"; +import { + IconAdjustments, + IconArrowUpRight, + IconClock, + IconFilter, + IconHelpCircle, + IconInfoCircle, + IconSearch, + IconShield, + IconShieldCheck, + IconSwitchHorizontal, +} from "@tabler/icons-react"; +import { formatDistanceToNowStrict } from "date-fns"; +import _ from "lodash"; +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 { Input } from "@ctrlplane/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +// Helper time formatting functions +const formatTimeAgo = (date: Date) => { + return formatDistanceToNowStrict(date, { addSuffix: true }); +}; + +const formatDuration = (seconds: number | null | undefined) => { + if (!seconds) return "N/A"; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + return `${remainingSeconds}s`; +}; + +// Helper function for rendering status badges +const renderStatusBadge = (status: string) => { + switch (status.toLowerCase()) { + case "success": + return ( + + Success + + ); + case "pending": + return ( + + Pending + + ); + case "running": + case "deploying": + return ( + + Running + + ); + case "failed": + return ( + + Failed + + ); + default: + return ( + + {status} + + ); + } +}; + +// DeploymentDetail component for showing details of a selected deployment +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" && ( + + )} +
+
+
+
+ ); +}; + +// PoliciesTabContent component for the Policies tab +const PoliciesTabContent: React.FC<{ environmentId: string }> = ({ + environmentId, +}) => { + const hasParentPolicy = true; + // Sample static policy data + const environmentPolicy = { + id: "env-pol-1", + name: "Production Environment Policy", + description: "Policy settings for the production environment", + environmentId: environmentId, + approvalRequirement: "manual", + successType: "all", + successMinimum: 0, + concurrencyLimit: 2, + rolloutDuration: 1800000, // 30 minutes in ms + minimumReleaseInterval: 86400000, // 24 hours in ms + releaseSequencing: "wait", + versionChannels: [ + { id: "channel-1", name: "stable", deploymentId: "deploy-1" }, + { id: "channel-2", name: "beta", deploymentId: "deploy-2" }, + ], + releaseWindows: [ + { + id: "window-1", + recurrence: "weekly", + startTime: new Date("2025-03-18T09:00:00"), + endTime: new Date("2025-03-18T17:00:00"), + }, + ], + }; + + const formatDurationText = (ms: number) => { + if (ms === 0) return "None"; + return prettyMs(ms, { compact: true, verbose: false }); + }; + + 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} +
+
+
+
+ +
+
+
+
+
+
+ ); +}; + +// ResourcesTabContent component for the Resources tab +const ResourcesTabContent: React.FC<{ environmentId: string }> = ({ + environmentId, +}) => { + const [selectedView, setSelectedView] = React.useState("grid"); + const [showFilterEditor, setShowFilterEditor] = React.useState(false); + const [resourceFilter, setResourceFilter] = React.useState({ + type: "comparison", + operator: "and", + not: false, + conditions: [ + { + type: "kind", + operator: "equals", + not: false, + value: "Pod", + }, + ], + }); + + // Sample static resource data + // eslint-disable-next-line react-hooks/exhaustive-deps + const resources = [ + { + id: "res-1", + name: "api-server-pod-1", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "nginx:1.21", + lastUpdated: new Date("2024-03-15T10:30:00"), + component: "API Server", + healthScore: 98, + metrics: { + cpu: 32, + memory: 45, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T10:30:00"), + message: "Pod started successfully", + }, + { + type: "normal", + timestamp: new Date("2024-03-15T10:28:00"), + message: "Container image pulled successfully", + }, + ], + relatedResources: [ + { name: "api-server-service", kind: "Service", status: "healthy" }, + { + name: "api-data-volume", + kind: "PersistentVolume", + status: "healthy", + }, + ], + deploymentHistory: [ + { + date: new Date("2024-03-15"), + version: "v1.21", + deploymentName: "API Server Rollout", + duration: 3, + status: "success", + }, + { + date: new Date("2024-02-28"), + version: "v1.20", + deploymentName: "February Release", + duration: 5, + status: "success", + }, + ], + }, + { + id: "res-2", + name: "frontend-service", + kind: "Service", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "ClusterIP", + lastUpdated: new Date("2024-03-15T09:45:00"), + component: "Frontend", + healthScore: 100, + metrics: { + cpu: 12, + memory: 25, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T09:45:00"), + message: "Service created", + }, + ], + }, + { + id: "res-3", + name: "main-db-instance", + kind: "Database", + provider: "aws", + region: "us-west-2", + status: "degraded", + version: "postgres-13.4", + lastUpdated: new Date("2024-03-14T22:15:00"), + component: "Database", + healthScore: 75, + metrics: { + cpu: 78, + memory: 65, + disk: 82, + }, + events: [ + { + type: "warning", + timestamp: new Date("2024-03-15T02:12:00"), + message: "High disk usage detected", + }, + { + type: "normal", + timestamp: new Date("2024-03-14T22:15:00"), + message: "Database backup completed", + }, + ], + relatedResources: [ + { name: "db-backup-bucket", kind: "Storage", status: "healthy" }, + ], + }, + { + id: "res-4", + name: "cache-redis-01", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "failed", + version: "redis:6.2", + lastUpdated: new Date("2024-03-15T08:12:00"), + component: "Cache", + healthScore: 0, + metrics: { + cpu: 0, + memory: 0, + }, + events: [ + { + type: "error", + timestamp: new Date("2024-03-15T08:12:00"), + message: "Container failed to start: OOMKilled", + }, + { + type: "warning", + timestamp: new Date("2024-03-15T08:11:30"), + message: "Memory usage exceeded limit", + }, + ], + }, + { + id: "res-5", + name: "monitoring-server", + kind: "VM", + provider: "gcp", + region: "us-west-1", + status: "healthy", + version: "n/a", + lastUpdated: new Date("2024-03-10T15:30:00"), + component: "Monitoring", + healthScore: 96, + metrics: { + cpu: 15, + memory: 40, + disk: 30, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-10T15:30:00"), + message: "VM started successfully", + }, + ], + }, + { + id: "res-6", + name: "backend-pod-1", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "backend:4.1.0", + lastUpdated: new Date("2024-03-10T11:45:00"), + component: "Backend", + healthScore: 99, + metrics: { + cpu: 45, + memory: 38, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-10T11:45:00"), + message: "Pod started successfully", + }, + ], + }, + { + id: "res-7", + name: "backend-pod-2", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "backend:4.1.0", + lastUpdated: new Date("2024-03-10T11:45:00"), + component: "Backend", + healthScore: 97, + metrics: { + cpu: 49, + memory: 42, + }, + }, + { + id: "res-8", + name: "analytics-queue", + kind: "Service", + provider: "aws", + region: "us-west-2", + status: "updating", + version: "n/a", + lastUpdated: new Date("2024-03-15T14:22:00"), + component: "Analytics", + healthScore: 90, + metrics: { + cpu: 28, + memory: 35, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T14:22:00"), + message: "Service configuration update in progress", + }, + ], + }, + ]; + + // Group resources by component + const resourcesByComponent = _(resources) + .groupBy((t) => t.component) + .value() as Record; + + // Apply filters to resources + const filteredResources = React.useMemo(() => { + // Start with all resources + let filtered = [...resources]; + + // Apply resource condition filters + if (resourceFilter.conditions.length > 0) { + // If it's an AND operator, each condition must match + if (resourceFilter.operator === "and") { + resourceFilter.conditions.forEach((condition: any) => { + filtered = filtered.filter((resource) => { + switch (condition.type) { + case "kind": + return resource.kind === condition.value; + case "provider": + return resource.provider === condition.value; + case "status": + return resource.status === condition.value; + case "component": + return resource.component === condition.value; + default: + return true; + } + }); + }); + } + // If it's an OR operator, any condition can match + else if (resourceFilter.operator === "or") { + filtered = filtered.filter((resource) => + resourceFilter.conditions.some((condition: any) => { + switch (condition.type) { + case "kind": + return resource.kind === condition.value; + case "provider": + return resource.provider === condition.value; + case "status": + return resource.status === condition.value; + case "component": + return resource.component === condition.value; + default: + return true; + } + }), + ); + } + } + + return filtered; + }, [resources, resourceFilter]); + + const getStatusCount = (status: string) => { + return resources.filter((r) => r.status === status).length; + }; + + const renderResourceCard = (resource: any) => { + const statusColor = { + healthy: "bg-green-500", + degraded: "bg-amber-500", + failed: "bg-red-500", + updating: "bg-blue-500", + unknown: "bg-neutral-500", + }; + + return ( +
+
+
+
+

{resource.name}

+
+ + {resource.kind} + +
+ +
+
Component
+
{resource.component}
+ +
Provider
+
{resource.provider}
+ +
Region
+
{resource.region}
+ +
Updated
+
+ {resource.lastUpdated.toLocaleDateString()} +
+
+ +
+
+ Provider + {resource.provider} +
+ +
+ Deployment Success + 90 ? "green" : resource.healthScore > 70 ? "amber" : "red"}-400`} + > + {resource.healthScore}% + +
+ +
+
+ ID: {resource.id} +
+
+
+
+ ); + }; + + return ( +
+ {/* Resource Summary Cards */} +
+
+
Total Resources
+
+ {resources.length} +
+
+ + Across {Object.keys(resourcesByComponent).length} components + +
+
+ +
+
+
+ Healthy +
+
+ {getStatusCount("healthy")} +
+
+ + {Math.round((getStatusCount("healthy") / resources.length) * 100)} + % of resources + +
+
+ +
+
+
+ Needs Attention +
+
+ {getStatusCount("degraded")} +
+
+ + {getStatusCount("degraded") > 0 + ? "Action required" + : "No issues detected"} + +
+
+ +
+
+
+ Deploying +
+
+ {getStatusCount("updating") + getStatusCount("failed")} +
+
+ + {getStatusCount("updating") > 0 + ? "Updates in progress" + : "No active deployments"} + +
+
+
+ + {/* Search and Filters */} +
+
+ + +
+
+ setShowFilterEditor(true)} + > + + {resourceFilter.conditions.length > 0 + ? `Filter (${resourceFilter.conditions.length})` + : "Filter"} + + + + +
+ + +
+
+
+ + {/* Resource Content */} + {selectedView === "grid" ? ( +
+ {filteredResources.map((resource) => renderResourceCard(resource))} +
+ ) : ( +
+ + + + + Name + + + Kind + + + Component + + + Provider + + + Region + + + Success Rate + + + Last Updated + + + Status + + + + + {filteredResources.map((resource) => ( + + + {resource.name} + + + {resource.kind} + + + {resource.component} + + + {resource.provider} + + + {resource.region} + + +
+
+
90 + ? "bg-green-500" + : resource.healthScore > 70 + ? "bg-amber-500" + : resource.healthScore > 0 + ? "bg-red-500" + : "bg-neutral-600" + }`} + style={{ width: `${resource.healthScore}%` }} + /> +
+ {resource.healthScore}% +
+ + + {resource.lastUpdated.toLocaleString()} + + + + {resource.status.charAt(0).toUpperCase() + + resource.status.slice(1)} + + + + ))} + +
+
+ )} + +
+
+ {filteredResources.length === resources.length ? ( + <>Showing all {resources.length} resources + ) : ( + <> + Showing {filteredResources.length} of {resources.length} resources + + )} + {resourceFilter.conditions.length > 0 && ( + <> + {" "} + • Filtered + + )} +
+
+ + +
+
+ + {/* Resource Filter Editor Modal */} + {showFilterEditor && ( +
+
+
+

+ Edit Resource Filter +

+ +
+ +
+ {/* Current Conditions */} +
+

+ Current Filter Conditions +

+ + {resourceFilter.conditions?.length > 0 ? ( +
+ {resourceFilter.conditions.map( + (condition: any, index: number) => ( +
+
+ + {condition.type.charAt(0).toUpperCase() + + condition.type.slice(1)} + + + equals + + + {condition.value} + +
+ +
+ ), + )} +
+ ) : ( +
+ No filter conditions set. Resources will not be filtered. +
+ )} +
+ + {/* Add New Condition */} +
+

Add New Condition

+ +
+ {/* Condition Type */} +
+ + +
+ + {/* Operator - Static for now */} +
+ + +
+ + {/* Condition Value */} +
+ + +
+ + {/* Add Button */} +
+ + +
+
+
+ + {/* Description of the filter effect */} +
+

Filter Effect

+

+ This filter will{" "} + {resourceFilter.conditions.length > 0 + ? "show only" + : "show all"}{" "} + resources + {resourceFilter.conditions.length > 0 && " that match"} + {resourceFilter.conditions.length > 1 && + resourceFilter.operator === "and" && + " all"} + {resourceFilter.conditions.length > 1 && + resourceFilter.operator === "or" && + " any"} + {resourceFilter.conditions.length > 0 && + " of these conditions."} +

+ {resourceFilter.conditions.length > 0 && ( +
+ + Currently filtering:{" "} + + {resourceFilter.conditions.map((c: any, i: number) => ( + + {i > 0 && ( + + {" "} + {resourceFilter.operator}{" "} + + )} + {c.type} {c.operator} "{c.value}" + + ))} +
+ )} +
+ + {/* Save and Cancel Buttons */} +
+ + +
+
+
+
+ )} +
+ ); +}; + +// DeploymentsTabContent component for the Deployments tab +const DeploymentsTabContent: React.FC<{ environmentId: string }> = () => { + const [selectedDeployment, setSelectedDeployment] = React.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} + + + {renderStatusBadge(deployment.status)} + + + {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)} + /> + )} +
+ ); +}; export default function EnvironmentOverviewPage(props: { params: { @@ -19,7 +2436,7 @@ export default function EnvironmentOverviewPage(props: { metadata: [ { key: "region", value: "us-west-2" }, { key: "cluster", value: "main-cluster" }, - { key: "tier", value: "premium" } + { key: "tier", value: "premium" }, ], policy: { id: "pol-123", @@ -34,15 +2451,15 @@ export default function EnvironmentOverviewPage(props: { { recurrence: "daily", startTime: new Date("2023-01-01T10:00:00"), - endTime: new Date("2023-01-01T16:00:00") + endTime: new Date("2023-01-01T16:00:00"), }, { recurrence: "weekly", startTime: new Date("2023-01-01T09:00:00"), - endTime: new Date("2023-01-01T17:00:00") - } - ] - } + endTime: new Date("2023-01-01T17:00:00"), + }, + ], + }, }; const stats = { @@ -51,38 +2468,21 @@ export default function EnvironmentOverviewPage(props: { successful: 124, failed: 18, inProgress: 10, - pending: 4 + pending: 4, }, - resources: 42 + resources: 42, }; - const recentReleases = [ - { - id: "rel-123", - tag: "v1.5.0", - createdAt: new Date("2023-12-15T14:30:00"), - metadata: { commitSha: "8fc12a3b923e4b96812f7a8e" } - }, - { - id: "rel-122", - tag: "v1.4.2", - createdAt: new Date("2023-12-10T09:15:00"), - metadata: { commitSha: "3e4b2d1c923e4b96812f7a8e" } - }, - { - id: "rel-121", - tag: "v1.4.1", - createdAt: new Date("2023-12-05T16:45:00"), - metadata: { commitSha: "a7b9c4d3923e4b96812f7a8e" } - } - ]; - - const deploymentSuccess = Math.round((stats.deployments.successful / stats.deployments.total) * 100); + const deploymentSuccess = Math.round( + (stats.deployments.successful / stats.deployments.total) * 100, + ); return ( -
+
-

{environmentData.name} Environment

+

+ {environmentData.name} Environment +

{environmentData.description}

@@ -95,7 +2495,7 @@ export default function EnvironmentOverviewPage(props: { -
+
{/* Environment Overview Card */} @@ -104,15 +2504,13 @@ export default function EnvironmentOverviewPage(props: {
Name
-
- {environmentData.name} -
- +
{environmentData.name}
+
Directory
{environmentData.directory}
- +
Created
{environmentData.createdAt.toLocaleDateString()} @@ -144,7 +2542,9 @@ export default function EnvironmentOverviewPage(props: { Deployment Statistics - Overview of deployment performance + + Overview of deployment performance +
@@ -152,10 +2552,12 @@ export default function EnvironmentOverviewPage(props: { Success Rate - {deploymentSuccess}% + + {deploymentSuccess}% +
-
@@ -167,7 +2569,9 @@ export default function EnvironmentOverviewPage(props: {
{stats.deployments.total}
-
Total Deployments
+
+ Total Deployments +
@@ -217,22 +2621,28 @@ export default function EnvironmentOverviewPage(props: { Resource Telemetry - Real-time deployment status and version distribution across environment. + Real-time deployment status and version distribution across + environment. -
-
+
+
-
- Deployment Status +
+ + Deployment Status +
- + 75% Complete
-
-
+
+
Started 24 minutes ago @@ -242,25 +2652,44 @@ export default function EnvironmentOverviewPage(props: {

- Component Versions + Deployment Versions

-
+
- - - - + + + + - + - + - + - + - + - + @@ -379,77 +2875,20 @@ export default function EnvironmentOverviewPage(props: {
ComponentCurrent DistributionDesired VersionDeployment Status + Component + + Current Distribution + + Desired Version + + Deployment Status +
Database (9) + Database{" "} + + (9) + +
-
-
+
+
v3.4.1
@@ -270,17 +2699,30 @@ export default function EnvironmentOverviewPage(props: {
v3.4.1 - Deployed - + + Deployed + +
API Server (12) + API Server{" "} + + (12) + +
-
-
+
+
v2.8.5
@@ -290,16 +2732,26 @@ export default function EnvironmentOverviewPage(props: {
v3.0.0 - Pending Approval - + + Pending Approval + +
Backend (7) + Backend{" "} + + (7) + +
-
+
v4.1.0
@@ -308,17 +2760,30 @@ export default function EnvironmentOverviewPage(props: {
v4.1.0 - Deployed - + + Deployed + +
Frontend (5) + Frontend{" "} + + (5) + +
-
-
+
+
v2.0.0
@@ -328,18 +2793,34 @@ export default function EnvironmentOverviewPage(props: {
v2.1.0 - Deploying - + + Deploying + +
Cache (4) + Cache{" "} + + (4) + +
-
-
-
+
+
+
v1.9.2
@@ -350,17 +2831,30 @@ export default function EnvironmentOverviewPage(props: {
v2.0.0 - Failed - + + Failed + +
Monitoring (5) + Monitoring{" "} + + (5) + +
-
-
+
+
v3.0.1
@@ -370,8 +2864,10 @@ export default function EnvironmentOverviewPage(props: {
v3.0.1 - Deployed - + + Deployed + +
- - - {/* Policy Information */} - - - Policy Settings - Deployment governance for this environment - - -
-
-

Approval

-
-
- Required - - {environmentData.policy.approvalRequirement === "manual" ? "Manual" : "Automatic"} - -
-
-
- -
-

Rollout Settings

-
-
- Duration - {environmentData.policy.rolloutDuration > 0 ? `${environmentData.policy.rolloutDuration / 60000} minutes` : "Immediate"} - - Min Interval - {environmentData.policy.minimumReleaseInterval > 0 ? `${environmentData.policy.minimumReleaseInterval / 60000} minutes` : "None"} - - Concurrency - {environmentData.policy.concurrencyLimit || "Unlimited"} -
-
-
-
- - {environmentData.policy.releaseWindows && environmentData.policy.releaseWindows.length > 0 && ( -
-

Release Windows

-
-
- {environmentData.policy.releaseWindows.map((window, i) => ( -
- {window.recurrence} - - {window.startTime.toLocaleTimeString()} - {window.endTime.toLocaleTimeString()} - -
- ))} -
-
-
- )} -
-
- Deployments - View detailed deployment information + + View detailed deployment information + -

Deployment details will be displayed here.

+
@@ -458,26 +2897,20 @@ export default function EnvironmentOverviewPage(props: { Resources - Resources managed in this environment + + Resources managed in this environment + -

Resource details will be displayed here.

+
- - - Policies - Deployment policies and governance - - -

Policy details will be displayed here.

-
-
+
); -} \ No newline at end of file +} From 5247825c2fdad3e42ca4e748f977fb70db5dcac4 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Tue, 18 Mar 2025 15:55:18 -0400 Subject: [PATCH 03/18] cleanup spacing --- .../environments/[environmentId]/page.tsx | 513 +++++++++--------- 1 file changed, 263 insertions(+), 250 deletions(-) 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 970e08243..02b3f0384 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 @@ -2616,267 +2616,280 @@ export default function EnvironmentOverviewPage(props: {
- {/* Resource Telemetry Card */} - - - Resource Telemetry - - Real-time deployment status and version distribution across - environment. - - - -
-
-
-
- - Deployment Status +
+ + + Resource Telemetry + + Real-time deployment status and version distribution across + environment. + + + +
+
+
+
+ + Deployment Status + +
+ + 75% Complete
- - 75% Complete - -
-
-
-
-
- Started 24 minutes ago - ETA: ~8 minutes +
+
+
+
+ Started 24 minutes ago + ETA: ~8 minutes +
-
-
-

- Deployment Versions -

-
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + +
- Component - - Current Distribution - - Desired Version - - Deployment Status -
- Database{" "} - - (9) - - -
-
-
-
-
-
v3.4.1
-
v3.3.0
-
-
v3.4.1 - - - Deployed +
+

+ Deployment Versions +

+
+ + + + + + + + + + + + - - - - - - + + + - - - - - - + + + - - - - - - + + + - - - - - - + + + - - - - - - + + + - - -
+ Component + + Current Distribution + + Desired Version + + Deployment Status +
+ Database{" "} + + (9) - - -
- API Server{" "} - - (12) - - -
-
-
-
-
-
v2.8.5
-
v2.7.0
-
-
v3.0.0 - - - Pending Approval + +
+
+
+
+
+
v3.4.1
+
v3.3.0
+
+
+ v3.4.1 + + + + Deployed + + - - -
- Backend{" "} - - (7) - - -
-
-
-
-
v4.1.0
-
-
v4.1.0 - - - Deployed +
+ API Server{" "} + + (12) - - -
- Frontend{" "} - - (5) - - -
-
-
-
-
-
v2.0.0
-
v2.1.0-β
-
-
v2.1.0 - - - Deploying + +
+
+
+
+
+
v2.8.5
+
v2.7.0
+
+
+ v3.0.0 + + + + Pending Approval + + - - -
- Cache{" "} - - (4) - - -
-
-
-
-
-
-
v1.9.2
-
v2.0.0
-
v1.8.0
-
-
v2.0.0 - - - Failed +
+ Backend{" "} + + (7) - - -
- Monitoring{" "} - - (5) - - -
-
-
-
-
-
v3.0.1
-
v2.9.5
-
-
v3.0.1 - - - Deployed + +
+
+
+
+
v4.1.0
+
+
+ v4.1.0 + + + + Deployed + + - - -
+
+ Frontend{" "} + + (5) + + +
+
+
+
+
+
v2.0.0
+
v2.1.0-β
+
+
+ v2.1.0 + + + + Deploying + + + +
+ Cache{" "} + + (4) + + +
+
+
+
+
+
+
v1.9.2
+
v2.0.0
+
v1.8.0
+
+
+ v2.0.0 + + + + Failed + + + +
+ Monitoring{" "} + + (5) + + +
+
+
+
+
+
v3.0.1
+
v2.9.5
+
+
+ v3.0.1 + + + + Deployed + + + +
+
-
- - + + +
From 53077e8514fcbd67890d7d8edd173cc75b92ce5e Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Tue, 18 Mar 2025 16:08:49 -0400 Subject: [PATCH 04/18] clean up --- .../environments/[environmentId]/page.tsx | 388 ++++++++---------- 1 file changed, 177 insertions(+), 211 deletions(-) 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 02b3f0384..4abbcb1cf 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 @@ -2654,237 +2654,203 @@ export default function EnvironmentOverviewPage(props: {

Deployment Versions

-
- - - - -
- Component - +
+ + + + + Deployments + + Current Distribution - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ + Desired Version - + + Deployment Status -
- Database{" "} - - (9) - - -
-
-
-
-
-
v3.4.1
-
v3.3.0
-
-
- v3.4.1 - - - - Deployed + + + + + + +
+
+ + Database - - -
- API Server{" "} - - (12) - - -
-
-
-
-
-
v2.8.5
-
v2.7.0
-
-
- v3.0.0 - - - - Pending Approval + + (9) - - -
- Backend{" "} - - (7) - - -
-
-
-
v4.1.0
+ + +
+
+
+
+
+
+
v3.4.1
+
v3.3.0
+
-
- v4.1.0 - - - - Deployed - - + + + + v3.4.1 -
- Frontend{" "} - - (5) + + + +
+ Deployed
-
-
-
-
+ + + + + +
+
+ + API Server + + + (12) +
-
-
v2.0.0
-
v2.1.0-β
+ + +
+
+
+
+
+
+
v2.8.5
+
v2.7.0
+
-
- v2.1.0 - - - - Deploying - - + + + + v3.0.0 -
- Cache{" "} - - (4) + + + +
+ Pending Approval
-
-
-
-
-
+ + + + + +
+
+ + Frontend + + + (5) +
-
-
v1.9.2
-
v2.0.0
-
v1.8.0
+ + +
+
+
+
+
+
+
v2.0.0
+
v2.1.0-β
+
-
- v2.0.0 - - - - Failed - - + + + + v2.1.0 -
- Monitoring{" "} - - (5) + + + +
+ Deploying
-
-
-
-
+ + + + + +
+
+ + Cache + + + (4) +
-
-
v3.0.1
-
v2.9.5
+ + +
+
+
+
+
+
+
+
v1.9.2
+
v2.0.0
+
v1.8.0
+
-
- v3.0.1 - - - - Deployed - - + + + + v2.0.0 + + + + +
+ Failed
-
+ + + +
From b90e902e95816c5fa3f0259b472161728725effa Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 18 Mar 2025 14:04:56 -0700 Subject: [PATCH 05/18] rebase organize --- .../_components/EnvironmentTabs.tsx | 57 + .../{config => _config}/page.tsx | 0 .../[environmentId]/_deployments/page.tsx | 13 + .../DailyResourcesCountGraph.tsx | 0 .../{policies => _policies}/PolicyTabs.tsx | 0 .../approval/ApprovalAndGovernance.tsx | 0 .../{policies => _policies}/approval/page.tsx | 0 .../channels/DeploymentVersionChannels.tsx | 0 .../{policies => _policies}/channels/page.tsx | 0 .../control/DeploymentControl.tsx | 0 .../{policies => _policies}/control/page.tsx | 0 .../{policies => _policies}/layout.tsx | 0 .../management/VersionManagement.tsx | 0 .../management/page.tsx | 0 .../[environmentId]/_policies/page.tsx | 13 + .../rollout/RolloutAndTiming.tsx | 0 .../{policies => _policies}/rollout/page.tsx | 0 .../useUpdatePolicy.ts | 0 .../EditFilterForm.tsx | 0 .../EnvironmentResourcesTable.tsx | 0 .../[environmentId]/_resources/page.tsx | 21 + .../{settings => _settings}/Overview.tsx | 0 .../{settings => _settings}/page.tsx | 0 .../{variables => _variables}/page.tsx | 0 .../deployments/DeploymentDetail.tsx | 646 ++++ .../EnvironmentDeploymentsPageContent.tsx | 381 +++ .../[environmentId]/deployments/page.tsx | 9 +- .../environments/[environmentId]/layout.tsx | 14 +- .../[environmentId]/overview/page.tsx | 508 +++ .../environments/[environmentId]/page.tsx | 2900 +---------------- .../policies/PoliciesPageContent.tsx | 495 +++ .../[environmentId]/policies/page.tsx | 15 +- .../resources/ResourcesPageContent.tsx | 915 ++++++ .../[environmentId]/resources/page.tsx | 21 +- apps/webservice/src/app/urls.ts | 1 + 35 files changed, 3086 insertions(+), 2923 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_components/EnvironmentTabs.tsx rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{config => _config}/page.tsx (100%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_deployments/page.tsx rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{insights => _insights}/DailyResourcesCountGraph.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/PolicyTabs.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/approval/ApprovalAndGovernance.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/approval/page.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/channels/DeploymentVersionChannels.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/channels/page.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/control/DeploymentControl.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/control/page.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/layout.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/management/VersionManagement.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/management/page.tsx (100%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_policies/page.tsx rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/rollout/RolloutAndTiming.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/rollout/page.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{policies => _policies}/useUpdatePolicy.ts (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{resources => _resources}/EditFilterForm.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{resources => _resources}/EnvironmentResourcesTable.tsx (100%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_resources/page.tsx rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{settings => _settings}/Overview.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{settings => _settings}/page.tsx (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/{variables => _variables}/page.tsx (100%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/DeploymentDetail.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PoliciesPageContent.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourcesPageContent.tsx 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..0936d61d2 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/_components/EnvironmentTabs.tsx @@ -0,0 +1,57 @@ +"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 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"; + 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/DeploymentDetail.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/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/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/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..caad3045e --- /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 "./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/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/page.tsx index f83f3f5e7..5fb3781de 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,8 @@ -import { DeploymentsCard } from "~/app/[workspaceSlug]/(app)/_components/deployments/Card"; +import { EnvironmentDeploymentsPageContent } from "./EnvironmentDeploymentsPageContent"; export default async function DeploymentsPage(props: { params: Promise<{ environmentId: string }>; }) { const { environmentId } = await props.params; - - return ( -
- -
- ); + return ; } 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 002c9f042..e2de5a085 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 @@ -17,6 +17,7 @@ import { PageHeader } from "~/app/[workspaceSlug]/(app)/_components/PageHeader"; import { Sidebars } from "~/app/[workspaceSlug]/sidebars"; import { urls } from "~/app/urls"; import { api } from "~/trpc/server"; +import { EnvironmentTabs } from "./_components/EnvironmentTabs"; export default async function EnvironmentLayout(props: { children: React.ReactNode; @@ -71,7 +72,18 @@ export default async function EnvironmentLayout(props: { -
{props.children}
+
+
+

+ {environment.name} Environment +

+

{environment.description}

+
+ + + + {props.children} +
); } 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..f0484ff23 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx @@ -0,0 +1,508 @@ +import React from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + +export default function EnvironmentOverviewPage(_props: { + params: Promise<{ + workspaceSlug: string; + systemSlug: string; + environmentId: string; + }>; +}) { + const environmentData = { + id: "env-123", + name: "Production", + directory: "prod", + description: "Production environment for customer-facing applications", + createdAt: new Date("2024-01-15"), + metadata: [ + { key: "region", value: "us-west-2" }, + { key: "cluster", value: "main-cluster" }, + { key: "tier", value: "premium" }, + ], + policy: { + id: "pol-123", + name: "Production Policy", + approvalRequirement: "manual", + successType: "all", + successMinimum: 1, + concurrencyLimit: 2, + rolloutDuration: 1800000, // 30 minutes + minimumReleaseInterval: 3600000, // 1 hour + releaseWindows: [ + { + recurrence: "daily", + startTime: new Date("2023-01-01T10:00:00"), + endTime: new Date("2023-01-01T16:00:00"), + }, + { + recurrence: "weekly", + startTime: new Date("2023-01-01T09:00:00"), + endTime: new Date("2023-01-01T17:00:00"), + }, + ], + }, + }; + + const stats = { + deployments: { + total: 156, + successful: 124, + failed: 18, + inProgress: 10, + pending: 4, + }, + resources: 42, + }; + + const deploymentSuccess = Math.round( + (stats.deployments.successful / stats.deployments.total) * 100, + ); + + return ( +
+
+ {/* Environment Overview Card */} + + + Environment Details + + +
+
Name
+
{environmentData.name}
+ +
Directory
+
+ {environmentData.directory} +
+ +
Created
+
+ {environmentData.createdAt.toLocaleDateString()} +
+
+ + {environmentData.metadata.length > 0 && ( + <> +
+
+

+ Metadata +

+
+ {environmentData.metadata.map((meta, i) => ( + +
{meta.key}
+
{meta.value}
+
+ ))} +
+
+ + )} +
+
+ + {/* 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 +
+
+ +
+

+ Deployment Versions +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Component + + Current Distribution + + Desired Version + + Deployment Status +
+ Database{" "} + + (9) + + +
+
+
+
+
+
v3.4.1
+
v3.3.0
+
+
v3.4.1 + + + Deployed + + + +
+ API Server{" "} + + (12) + + +
+
+
+
+
+
v2.8.5
+
v2.7.0
+
+
v3.0.0 + + + Pending Approval + + + +
+ Backend{" "} + + (7) + + +
+
+
+
+
v4.1.0
+
+
v4.1.0 + + + Deployed + + + +
+ Frontend{" "} + + (5) + + +
+
+
+
+
+
v2.0.0
+
v2.1.0-β
+
+
v2.1.0 + + + Deploying + + + +
+ Cache{" "} + + (4) + + +
+
+
+
+
+
+
v1.9.2
+
v2.0.0
+
v1.8.0
+
+
v2.0.0 + + + Failed + + + +
+ Monitoring{" "} + + (5) + + +
+
+
+
+
+
v3.0.1
+
v2.9.5
+
+
v3.0.1 + + + Deployed + + + +
+
+
+
+
+
+
+ + //
+ //
+ //

+ // {environmentData.name} Environment + //

+ //

{environmentData.description}

+ //
+ + // + // + // Overview + // Deployments + // Resources + // Policies + // + + // + + // + + // + // + // + // Deployments + // + // View detailed deployment information + // + // + // + // + // + // + // + + // + // + // + // Resources + // + // Resources managed in this environment + // + // + // + // + // + // + // + + // + // + // + // + ); +} 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 4abbcb1cf..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,2895 +1,19 @@ -"use client"; +import { redirect } from "next/navigation"; -import React from "react"; -import Link from "next/link"; -import { - IconAdjustments, - IconArrowUpRight, - IconClock, - IconFilter, - IconHelpCircle, - IconInfoCircle, - IconSearch, - IconShield, - IconShieldCheck, - IconSwitchHorizontal, -} from "@tabler/icons-react"; -import { formatDistanceToNowStrict } from "date-fns"; -import _ from "lodash"; -import prettyMs from "pretty-ms"; +import { urls } from "~/app/urls"; -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 { Input } from "@ctrlplane/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ctrlplane/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@ctrlplane/ui/tooltip"; - -// Helper time formatting functions -const formatTimeAgo = (date: Date) => { - return formatDistanceToNowStrict(date, { addSuffix: true }); -}; - -const formatDuration = (seconds: number | null | undefined) => { - if (!seconds) return "N/A"; - - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); - - if (minutes > 0) { - return `${minutes}m ${remainingSeconds}s`; - } - return `${remainingSeconds}s`; -}; - -// Helper function for rendering status badges -const renderStatusBadge = (status: string) => { - switch (status.toLowerCase()) { - case "success": - return ( - - Success - - ); - case "pending": - return ( - - Pending - - ); - case "running": - case "deploying": - return ( - - Running - - ); - case "failed": - return ( - - Failed - - ); - default: - return ( - - {status} - - ); - } -}; - -// DeploymentDetail component for showing details of a selected deployment -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" && ( - - )} -
-
-
-
- ); -}; - -// PoliciesTabContent component for the Policies tab -const PoliciesTabContent: React.FC<{ environmentId: string }> = ({ - environmentId, -}) => { - const hasParentPolicy = true; - // Sample static policy data - const environmentPolicy = { - id: "env-pol-1", - name: "Production Environment Policy", - description: "Policy settings for the production environment", - environmentId: environmentId, - approvalRequirement: "manual", - successType: "all", - successMinimum: 0, - concurrencyLimit: 2, - rolloutDuration: 1800000, // 30 minutes in ms - minimumReleaseInterval: 86400000, // 24 hours in ms - releaseSequencing: "wait", - versionChannels: [ - { id: "channel-1", name: "stable", deploymentId: "deploy-1" }, - { id: "channel-2", name: "beta", deploymentId: "deploy-2" }, - ], - releaseWindows: [ - { - id: "window-1", - recurrence: "weekly", - startTime: new Date("2025-03-18T09:00:00"), - endTime: new Date("2025-03-18T17:00:00"), - }, - ], - }; - - const formatDurationText = (ms: number) => { - if (ms === 0) return "None"; - return prettyMs(ms, { compact: true, verbose: false }); - }; - - 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} -
-
-
-
- -
-
-
-
-
-
- ); -}; - -// ResourcesTabContent component for the Resources tab -const ResourcesTabContent: React.FC<{ environmentId: string }> = ({ - environmentId, -}) => { - const [selectedView, setSelectedView] = React.useState("grid"); - const [showFilterEditor, setShowFilterEditor] = React.useState(false); - const [resourceFilter, setResourceFilter] = React.useState({ - type: "comparison", - operator: "and", - not: false, - conditions: [ - { - type: "kind", - operator: "equals", - not: false, - value: "Pod", - }, - ], - }); - - // Sample static resource data - // eslint-disable-next-line react-hooks/exhaustive-deps - const resources = [ - { - id: "res-1", - name: "api-server-pod-1", - kind: "Pod", - provider: "kubernetes", - region: "us-west-2", - status: "healthy", - version: "nginx:1.21", - lastUpdated: new Date("2024-03-15T10:30:00"), - component: "API Server", - healthScore: 98, - metrics: { - cpu: 32, - memory: 45, - }, - events: [ - { - type: "normal", - timestamp: new Date("2024-03-15T10:30:00"), - message: "Pod started successfully", - }, - { - type: "normal", - timestamp: new Date("2024-03-15T10:28:00"), - message: "Container image pulled successfully", - }, - ], - relatedResources: [ - { name: "api-server-service", kind: "Service", status: "healthy" }, - { - name: "api-data-volume", - kind: "PersistentVolume", - status: "healthy", - }, - ], - deploymentHistory: [ - { - date: new Date("2024-03-15"), - version: "v1.21", - deploymentName: "API Server Rollout", - duration: 3, - status: "success", - }, - { - date: new Date("2024-02-28"), - version: "v1.20", - deploymentName: "February Release", - duration: 5, - status: "success", - }, - ], - }, - { - id: "res-2", - name: "frontend-service", - kind: "Service", - provider: "kubernetes", - region: "us-west-2", - status: "healthy", - version: "ClusterIP", - lastUpdated: new Date("2024-03-15T09:45:00"), - component: "Frontend", - healthScore: 100, - metrics: { - cpu: 12, - memory: 25, - }, - events: [ - { - type: "normal", - timestamp: new Date("2024-03-15T09:45:00"), - message: "Service created", - }, - ], - }, - { - id: "res-3", - name: "main-db-instance", - kind: "Database", - provider: "aws", - region: "us-west-2", - status: "degraded", - version: "postgres-13.4", - lastUpdated: new Date("2024-03-14T22:15:00"), - component: "Database", - healthScore: 75, - metrics: { - cpu: 78, - memory: 65, - disk: 82, - }, - events: [ - { - type: "warning", - timestamp: new Date("2024-03-15T02:12:00"), - message: "High disk usage detected", - }, - { - type: "normal", - timestamp: new Date("2024-03-14T22:15:00"), - message: "Database backup completed", - }, - ], - relatedResources: [ - { name: "db-backup-bucket", kind: "Storage", status: "healthy" }, - ], - }, - { - id: "res-4", - name: "cache-redis-01", - kind: "Pod", - provider: "kubernetes", - region: "us-west-2", - status: "failed", - version: "redis:6.2", - lastUpdated: new Date("2024-03-15T08:12:00"), - component: "Cache", - healthScore: 0, - metrics: { - cpu: 0, - memory: 0, - }, - events: [ - { - type: "error", - timestamp: new Date("2024-03-15T08:12:00"), - message: "Container failed to start: OOMKilled", - }, - { - type: "warning", - timestamp: new Date("2024-03-15T08:11:30"), - message: "Memory usage exceeded limit", - }, - ], - }, - { - id: "res-5", - name: "monitoring-server", - kind: "VM", - provider: "gcp", - region: "us-west-1", - status: "healthy", - version: "n/a", - lastUpdated: new Date("2024-03-10T15:30:00"), - component: "Monitoring", - healthScore: 96, - metrics: { - cpu: 15, - memory: 40, - disk: 30, - }, - events: [ - { - type: "normal", - timestamp: new Date("2024-03-10T15:30:00"), - message: "VM started successfully", - }, - ], - }, - { - id: "res-6", - name: "backend-pod-1", - kind: "Pod", - provider: "kubernetes", - region: "us-west-2", - status: "healthy", - version: "backend:4.1.0", - lastUpdated: new Date("2024-03-10T11:45:00"), - component: "Backend", - healthScore: 99, - metrics: { - cpu: 45, - memory: 38, - }, - events: [ - { - type: "normal", - timestamp: new Date("2024-03-10T11:45:00"), - message: "Pod started successfully", - }, - ], - }, - { - id: "res-7", - name: "backend-pod-2", - kind: "Pod", - provider: "kubernetes", - region: "us-west-2", - status: "healthy", - version: "backend:4.1.0", - lastUpdated: new Date("2024-03-10T11:45:00"), - component: "Backend", - healthScore: 97, - metrics: { - cpu: 49, - memory: 42, - }, - }, - { - id: "res-8", - name: "analytics-queue", - kind: "Service", - provider: "aws", - region: "us-west-2", - status: "updating", - version: "n/a", - lastUpdated: new Date("2024-03-15T14:22:00"), - component: "Analytics", - healthScore: 90, - metrics: { - cpu: 28, - memory: 35, - }, - events: [ - { - type: "normal", - timestamp: new Date("2024-03-15T14:22:00"), - message: "Service configuration update in progress", - }, - ], - }, - ]; - - // Group resources by component - const resourcesByComponent = _(resources) - .groupBy((t) => t.component) - .value() as Record; - - // Apply filters to resources - const filteredResources = React.useMemo(() => { - // Start with all resources - let filtered = [...resources]; - - // Apply resource condition filters - if (resourceFilter.conditions.length > 0) { - // If it's an AND operator, each condition must match - if (resourceFilter.operator === "and") { - resourceFilter.conditions.forEach((condition: any) => { - filtered = filtered.filter((resource) => { - switch (condition.type) { - case "kind": - return resource.kind === condition.value; - case "provider": - return resource.provider === condition.value; - case "status": - return resource.status === condition.value; - case "component": - return resource.component === condition.value; - default: - return true; - } - }); - }); - } - // If it's an OR operator, any condition can match - else if (resourceFilter.operator === "or") { - filtered = filtered.filter((resource) => - resourceFilter.conditions.some((condition: any) => { - switch (condition.type) { - case "kind": - return resource.kind === condition.value; - case "provider": - return resource.provider === condition.value; - case "status": - return resource.status === condition.value; - case "component": - return resource.component === condition.value; - default: - return true; - } - }), - ); - } - } - - return filtered; - }, [resources, resourceFilter]); - - const getStatusCount = (status: string) => { - return resources.filter((r) => r.status === status).length; - }; - - const renderResourceCard = (resource: any) => { - const statusColor = { - healthy: "bg-green-500", - degraded: "bg-amber-500", - failed: "bg-red-500", - updating: "bg-blue-500", - unknown: "bg-neutral-500", - }; - - return ( -
-
-
-
-

{resource.name}

-
- - {resource.kind} - -
- -
-
Component
-
{resource.component}
- -
Provider
-
{resource.provider}
- -
Region
-
{resource.region}
- -
Updated
-
- {resource.lastUpdated.toLocaleDateString()} -
-
- -
-
- Provider - {resource.provider} -
- -
- Deployment Success - 90 ? "green" : resource.healthScore > 70 ? "amber" : "red"}-400`} - > - {resource.healthScore}% - -
- -
-
- ID: {resource.id} -
-
-
-
- ); - }; - - return ( -
- {/* Resource Summary Cards */} -
-
-
Total Resources
-
- {resources.length} -
-
- - Across {Object.keys(resourcesByComponent).length} components - -
-
- -
-
-
- Healthy -
-
- {getStatusCount("healthy")} -
-
- - {Math.round((getStatusCount("healthy") / resources.length) * 100)} - % of resources - -
-
- -
-
-
- Needs Attention -
-
- {getStatusCount("degraded")} -
-
- - {getStatusCount("degraded") > 0 - ? "Action required" - : "No issues detected"} - -
-
- -
-
-
- Deploying -
-
- {getStatusCount("updating") + getStatusCount("failed")} -
-
- - {getStatusCount("updating") > 0 - ? "Updates in progress" - : "No active deployments"} - -
-
-
- - {/* Search and Filters */} -
-
- - -
-
- setShowFilterEditor(true)} - > - - {resourceFilter.conditions.length > 0 - ? `Filter (${resourceFilter.conditions.length})` - : "Filter"} - - - - -
- - -
-
-
- - {/* Resource Content */} - {selectedView === "grid" ? ( -
- {filteredResources.map((resource) => renderResourceCard(resource))} -
- ) : ( -
- - - - - Name - - - Kind - - - Component - - - Provider - - - Region - - - Success Rate - - - Last Updated - - - Status - - - - - {filteredResources.map((resource) => ( - - - {resource.name} - - - {resource.kind} - - - {resource.component} - - - {resource.provider} - - - {resource.region} - - -
-
-
90 - ? "bg-green-500" - : resource.healthScore > 70 - ? "bg-amber-500" - : resource.healthScore > 0 - ? "bg-red-500" - : "bg-neutral-600" - }`} - style={{ width: `${resource.healthScore}%` }} - /> -
- {resource.healthScore}% -
- - - {resource.lastUpdated.toLocaleString()} - - - - {resource.status.charAt(0).toUpperCase() + - resource.status.slice(1)} - - - - ))} - -
-
- )} - -
-
- {filteredResources.length === resources.length ? ( - <>Showing all {resources.length} resources - ) : ( - <> - Showing {filteredResources.length} of {resources.length} resources - - )} - {resourceFilter.conditions.length > 0 && ( - <> - {" "} - • Filtered - - )} -
-
- - -
-
- - {/* Resource Filter Editor Modal */} - {showFilterEditor && ( -
-
-
-

- Edit Resource Filter -

- -
- -
- {/* Current Conditions */} -
-

- Current Filter Conditions -

- - {resourceFilter.conditions?.length > 0 ? ( -
- {resourceFilter.conditions.map( - (condition: any, index: number) => ( -
-
- - {condition.type.charAt(0).toUpperCase() + - condition.type.slice(1)} - - - equals - - - {condition.value} - -
- -
- ), - )} -
- ) : ( -
- No filter conditions set. Resources will not be filtered. -
- )} -
- - {/* Add New Condition */} -
-

Add New Condition

- -
- {/* Condition Type */} -
- - -
- - {/* Operator - Static for now */} -
- - -
- - {/* Condition Value */} -
- - -
- - {/* Add Button */} -
- - -
-
-
- - {/* Description of the filter effect */} -
-

Filter Effect

-

- This filter will{" "} - {resourceFilter.conditions.length > 0 - ? "show only" - : "show all"}{" "} - resources - {resourceFilter.conditions.length > 0 && " that match"} - {resourceFilter.conditions.length > 1 && - resourceFilter.operator === "and" && - " all"} - {resourceFilter.conditions.length > 1 && - resourceFilter.operator === "or" && - " any"} - {resourceFilter.conditions.length > 0 && - " of these conditions."} -

- {resourceFilter.conditions.length > 0 && ( -
- - Currently filtering:{" "} - - {resourceFilter.conditions.map((c: any, i: number) => ( - - {i > 0 && ( - - {" "} - {resourceFilter.operator}{" "} - - )} - {c.type} {c.operator} "{c.value}" - - ))} -
- )} -
- - {/* Save and Cancel Buttons */} -
- - -
-
-
-
- )} -
- ); -}; - -// DeploymentsTabContent component for the Deployments tab -const DeploymentsTabContent: React.FC<{ environmentId: string }> = () => { - const [selectedDeployment, setSelectedDeployment] = React.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} - - - {renderStatusBadge(deployment.status)} - - - {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)} - /> - )} -
- ); -}; - -export default function EnvironmentOverviewPage(props: { - params: { +export default async function EnvironmentOverviewPage(props: { + params: Promise<{ workspaceSlug: string; systemSlug: string; environmentId: string; - }; + }>; }) { - // Sample static data - const environmentData = { - id: "env-123", - name: "Production", - directory: "prod", - description: "Production environment for customer-facing applications", - createdAt: new Date("2024-01-15"), - metadata: [ - { key: "region", value: "us-west-2" }, - { key: "cluster", value: "main-cluster" }, - { key: "tier", value: "premium" }, - ], - policy: { - id: "pol-123", - name: "Production Policy", - approvalRequirement: "manual", - successType: "all", - successMinimum: 1, - concurrencyLimit: 2, - rolloutDuration: 1800000, // 30 minutes - minimumReleaseInterval: 3600000, // 1 hour - releaseWindows: [ - { - recurrence: "daily", - startTime: new Date("2023-01-01T10:00:00"), - endTime: new Date("2023-01-01T16:00:00"), - }, - { - recurrence: "weekly", - startTime: new Date("2023-01-01T09:00:00"), - endTime: new Date("2023-01-01T17:00:00"), - }, - ], - }, - }; - - const stats = { - deployments: { - total: 156, - successful: 124, - failed: 18, - inProgress: 10, - pending: 4, - }, - resources: 42, - }; - - const deploymentSuccess = Math.round( - (stats.deployments.successful / stats.deployments.total) * 100, - ); - - return ( -
-
-

- {environmentData.name} Environment -

-

{environmentData.description}

-
- - - - Overview - Deployments - Resources - Policies - - - -
- {/* Environment Overview Card */} - - - Environment Details - - -
-
Name
-
{environmentData.name}
- -
Directory
-
- {environmentData.directory} -
- -
Created
-
- {environmentData.createdAt.toLocaleDateString()} -
-
- - {environmentData.metadata.length > 0 && ( - <> -
-
-

- Metadata -

-
- {environmentData.metadata.map((meta, i) => ( - -
{meta.key}
-
{meta.value}
-
- ))} -
-
- - )} -
-
- - {/* 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 -
-
- -
-

- Deployment Versions -

-
- - - - - Deployments - - - Current Distribution - - - Desired Version - - - Deployment Status - - - - - - -
-
- - Database - - - (9) - -
-
- -
-
-
-
-
-
-
v3.4.1
-
v3.3.0
-
-
-
- - - v3.4.1 - - - - -
- Deployed -
-
-
- - - -
-
- - API Server - - - (12) - -
-
- -
-
-
-
-
-
-
v2.8.5
-
v2.7.0
-
-
-
- - - v3.0.0 - - - - -
- Pending Approval -
-
-
- - - -
-
- - Frontend - - - (5) - -
-
- -
-
-
-
-
-
-
v2.0.0
-
v2.1.0-β
-
-
-
- - - v2.1.0 - - - - -
- Deploying -
-
-
- - - -
-
- - Cache - - - (4) - -
-
- -
-
-
-
-
-
-
-
v1.9.2
-
v2.0.0
-
v1.8.0
-
-
-
- - - v2.0.0 - - - - -
- Failed -
-
-
-
-
-
-
-
-
-
-
- - - - - Deployments - - View detailed deployment information - - - - - - - - - - - - Resources - - Resources managed in this environment - - - - - - - - - - - -
-
- ); + const { workspaceSlug, systemSlug, environmentId } = await props.params; + 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..9dbf4cc46 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/policies/PoliciesPageContent.tsx @@ -0,0 +1,495 @@ +"use client"; + +import Link from "next/link"; +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"; + +// PoliciesTabContent component for the Policies tab +export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ + environmentId, +}) => { + const hasParentPolicy = true; + // Sample static policy data + const environmentPolicy = { + id: "env-pol-1", + name: "Production Environment Policy", + description: "Policy settings for the production environment", + environmentId: environmentId, + approvalRequirement: "manual", + successType: "all", + successMinimum: 0, + concurrencyLimit: 2, + rolloutDuration: 1800000, // 30 minutes in ms + minimumReleaseInterval: 86400000, // 24 hours in ms + releaseSequencing: "wait", + versionChannels: [ + { id: "channel-1", name: "stable", deploymentId: "deploy-1" }, + { id: "channel-2", name: "beta", deploymentId: "deploy-2" }, + ], + releaseWindows: [ + { + id: "window-1", + recurrence: "weekly", + startTime: new Date("2025-03-18T09:00:00"), + endTime: new Date("2025-03-18T17:00:00"), + }, + ], + }; + + const formatDurationText = (ms: number) => { + if (ms === 0) return "None"; + return prettyMs(ms, { compact: true, verbose: false }); + }; + + 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..49abf32d5 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,8 @@ -import { redirect } from "next/navigation"; +import { PoliciesPageContent } from "./PoliciesPageContent"; -export default function PoliciesPage(props: { - params: { - workspaceSlug: string; - systemSlug: string; - environmentId: string; - }; +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; + 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..e6e795d8b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourcesPageContent.tsx @@ -0,0 +1,915 @@ +"use client"; + +import React, { useState } from "react"; +import { IconFilter, IconSearch } from "@tabler/icons-react"; +import _ from "lodash"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Input } from "@ctrlplane/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +export const ResourcesPageContent: React.FC<{ environmentId: string }> = ({ + environmentId, +}) => { + const [selectedView, setSelectedView] = useState("grid"); + const [showFilterEditor, setShowFilterEditor] = useState(false); + const [resourceFilter, setResourceFilter] = useState({ + type: "comparison", + operator: "and", + not: false, + conditions: [ + { + type: "kind", + operator: "equals", + not: false, + value: "Pod", + }, + ], + }); + + // Sample static resource data + // eslint-disable-next-line react-hooks/exhaustive-deps + const resources = [ + { + id: "res-1", + name: "api-server-pod-1", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "nginx:1.21", + lastUpdated: new Date("2024-03-15T10:30:00"), + component: "API Server", + healthScore: 98, + metrics: { + cpu: 32, + memory: 45, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T10:30:00"), + message: "Pod started successfully", + }, + { + type: "normal", + timestamp: new Date("2024-03-15T10:28:00"), + message: "Container image pulled successfully", + }, + ], + relatedResources: [ + { name: "api-server-service", kind: "Service", status: "healthy" }, + { + name: "api-data-volume", + kind: "PersistentVolume", + status: "healthy", + }, + ], + deploymentHistory: [ + { + date: new Date("2024-03-15"), + version: "v1.21", + deploymentName: "API Server Rollout", + duration: 3, + status: "success", + }, + { + date: new Date("2024-02-28"), + version: "v1.20", + deploymentName: "February Release", + duration: 5, + status: "success", + }, + ], + }, + { + id: "res-2", + name: "frontend-service", + kind: "Service", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "ClusterIP", + lastUpdated: new Date("2024-03-15T09:45:00"), + component: "Frontend", + healthScore: 100, + metrics: { + cpu: 12, + memory: 25, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T09:45:00"), + message: "Service created", + }, + ], + }, + { + id: "res-3", + name: "main-db-instance", + kind: "Database", + provider: "aws", + region: "us-west-2", + status: "degraded", + version: "postgres-13.4", + lastUpdated: new Date("2024-03-14T22:15:00"), + component: "Database", + healthScore: 75, + metrics: { + cpu: 78, + memory: 65, + disk: 82, + }, + events: [ + { + type: "warning", + timestamp: new Date("2024-03-15T02:12:00"), + message: "High disk usage detected", + }, + { + type: "normal", + timestamp: new Date("2024-03-14T22:15:00"), + message: "Database backup completed", + }, + ], + relatedResources: [ + { name: "db-backup-bucket", kind: "Storage", status: "healthy" }, + ], + }, + { + id: "res-4", + name: "cache-redis-01", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "failed", + version: "redis:6.2", + lastUpdated: new Date("2024-03-15T08:12:00"), + component: "Cache", + healthScore: 0, + metrics: { + cpu: 0, + memory: 0, + }, + events: [ + { + type: "error", + timestamp: new Date("2024-03-15T08:12:00"), + message: "Container failed to start: OOMKilled", + }, + { + type: "warning", + timestamp: new Date("2024-03-15T08:11:30"), + message: "Memory usage exceeded limit", + }, + ], + }, + { + id: "res-5", + name: "monitoring-server", + kind: "VM", + provider: "gcp", + region: "us-west-1", + status: "healthy", + version: "n/a", + lastUpdated: new Date("2024-03-10T15:30:00"), + component: "Monitoring", + healthScore: 96, + metrics: { + cpu: 15, + memory: 40, + disk: 30, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-10T15:30:00"), + message: "VM started successfully", + }, + ], + }, + { + id: "res-6", + name: "backend-pod-1", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "backend:4.1.0", + lastUpdated: new Date("2024-03-10T11:45:00"), + component: "Backend", + healthScore: 99, + metrics: { + cpu: 45, + memory: 38, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-10T11:45:00"), + message: "Pod started successfully", + }, + ], + }, + { + id: "res-7", + name: "backend-pod-2", + kind: "Pod", + provider: "kubernetes", + region: "us-west-2", + status: "healthy", + version: "backend:4.1.0", + lastUpdated: new Date("2024-03-10T11:45:00"), + component: "Backend", + healthScore: 97, + metrics: { + cpu: 49, + memory: 42, + }, + }, + { + id: "res-8", + name: "analytics-queue", + kind: "Service", + provider: "aws", + region: "us-west-2", + status: "updating", + version: "n/a", + lastUpdated: new Date("2024-03-15T14:22:00"), + component: "Analytics", + healthScore: 90, + metrics: { + cpu: 28, + memory: 35, + }, + events: [ + { + type: "normal", + timestamp: new Date("2024-03-15T14:22:00"), + message: "Service configuration update in progress", + }, + ], + }, + ]; + + // Group resources by component + const resourcesByComponent = _(resources) + .groupBy((t) => t.component) + .value() as Record; + + // Apply filters to resources + const filteredResources = React.useMemo(() => { + // Start with all resources + let filtered = [...resources]; + + // Apply resource condition filters + if (resourceFilter.conditions.length > 0) { + // If it's an AND operator, each condition must match + if (resourceFilter.operator === "and") { + resourceFilter.conditions.forEach((condition: any) => { + filtered = filtered.filter((resource) => { + switch (condition.type) { + case "kind": + return resource.kind === condition.value; + case "provider": + return resource.provider === condition.value; + case "status": + return resource.status === condition.value; + case "component": + return resource.component === condition.value; + default: + return true; + } + }); + }); + } + // If it's an OR operator, any condition can match + else if (resourceFilter.operator === "or") { + filtered = filtered.filter((resource) => + resourceFilter.conditions.some((condition: any) => { + switch (condition.type) { + case "kind": + return resource.kind === condition.value; + case "provider": + return resource.provider === condition.value; + case "status": + return resource.status === condition.value; + case "component": + return resource.component === condition.value; + default: + return true; + } + }), + ); + } + } + + return filtered; + }, [resources, resourceFilter]); + + const getStatusCount = (status: string) => { + return resources.filter((r) => r.status === status).length; + }; + + const renderResourceCard = (resource: any) => { + const statusColor = { + healthy: "bg-green-500", + degraded: "bg-amber-500", + failed: "bg-red-500", + updating: "bg-blue-500", + unknown: "bg-neutral-500", + }; + + return ( +
+
+
+
+

{resource.name}

+
+ + {resource.kind} + +
+ +
+
Component
+
{resource.component}
+ +
Provider
+
{resource.provider}
+ +
Region
+
{resource.region}
+ +
Updated
+
+ {resource.lastUpdated.toLocaleDateString()} +
+
+ +
+
+ Provider + {resource.provider} +
+ +
+ Deployment Success + 90 ? "green" : resource.healthScore > 70 ? "amber" : "red"}-400`} + > + {resource.healthScore}% + +
+ +
+
+ ID: {resource.id} +
+
+
+
+ ); + }; + + return ( +
+ {/* Resource Summary Cards */} +
+
+
Total Resources
+
+ {resources.length} +
+
+ + Across {Object.keys(resourcesByComponent).length} components + +
+
+ +
+
+
+ Healthy +
+
+ {getStatusCount("healthy")} +
+
+ + {Math.round((getStatusCount("healthy") / resources.length) * 100)} + % of resources + +
+
+ +
+
+
+ Needs Attention +
+
+ {getStatusCount("degraded")} +
+
+ + {getStatusCount("degraded") > 0 + ? "Action required" + : "No issues detected"} + +
+
+ +
+
+
+ Deploying +
+
+ {getStatusCount("updating") + getStatusCount("failed")} +
+
+ + {getStatusCount("updating") > 0 + ? "Updates in progress" + : "No active deployments"} + +
+
+
+ + {/* Search and Filters */} +
+
+ + +
+
+ setShowFilterEditor(true)} + > + + {resourceFilter.conditions.length > 0 + ? `Filter (${resourceFilter.conditions.length})` + : "Filter"} + + + + +
+ + +
+
+
+ + {/* Resource Content */} + {selectedView === "grid" ? ( +
+ {filteredResources.map((resource) => renderResourceCard(resource))} +
+ ) : ( +
+ + + + + Name + + + Kind + + + Component + + + Provider + + + Region + + + Success Rate + + + Last Updated + + + Status + + + + + {filteredResources.map((resource) => ( + + + {resource.name} + + + {resource.kind} + + + {resource.component} + + + {resource.provider} + + + {resource.region} + + +
+
+
90 + ? "bg-green-500" + : resource.healthScore > 70 + ? "bg-amber-500" + : resource.healthScore > 0 + ? "bg-red-500" + : "bg-neutral-600" + }`} + style={{ width: `${resource.healthScore}%` }} + /> +
+ {resource.healthScore}% +
+ + + {resource.lastUpdated.toLocaleString()} + + + + {resource.status.charAt(0).toUpperCase() + + resource.status.slice(1)} + + + + ))} + +
+
+ )} + +
+
+ {filteredResources.length === resources.length ? ( + <>Showing all {resources.length} resources + ) : ( + <> + Showing {filteredResources.length} of {resources.length} resources + + )} + {resourceFilter.conditions.length > 0 && ( + <> + {" "} + • Filtered + + )} +
+
+ + +
+
+ + {/* Resource Filter Editor Modal */} + {showFilterEditor && ( +
+
+
+

+ Edit Resource Filter +

+ +
+ +
+ {/* Current Conditions */} +
+

+ Current Filter Conditions +

+ + {resourceFilter.conditions.length > 0 ? ( +
+ {resourceFilter.conditions.map( + (condition: any, index: number) => ( +
+
+ + {condition.type.charAt(0).toUpperCase() + + condition.type.slice(1)} + + + equals + + + {condition.value} + +
+ +
+ ), + )} +
+ ) : ( +
+ No filter conditions set. Resources will not be filtered. +
+ )} +
+ + {/* Add New Condition */} +
+

Add New Condition

+ +
+ {/* Condition Type */} +
+ + +
+ + {/* Operator - Static for now */} +
+ + +
+ + {/* Condition Value */} +
+ + +
+ + {/* Add Button */} +
+ + +
+
+
+ + {/* Description of the filter effect */} +
+

Filter Effect

+

+ This filter will{" "} + {resourceFilter.conditions.length > 0 + ? "show only" + : "show all"}{" "} + resources + {resourceFilter.conditions.length > 0 && " that match"} + {resourceFilter.conditions.length > 1 && + resourceFilter.operator === "and" && + " all"} + {resourceFilter.conditions.length > 1 && + resourceFilter.operator === "or" && + " any"} + {resourceFilter.conditions.length > 0 && + " of these conditions."} +

+ {resourceFilter.conditions.length > 0 && ( +
+ + Currently filtering:{" "} + + {resourceFilter.conditions.map((c: any, i: number) => ( + + {i > 0 && ( + + {" "} + {resourceFilter.operator}{" "} + + )} + {c.type} {c.operator} "{c.value}" + + ))} +
+ )} +
+ + {/* Save and Cancel Buttons */} +
+ + +
+
+
+
+ )} +
+ ); +}; 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..3e2ac840f 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,8 @@ -import { notFound } from "next/navigation"; - -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 }>; + params: Promise<{ 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 ( -
- -
- ); + const { environmentId } = await props.params; + return ; } diff --git a/apps/webservice/src/app/urls.ts b/apps/webservice/src/app/urls.ts index ffae52307..2725befb5 100644 --- a/apps/webservice/src/app/urls.ts +++ b/apps/webservice/src/app/urls.ts @@ -141,6 +141,7 @@ const environment = (params: EnvironmentParams) => { resources: () => buildUrl(...base, "resources"), variables: () => buildUrl(...base, "variables"), settings: () => buildUrl(...base, "settings"), + overview: () => buildUrl(...base, "overview"), }; }; type DeploymentParams = SystemParams & { From 8cc3d1a115be5cf1415ebc2bdadddbfb4c730e2e Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 18 Mar 2025 14:20:56 -0700 Subject: [PATCH 06/18] cardify --- .../[environmentId]/deployments/page.tsx | 20 +- .../[environmentId]/overview/page.tsx | 378 +++++++++--------- .../[environmentId]/resources/page.tsx | 20 +- 3 files changed, 216 insertions(+), 202 deletions(-) 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 5fb3781de..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,8 +1,26 @@ +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 ; + return ( + + + Deployments + View detailed deployment information + + + + + + ); } 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 index f0484ff23..9a7dea6c0 100644 --- 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 @@ -7,6 +7,14 @@ import { CardHeader, CardTitle, } from "@ctrlplane/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; export default function EnvironmentOverviewPage(_props: { params: Promise<{ @@ -66,7 +74,7 @@ export default function EnvironmentOverviewPage(_props: { ); return ( -
+
{/* Environment Overview Card */} @@ -226,225 +234,195 @@ export default function EnvironmentOverviewPage(_props: {

Deployment Versions

-
- - - - -
- Component - +
+ + + + + Deployments + + Current Distribution - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ + Desired Version - + + Deployment Status -
- Database{" "} - - (9) - - -
-
-
-
-
-
v3.4.1
-
v3.3.0
-
-
v3.4.1 - - - Deployed + + + + + + +
+
+ + Database - - -
- API Server{" "} - - (12) - - -
-
-
+ (9)
-
-
v2.8.5
-
v2.7.0
+ + +
+
+
+
+
+
+
v3.4.1
+
v3.3.0
+
-
v3.0.0 - - - Pending Approval - - + + + + v3.4.1 -
- Backend{" "} - - (7) + + + +
+ Deployed
-
-
-
-
-
-
v4.1.0
-
-
v4.1.0 - - - Deployed + + + + + +
+
+ + API Server - - -
- Frontend{" "} - - (5) - - -
-
-
+ (12)
-
-
v2.0.0
-
v2.1.0-β
+ + +
+
+
+
+
+
+
v2.8.5
+
v2.7.0
+
-
v2.1.0 - - - Deploying - - + + + + v3.0.0 -
- Cache{" "} - - (4) + + + +
+ Pending Approval
-
-
-
-
-
+ + + + + +
+
+ + Frontend + + (5)
-
-
v1.9.2
-
v2.0.0
-
v1.8.0
+ + +
+
+
+
+
+
+
v2.0.0
+
v2.1.0-β
+
-
v2.0.0 - - - Failed - - + + + + v2.1.0 -
- Monitoring{" "} - - (5) + + + +
+ Deploying
-
-
-
-
+ + + + + +
+
+ + Cache + + (4)
-
-
v3.0.1
-
v2.9.5
+ + +
+
+
+
+
+
+
+
v1.9.2
+
v2.0.0
+
v1.8.0
+
-
v3.0.1 - - - Deployed - - + + + + v2.0.0 + + + + +
+ Failed
-
+ + + +
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 3e2ac840f..53e40a179 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,8 +1,26 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + import { ResourcesPageContent } from "./ResourcesPageContent"; export default async function ResourcesPage(props: { params: Promise<{ environmentId: string }>; }) { const { environmentId } = await props.params; - return ; + return ( + + + Resources + Resources managed in this environment + + + + + + ); } From 0d0b57ce45497b6943c2ee4ef84bc04e5f663864 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 18 Mar 2025 14:32:59 -0700 Subject: [PATCH 07/18] more org --- .../deployments/EnvironmentDeploymentsPageContent.tsx | 2 +- .../deployments/{ => _components}/DeploymentDetail.tsx | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/{ => _components}/DeploymentDetail.tsx (100%) 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 index caad3045e..78a1069fc 100644 --- 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 @@ -14,7 +14,7 @@ import { TableRow, } from "@ctrlplane/ui/table"; -import { DeploymentDetail } from "./DeploymentDetail"; +import { DeploymentDetail } from "./_components/DeploymentDetail"; // Helper function for rendering status badges const StatusBadge: React.FC<{ status: string }> = ({ status }) => { diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/DeploymentDetail.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/_components/DeploymentDetail.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/DeploymentDetail.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/_components/DeploymentDetail.tsx From 9b8b9388df79ee57158d9b092ed8ff286090f62e Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Tue, 18 Mar 2025 22:23:35 -0700 Subject: [PATCH 08/18] fix: Init env deploymen stats query on overview page (#400) Co-authored-by: Justin Brooks --- .../_components/EnvironmentTabs.tsx | 2 + .../overview/CopyEnvIdButton.tsx | 33 +++ .../[environmentId]/overview/page.tsx | 185 ++++++----------- .../environment-page/environment-page.ts | 6 + .../src/router/environment-page/overview.ts | 191 ++++++++++++++++++ packages/api/src/router/environment.ts | 2 + 6 files changed, 291 insertions(+), 128 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/CopyEnvIdButton.tsx create mode 100644 packages/api/src/router/environment-page/environment-page.ts create mode 100644 packages/api/src/router/environment-page/overview.ts 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 index 0936d61d2..029729cec 100644 --- 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 @@ -19,6 +19,7 @@ export const EnvironmentTabs: React.FC = () => { .workspace(workspaceSlug) .system(systemSlug) .environment(environmentId); + const baseUrl = environmentUrls.baseUrl(); const overviewUrl = environmentUrls.overview(); const deploymentsUrl = environmentUrls.deployments(); const resourcesUrl = environmentUrls.resources(); @@ -29,6 +30,7 @@ export const EnvironmentTabs: React.FC = () => { if (pathname === policiesUrl) return "policies"; if (pathname === resourcesUrl) return "resources"; if (pathname === deploymentsUrl) return "deployments"; + if (pathname === baseUrl) return "overview"; return "overview"; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/CopyEnvIdButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/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/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/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/page.tsx index 9a7dea6c0..3585823b3 100644 --- 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 @@ -1,4 +1,5 @@ import React from "react"; +import { notFound } from "next/navigation"; import { Card, @@ -16,105 +17,86 @@ import { TableRow, } from "@ctrlplane/ui/table"; -export default function EnvironmentOverviewPage(_props: { +import { api } from "~/trpc/server"; +import { CopyEnvIdButton } from "./CopyEnvIdButton"; + +export default async function EnvironmentOverviewPage(props: { params: Promise<{ workspaceSlug: string; systemSlug: string; environmentId: string; }>; }) { - const environmentData = { - id: "env-123", - name: "Production", - directory: "prod", - description: "Production environment for customer-facing applications", - createdAt: new Date("2024-01-15"), - metadata: [ - { key: "region", value: "us-west-2" }, - { key: "cluster", value: "main-cluster" }, - { key: "tier", value: "premium" }, - ], - policy: { - id: "pol-123", - name: "Production Policy", - approvalRequirement: "manual", - successType: "all", - successMinimum: 1, - concurrencyLimit: 2, - rolloutDuration: 1800000, // 30 minutes - minimumReleaseInterval: 3600000, // 1 hour - releaseWindows: [ - { - recurrence: "daily", - startTime: new Date("2023-01-01T10:00:00"), - endTime: new Date("2023-01-01T16:00:00"), - }, - { - recurrence: "weekly", - startTime: new Date("2023-01-01T09:00:00"), - endTime: new Date("2023-01-01T17:00:00"), - }, - ], - }, - }; + const { environmentId } = await props.params; + const environment = await api.environment.byId(environmentId); + if (environment == null) return notFound(); - const stats = { - deployments: { - total: 156, - successful: 124, - failed: 18, - inProgress: 10, - pending: 4, - }, - resources: 42, - }; + const stats = + await api.environment.page.overview.latestDeploymentStats(environmentId); - const deploymentSuccess = Math.round( - (stats.deployments.successful / stats.deployments.total) * 100, - ); + const deploymentSuccess = ( + (stats.deployments.successful / (stats.deployments.total || 1)) * + 100 + ).toFixed(1); return (
{/* Environment Overview Card */} - - + + Environment Details - -
+ +
+
Environment ID
+
+ + {environment.id.substring(0, 8)}... + + +
+
Name
-
{environmentData.name}
+
+ {environment.name} +
Directory
-
- {environmentData.directory} -
+ + {environment.directory === "" ? "/" : environment.directory} +
Created
-
- {environmentData.createdAt.toLocaleDateString()} +
+ {environment.createdAt.toLocaleDateString()}
- {environmentData.metadata.length > 0 && ( - <> -
-
-

- Metadata -

-
- {environmentData.metadata.map((meta, i) => ( - -
{meta.key}
-
{meta.value}
-
- ))} +
+ {Object.keys(environment.metadata).length > 0 ? ( + <> +
+
+

+ Metadata +

+
+ {Object.entries(environment.metadata).map( + ([key, value]) => ( + + {key} + {value} + + ), + )} +
-
- - )} + + ) : ( +
No metadata
+ )} +
@@ -429,58 +411,5 @@ export default function EnvironmentOverviewPage(_props: {
- - //
- //
- //

- // {environmentData.name} Environment - //

- //

{environmentData.description}

- //
- - // - // - // Overview - // Deployments - // Resources - // Policies - // - - // - - // - - // - // - // - // Deployments - // - // View detailed deployment information - // - // - // - // - // - // - // - - // - // - // - // Resources - // - // Resources managed in this environment - // - // - // - // - // - // - // - - // - // - // - // ); } diff --git a/packages/api/src/router/environment-page/environment-page.ts b/packages/api/src/router/environment-page/environment-page.ts new file mode 100644 index 000000000..6ece612bc --- /dev/null +++ b/packages/api/src/router/environment-page/environment-page.ts @@ -0,0 +1,6 @@ +import { createTRPCRouter } from "../../trpc"; +import { overviewRouter } from "./overview"; + +export const environmentPageRouter = createTRPCRouter({ + overview: overviewRouter, +}); diff --git a/packages/api/src/router/environment-page/overview.ts b/packages/api/src/router/environment-page/overview.ts new file mode 100644 index 000000000..b8ced7e41 --- /dev/null +++ b/packages/api/src/router/environment-page/overview.ts @@ -0,0 +1,191 @@ +import type { Tx } from "@ctrlplane/db"; +import type { JobStatusType } from "@ctrlplane/validators/jobs"; +import _ from "lodash"; +import { z } from "zod"; + +import { + and, + count, + desc, + eq, + inArray, + isNull, + sql, + takeFirst, +} from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import { createTRPCRouter, protectedProcedure } from "../../trpc"; + +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, +]; + +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, + }; +}; + +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, + }; + }), +}); diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index edec45bbd..905f2460a 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/environment-page"; import { policyRouter } from "./environment-policy"; import { environmentStatsRouter } from "./environment-stats"; export const environmentRouter = createTRPCRouter({ policy: policyRouter, stats: environmentStatsRouter, + page: environmentPageRouter, byId: protectedProcedure .meta({ From 5b1ade088e7f71d77d4f838f6764ff9dad084b23 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Wed, 19 Mar 2025 13:04:12 -0700 Subject: [PATCH 09/18] fix: Init telemetry query (#401) --- .../{ => _components}/CopyEnvIdButton.tsx | 0 .../_components/DeploymentTelemetryTable.tsx | 150 +++++++++++++ .../[environmentId]/overview/page.tsx | 209 +----------------- .../environment-page/environment-page.ts | 2 +- .../get-deployment-stats.ts} | 87 +------- .../overview/get-version-distro.ts | 59 +++++ .../environment-page/overview/router.ts | 152 +++++++++++++ 7 files changed, 369 insertions(+), 290 deletions(-) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/{ => _components}/CopyEnvIdButton.tsx (100%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/DeploymentTelemetryTable.tsx rename packages/api/src/router/environment-page/{overview.ts => overview/get-deployment-stats.ts} (58%) create mode 100644 packages/api/src/router/environment-page/overview/get-version-distro.ts create mode 100644 packages/api/src/router/environment-page/overview/router.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/CopyEnvIdButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/CopyEnvIdButton.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/CopyEnvIdButton.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/CopyEnvIdButton.tsx 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..4a07966a8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/overview/_components/DeploymentTelemetryTable.tsx @@ -0,0 +1,150 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React from "react"; +import { useParams } from "next/navigation"; + +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +import { api } from "~/trpc/react"; + +const colors = [ + "bg-green-500", + "bg-blue-500", + "bg-red-500", + "bg-purple-500", + "bg-amber-500", +]; + +const DistroBar: React.FC<{ + versionDistro: Record; + isLoading: boolean; +}> = ({ versionDistro, isLoading }) => { + if (isLoading) return ; + + if (Object.entries(versionDistro).length === 0) + return ( +
+ ); + + return ( +
+ {Object.values(versionDistro).map(({ percentage }, index) => ( +
+ ))} +
+ ); +}; + +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 ?? {}; + + return ( + + +
+
+ {deployment.name} + + ({isLoading ? "-" : resourceCount}) + +
+
+ +
+ +
+ {Object.values(versionDistro).length === 0 && ( + + {isLoading + ? "Loading distribution..." + : "No resources deployed"} + + )} + {Object.entries(versionDistro).map(([version, { percentage }]) => { + return ( +
+ {version} +
+ ); + })} +
+
+
+ + + v3.4.1 + + + + +
+ Deployed +
+
+
+ ); +}; + +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 index 3585823b3..612dc91c0 100644 --- 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 @@ -8,17 +8,10 @@ import { CardHeader, CardTitle, } from "@ctrlplane/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ctrlplane/ui/table"; import { api } from "~/trpc/server"; -import { CopyEnvIdButton } from "./CopyEnvIdButton"; +import { CopyEnvIdButton } from "./_components/CopyEnvIdButton"; +import { DeploymentTelemetryTable } from "./_components/DeploymentTelemetryTable"; export default async function EnvironmentOverviewPage(props: { params: Promise<{ @@ -39,6 +32,8 @@ export default async function EnvironmentOverviewPage(props: { 100 ).toFixed(1); + const deployments = await api.deployment.bySystemId(environment.systemId); + return (
@@ -212,201 +207,7 @@ export default async function EnvironmentOverviewPage(props: {
-
-

- Deployment Versions -

-
- - - - - Deployments - - - Current Distribution - - - Desired Version - - - Deployment Status - - - - - - -
-
- - Database - - (9) -
-
- -
-
-
-
-
-
-
v3.4.1
-
v3.3.0
-
-
-
- - - v3.4.1 - - - - -
- Deployed -
-
-
- - - -
-
- - API Server - - (12) -
-
- -
-
-
-
-
-
-
v2.8.5
-
v2.7.0
-
-
-
- - - v3.0.0 - - - - -
- Pending Approval -
-
-
- - - -
-
- - Frontend - - (5) -
-
- -
-
-
-
-
-
-
v2.0.0
-
v2.1.0-β
-
-
-
- - - v2.1.0 - - - - -
- Deploying -
-
-
- - - -
-
- - Cache - - (4) -
-
- -
-
-
-
-
-
-
-
v1.9.2
-
v2.0.0
-
v1.8.0
-
-
-
- - - v2.0.0 - - - - -
- Failed -
-
-
-
-
-
-
+
diff --git a/packages/api/src/router/environment-page/environment-page.ts b/packages/api/src/router/environment-page/environment-page.ts index 6ece612bc..a0f466250 100644 --- a/packages/api/src/router/environment-page/environment-page.ts +++ b/packages/api/src/router/environment-page/environment-page.ts @@ -1,5 +1,5 @@ import { createTRPCRouter } from "../../trpc"; -import { overviewRouter } from "./overview"; +import { overviewRouter } from "./overview/router"; export const environmentPageRouter = createTRPCRouter({ overview: overviewRouter, diff --git a/packages/api/src/router/environment-page/overview.ts b/packages/api/src/router/environment-page/overview/get-deployment-stats.ts similarity index 58% rename from packages/api/src/router/environment-page/overview.ts rename to packages/api/src/router/environment-page/overview/get-deployment-stats.ts index b8ced7e41..ec5d5a089 100644 --- a/packages/api/src/router/environment-page/overview.ts +++ b/packages/api/src/router/environment-page/overview/get-deployment-stats.ts @@ -1,24 +1,10 @@ import type { Tx } from "@ctrlplane/db"; import type { JobStatusType } from "@ctrlplane/validators/jobs"; -import _ from "lodash"; -import { z } from "zod"; -import { - and, - count, - desc, - eq, - inArray, - isNull, - sql, - takeFirst, -} from "@ctrlplane/db"; +import { and, count, desc, eq, inArray, sql, takeFirst } from "@ctrlplane/db"; import * as SCHEMA from "@ctrlplane/db/schema"; -import { Permission } from "@ctrlplane/validators/auth"; import { JobStatus } from "@ctrlplane/validators/jobs"; -import { createTRPCRouter, protectedProcedure } from "../../trpc"; - const failureStatuses: JobStatusType[] = [ JobStatus.Failure, JobStatus.InvalidIntegration, @@ -38,7 +24,7 @@ const deployedStatuses = [ JobStatus.InProgress, ]; -const getDeploymentStats = async ( +export const getDeploymentStats = async ( db: Tx, environment: SCHEMA.Environment, deployment: SCHEMA.Deployment, @@ -120,72 +106,3 @@ const getDeploymentStats = async ( notDeployed, }; }; - -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, - }; - }), -}); diff --git a/packages/api/src/router/environment-page/overview/get-version-distro.ts b/packages/api/src/router/environment-page/overview/get-version-distro.ts new file mode 100644 index 000000000..53b72e2ed --- /dev/null +++ b/packages/api/src/router/environment-page/overview/get-version-distro.ts @@ -0,0 +1,59 @@ +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"; + +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/overview/router.ts b/packages/api/src/router/environment-page/overview/router.ts new file mode 100644 index 000000000..231df0805 --- /dev/null +++ b/packages/api/src/router/environment-page/overview/router.ts @@ -0,0 +1,152 @@ +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 "./get-deployment-stats"; +import { getVersionDistro } from "./get-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 versionDistro = await getVersionDistro( + ctx.db, + environment, + deployment, + resourceIds, + ); + + return { resourceCount: resourceIds.length, versionDistro }; + }), + }), +}); From 7631712fcbf112dc6a8d1a9a7e9c7b4421f1953d Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 19 Mar 2025 13:29:11 -0700 Subject: [PATCH 10/18] fix: Cleanup and comments --- .../{get-deployment-stats.ts => deployment-stats.ts} | 9 +++++++++ .../api/src/router/environment-page/overview/router.ts | 4 ++-- .../{get-version-distro.ts => version-distro.ts} | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) rename packages/api/src/router/environment-page/overview/{get-deployment-stats.ts => deployment-stats.ts} (87%) rename packages/api/src/router/environment-page/overview/{get-version-distro.ts => version-distro.ts} (82%) diff --git a/packages/api/src/router/environment-page/overview/get-deployment-stats.ts b/packages/api/src/router/environment-page/overview/deployment-stats.ts similarity index 87% rename from packages/api/src/router/environment-page/overview/get-deployment-stats.ts rename to packages/api/src/router/environment-page/overview/deployment-stats.ts index ec5d5a089..beb3eccdd 100644 --- a/packages/api/src/router/environment-page/overview/get-deployment-stats.ts +++ b/packages/api/src/router/environment-page/overview/deployment-stats.ts @@ -24,6 +24,15 @@ const deployedStatuses = [ 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, diff --git a/packages/api/src/router/environment-page/overview/router.ts b/packages/api/src/router/environment-page/overview/router.ts index 231df0805..4ebb5363a 100644 --- a/packages/api/src/router/environment-page/overview/router.ts +++ b/packages/api/src/router/environment-page/overview/router.ts @@ -12,8 +12,8 @@ import { } from "@ctrlplane/validators/conditions"; import { createTRPCRouter, protectedProcedure } from "../../../trpc"; -import { getDeploymentStats } from "./get-deployment-stats"; -import { getVersionDistro } from "./get-version-distro"; +import { getDeploymentStats } from "./deployment-stats"; +import { getVersionDistro } from "./version-distro"; export const overviewRouter = createTRPCRouter({ latestDeploymentStats: protectedProcedure diff --git a/packages/api/src/router/environment-page/overview/get-version-distro.ts b/packages/api/src/router/environment-page/overview/version-distro.ts similarity index 82% rename from packages/api/src/router/environment-page/overview/get-version-distro.ts rename to packages/api/src/router/environment-page/overview/version-distro.ts index 53b72e2ed..ffcdc7419 100644 --- a/packages/api/src/router/environment-page/overview/get-version-distro.ts +++ b/packages/api/src/router/environment-page/overview/version-distro.ts @@ -5,6 +5,14 @@ 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, From 6ff784e8d2d828196228a011242c05bf1d4b2c8c Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 19 Mar 2025 13:30:21 -0700 Subject: [PATCH 11/18] nit --- .../router/environment-page/{environment-page.ts => router.ts} | 0 packages/api/src/router/environment.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/api/src/router/environment-page/{environment-page.ts => router.ts} (100%) diff --git a/packages/api/src/router/environment-page/environment-page.ts b/packages/api/src/router/environment-page/router.ts similarity index 100% rename from packages/api/src/router/environment-page/environment-page.ts rename to packages/api/src/router/environment-page/router.ts diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index 905f2460a..dd1f5d3f4 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -39,7 +39,7 @@ import { } from "@ctrlplane/validators/conditions"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { environmentPageRouter } from "./environment-page/environment-page"; +import { environmentPageRouter } from "./environment-page/router"; import { policyRouter } from "./environment-policy"; import { environmentStatsRouter } from "./environment-stats"; From aed94c0e8de22f344291dd30ce80d3ff30be4d4f Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Wed, 19 Mar 2025 17:13:57 -0700 Subject: [PATCH 12/18] fix: Populate environment policy page (#403) --- .../policies/PoliciesPageContent.tsx | 93 ++++++++++++------- .../[environmentId]/policies/page.tsx | 7 +- .../environment/drawer/EnvironmentDrawer.tsx | 4 +- .../policy/drawer/EnvironmentPolicyDrawer.tsx | 8 +- apps/webservice/src/app/urls.ts | 1 + packages/api/src/router/environment.ts | 2 +- 6 files changed, 76 insertions(+), 39 deletions(-) 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 index 9dbf4cc46..41cad1657 100644 --- 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 @@ -1,6 +1,8 @@ "use client"; +import type { RouterOutputs } from "@ctrlplane/api"; import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; import { IconAdjustments, IconArrowUpRight, @@ -30,43 +32,42 @@ import { TooltipTrigger, } from "@ctrlplane/ui/tooltip"; -// PoliciesTabContent component for the Policies tab -export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ - environmentId, +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 hasParentPolicy = true; - // Sample static policy data - const environmentPolicy = { - id: "env-pol-1", - name: "Production Environment Policy", - description: "Policy settings for the production environment", - environmentId: environmentId, - approvalRequirement: "manual", - successType: "all", - successMinimum: 0, - concurrencyLimit: 2, - rolloutDuration: 1800000, // 30 minutes in ms - minimumReleaseInterval: 86400000, // 24 hours in ms - releaseSequencing: "wait", - versionChannels: [ - { id: "channel-1", name: "stable", deploymentId: "deploy-1" }, - { id: "channel-2", name: "beta", deploymentId: "deploy-2" }, - ], - releaseWindows: [ - { - id: "window-1", - recurrence: "weekly", - startTime: new Date("2025-03-18T09:00:00"), - endTime: new Date("2025-03-18T17:00:00"), - }, - ], - }; + 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 (
@@ -88,7 +89,7 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ 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. @@ -98,9 +99,14 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({

- @@ -244,6 +253,11 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ variant="ghost" size="sm" className="flex items-center gap-1.5 text-xs text-neutral-400 hover:text-neutral-200" + onClick={() => + onConfigurePolicyClick( + EnvironmentPolicyDrawerTab.Concurrency, + ) + } > Configure @@ -306,6 +320,11 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ variant="ghost" size="sm" className="flex items-center gap-1.5 text-xs text-neutral-400 hover:text-neutral-200" + onClick={() => + onConfigurePolicyClick( + EnvironmentPolicyDrawerTab.Management, + ) + } > Configure @@ -401,6 +420,11 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ variant="ghost" size="sm" className="flex items-center gap-1.5 text-xs text-neutral-400 hover:text-neutral-200" + onClick={() => + onConfigurePolicyClick( + EnvironmentPolicyDrawerTab.DeploymentVersionChannels, + ) + } > Configure @@ -482,6 +506,9 @@ export const PoliciesPageContent: React.FC<{ environmentId: string }> = ({ variant="ghost" size="sm" className="flex items-center gap-1.5 text-xs text-neutral-400 hover:text-neutral-200" + onClick={() => + onConfigurePolicyClick(EnvironmentPolicyDrawerTab.Rollout) + } > Configure 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 49abf32d5..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,8 +1,13 @@ +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; import { PoliciesPageContent } from "./PoliciesPageContent"; export default async function PoliciesPage(props: { params: Promise<{ environmentId: string }>; }) { const { environmentId } = await props.params; - return ; + const environment = await api.environment.byId(environmentId); + if (environment == null) return notFound(); + return ; } 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/urls.ts b/apps/webservice/src/app/urls.ts index 2725befb5..b2f5271b7 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), }; }; diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index dd1f5d3f4..a1f9c1416 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -127,7 +127,7 @@ export const environmentRouter = createTRPCRouter({ .filter(isPresent) .uniqBy((r) => r.id) .value(), - isOverride: + isDefaultPolicy: env.environment_policy.environmentId === env.environment.id, }; From 869bd9b1915ebec4e432d5f8c724627041234793 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Wed, 19 Mar 2025 17:27:09 -0700 Subject: [PATCH 13/18] fix: Add desired version telemetry query (#402) --- .../_components/DeploymentTelemetryTable.tsx | 36 +++++- .../overview/desired-version.ts | 104 ++++++++++++++++++ .../environment-page/overview/router.ts | 21 +++- 3 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/router/environment-page/overview/desired-version.ts 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 index 4a07966a8..a6cbd2fd3 100644 --- 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 @@ -4,6 +4,7 @@ import type * as SCHEMA from "@ctrlplane/db/schema"; import React from "react"; import { useParams } from "next/navigation"; +import { cn } from "@ctrlplane/ui"; import { Skeleton } from "@ctrlplane/ui/skeleton"; import { Table, @@ -60,6 +61,8 @@ const DeploymentRow: React.FC<{ const resourceCount = telemetry?.resourceCount ?? 0; const versionDistro = telemetry?.versionDistro ?? {}; + const desiredVersion = telemetry?.desiredVersion ?? null; + const tag = desiredVersion?.tag ?? "No version released"; return ( @@ -99,14 +102,37 @@ const DeploymentRow: React.FC<{ - v3.4.1 + {tag} - -
- Deployed -
+ {desiredVersion != null && ( + +
+ {desiredVersion.status} + + )} ); 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 index 4ebb5363a..e7d2f2e7c 100644 --- a/packages/api/src/router/environment-page/overview/router.ts +++ b/packages/api/src/router/environment-page/overview/router.ts @@ -13,6 +13,7 @@ import { import { createTRPCRouter, protectedProcedure } from "../../../trpc"; import { getDeploymentStats } from "./deployment-stats"; +import { getDesiredVersion } from "./desired-version"; import { getVersionDistro } from "./version-distro"; export const overviewRouter = createTRPCRouter({ @@ -139,14 +140,30 @@ export const overviewRouter = createTRPCRouter({ ) .then((rs) => rs.map((r) => r.id)); - const versionDistro = await getVersionDistro( + const versionDistroPromise = getVersionDistro( ctx.db, environment, deployment, resourceIds, ); - return { resourceCount: resourceIds.length, versionDistro }; + const desiredVersionPromise = getDesiredVersion( + ctx.db, + environment, + deployment, + resourceIds, + ); + + const [versionDistro, desiredVersion] = await Promise.all([ + versionDistroPromise, + desiredVersionPromise, + ]); + + return { + resourceCount: resourceIds.length, + versionDistro, + desiredVersion, + }; }), }), }); From 2321e5cedb4da6845b1c9be1a99f423a1cf2ab6e Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Thu, 20 Mar 2025 23:54:16 -0400 Subject: [PATCH 14/18] cleanup border style --- .../policies/PoliciesPageContent.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 index 41cad1657..941341fbd 100644 --- 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 @@ -118,8 +118,8 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({ )}
{/* Approval & Governance */} -
-
+
+

@@ -200,7 +200,7 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({

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

@@ -248,7 +248,7 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({

-
+
{/* Release Management */} -
-
+
+

@@ -315,7 +315,7 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({

-
+
{/* Deployment Version Channels */} -
-
+
+

@@ -415,7 +415,7 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({ )}

-
+
{/* Rollout & Timing */} -
-
+
+

@@ -501,7 +501,7 @@ export const PoliciesPageContent: React.FC<{ environment: Environment }> = ({

-
+
+
+
Version
{resource.version}
@@ -67,12 +91,6 @@ export const ResourceCard: React.FC<{ 10%
- -
-
- ID: {resource.id} -
-
); From 029e5ac4e47ed938286754e39dc9a458357b75d5 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Fri, 21 Mar 2025 18:14:18 -0700 Subject: [PATCH 18/18] fix: Env resources view (#407) --- .../resources/ResourceCard.tsx | 97 --- .../resources/ResourcesPageContent.tsx | 805 ++++++++---------- .../resources/_components/ResourceCard.tsx | 110 +++ .../resources/_components/ResourceTable.tsx | 119 +++ .../{ => _hooks}/useEnvResourceEditor.ts | 0 .../{ => _hooks}/useFilteredResources.ts | 12 +- .../[environmentId]/resources/page.tsx | 27 +- .../condition/ComparisonConditionRender.tsx | 11 + .../condition/ResourceConditionBadge.tsx | 17 + .../condition/ResourceConditionRender.tsx | 10 + .../ResourceVersionConditionRender.tsx | 40 + .../environment-page/resources/router.ts | 146 ++++ .../api/src/router/environment-page/router.ts | 2 + packages/api/src/router/resources.ts | 16 + packages/db/src/schema/resource.ts | 2 + packages/validators/src/jobs/index.ts | 9 + .../conditions/comparison-condition.ts | 4 + .../src/resources/conditions/index.ts | 1 + .../conditions/resource-condition.ts | 12 +- .../resources/conditions/version-condition.ts | 9 + 20 files changed, 885 insertions(+), 564 deletions(-) delete mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourceCard.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceCard.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_components/ResourceTable.tsx rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/{ => _hooks}/useEnvResourceEditor.ts (100%) rename apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/{ => _hooks}/useFilteredResources.ts (60%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/_components/resources/condition/ResourceVersionConditionRender.tsx create mode 100644 packages/api/src/router/environment-page/resources/router.ts create mode 100644 packages/validators/src/resources/conditions/version-condition.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourceCard.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourceCard.tsx deleted file mode 100644 index a829f85fc..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/ResourceCard.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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"; - -const statusColor = { - healthy: "bg-green-500", - degraded: "bg-amber-500", - failed: "bg-red-500", - updating: "bg-blue-500", - unknown: "bg-neutral-500", -}; - -type ResourceStatus = keyof typeof statusColor; - -export const ResourceCard: React.FC<{ - resource: SCHEMA.Resource; - resourceStatus?: ResourceStatus; -}> = ({ resource, resourceStatus }) => { - const handleCopyId = () => { - navigator.clipboard.writeText(resource.id); - toast("Resource ID copied", { - description: resource.id, - duration: 2000, - }); - }; - - return ( -
-
-
-
-

{resource.name}

-
- - {resource.kind} - -
- -
-
ID
-
- - {resource.id.split("-").at(0)}... - - -
- -
Version
-
{resource.version}
- -
Identifier
-
{resource.identifier}
- -
Provider
-
{resource.providerId}
- -
Updated
-
- {resource.updatedAt?.toLocaleDateString() ?? - resource.createdAt.toLocaleDateString()} -
-
- -
-
- Provider - {resource.providerId} -
- -
- Deployment Success - 90 ? "green" : resource.healthScore > 70 ? "amber" : "red"}-400`} - > - 10% - -
-
-
- ); -}; 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 index 4007be887..28f2cde09 100644 --- 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 @@ -1,48 +1,239 @@ "use client"; import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { + ComparisonCondition, + ResourceCondition, +} from "@ctrlplane/validators/resources"; import React, { useState } from "react"; -import { IconFilter, IconSearch } from "@tabler/icons-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 { Badge } from "@ctrlplane/ui/badge"; +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; import { Input } from "@ctrlplane/ui/input"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ctrlplane/ui/table"; + 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, + }; -import { ResourceCard } from "./ResourceCard"; -import { useFilteredResources } from "./useFilteredResources"; + 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; - workspaceId: string; -}> = ({ environment, workspaceId }) => { - const { resources } = useFilteredResources( - workspaceId, +}> = ({ 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 [showFilterEditor, setShowFilterEditor] = useState(false); - const [resourceFilter, setResourceFilter] = useState({ - type: "comparison", - operator: "and", + const [resourceFilter, setResourceFilter] = + useState(null); + + const finalFilter: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, not: false, - conditions: [ - { - type: "kind", - operator: "equals", - not: false, - value: "Pod", - }, - ], - }); + 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) @@ -52,8 +243,6 @@ export const ResourcesPageContent: React.FC<{ .groupBy((t) => t.version + ": " + t.kind) .value() as Record; - const filteredResources = resources; - return (
{/* Resource Summary Cards */} @@ -75,21 +264,29 @@ export const ResourcesPageContent: React.FC<{
Healthy
-
10
+
+ {healthyResources} +
- {10}% of resources + + {Number(healthyPercentage).toFixed(0)}% of resources +
-
- Needs Attention +
+ Unhealthy +
+
+ {unhealthyResources}
-
{0}
- - {0 > 0 ? "Action required" : "No issues detected"} + + {unhealthyResources > 0 + ? "Action required" + : "No issues detected"}
@@ -99,10 +296,14 @@ export const ResourcesPageContent: React.FC<{
Deploying
-
{0 + 0}
+
+ {deployingResources} +
- {0 > 0 ? "Updates in progress" : "No active deployments"} + {deployingResources > 0 + ? "Updates in progress" + : "No active deployments"}
@@ -115,208 +316,127 @@ export const ResourcesPageContent: React.FC<{ setSearch(e.target.value)} />
- setShowFilterEditor(true)} + + setResourceFilter(parseResourceFilter(condition)) + } > - - {resourceFilter.conditions.length > 0 - ? `Filter (${resourceFilter.conditions.length})` - : "Filter"} - - - - -
- + + + + + + +
+ - + + +
{/* Resource Content */} - {selectedView === "grid" ? ( + {selectedView === "grid" && (
- {filteredResources.map((resource) => ( - - ))} -
- ) : ( -
- - - - - Name - - - Kind - - - Component - - - Provider - - - Region - - - Success Rate - - - Last Updated - - - Status - - - - - {filteredResources.map((resource) => ( - - - {resource.name} - - - {resource.kind} - - - {resource.version} - - - {resource.providerId} - - - {resource.providerId} - - -
-
-
90 - ? "bg-green-500" - : resource.healthScore > 70 - ? "bg-amber-500" - : resource.healthScore > 0 - ? "bg-red-500" - : "bg-neutral-600" - }`} - style={{ width: `${resource.healthScore}%` }} - /> -
- {resource.healthScore}% -
- - - {resource.lastUpdated.toLocaleString()} - - - - {resource.status.charAt(0).toUpperCase() + - resource.status.slice(1)} - - - - ))} - -
+ {!isLoading && + resources.map((resource) => ( + + ))} + {isLoading && + Array.from({ length: 8 }).map((_, index) => ( + + ))}
)} + {selectedView === "list" && }
- {filteredResources.length === resources.length ? ( + {resources.length === resources.length ? ( <>Showing all {resources.length} resources ) : ( <> - Showing {filteredResources.length} of {resources.length} resources + Showing {resources.length} of {resources.length} resources )} - {resourceFilter.conditions.length > 0 && ( + {resourceFilter != null && resourceFilter.conditions.length > 0 && ( <> {" "} • Filtered @@ -324,247 +444,24 @@ export const ResourcesPageContent: React.FC<{ )}
- - + +
- - {/* Resource Filter Editor Modal */} - {showFilterEditor && ( -
-
-
-

- Edit Resource Filter -

- -
- -
- {/* Current Conditions */} -
-

- Current Filter Conditions -

- - {resourceFilter.conditions.length > 0 ? ( -
- {resourceFilter.conditions.map( - (condition: any, index: number) => ( -
-
- - {condition.type.charAt(0).toUpperCase() + - condition.type.slice(1)} - - - equals - - - {condition.value} - -
- -
- ), - )} -
- ) : ( -
- No filter conditions set. Resources will not be filtered. -
- )} -
- - {/* Add New Condition */} -
-

Add New Condition

- -
- {/* Condition Type */} -
- - -
- - {/* Operator - Static for now */} -
- - -
- - {/* Condition Value */} -
- - -
- - {/* Add Button */} -
- - -
-
-
- - {/* Description of the filter effect */} -
-

Filter Effect

-

- This filter will{" "} - {resourceFilter.conditions.length > 0 - ? "show only" - : "show all"}{" "} - resources - {resourceFilter.conditions.length > 0 && " that match"} - {resourceFilter.conditions.length > 1 && - resourceFilter.operator === "and" && - " all"} - {resourceFilter.conditions.length > 1 && - resourceFilter.operator === "or" && - " any"} - {resourceFilter.conditions.length > 0 && - " of these conditions."} -

- {resourceFilter.conditions.length > 0 && ( -
- - Currently filtering:{" "} - - {resourceFilter.conditions.map((c: any, i: number) => ( - - {i > 0 && ( - - {" "} - {resourceFilter.operator}{" "} - - )} - {c.type} {c.operator} "{c.value}" - - ))} -
- )} -
- - {/* Save and Cancel Buttons */} -
- - -
-
-
-
- )}
); }; 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/useEnvResourceEditor.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useEnvResourceEditor.ts similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/useEnvResourceEditor.ts rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useEnvResourceEditor.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/useFilteredResources.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts similarity index 60% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/useFilteredResources.ts rename to apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts index 4cd68133a..dd58cabcb 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/useFilteredResources.ts +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/resources/_hooks/useFilteredResources.ts @@ -12,12 +12,14 @@ import { api } from "~/trpc/react"; * @returns Query result containing filtered resources */ export const useFilteredResources = ( - workspaceId: string, + environmentId: string, filter?: ResourceCondition | null, + limit?: number, + offset?: number, ) => { - const resourcesQ = api.resource.byWorkspaceId.list.useQuery( - { workspaceId, filter: filter ?? undefined }, - { enabled: workspaceId !== "" }, + const resourcesQ = api.environment.page.resources.list.useQuery( + { environmentId, filter: filter ?? undefined, limit, offset }, + { enabled: environmentId !== "" }, ); - return { ...resourcesQ, resources: resourcesQ.data?.items ?? [] }; + 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 cfc78cb61..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 @@ -14,10 +14,26 @@ import { ResourcesPageContent } from "./ResourcesPageContent"; export default async function ResourcesPage(props: { params: Promise<{ workspaceSlug: string; environmentId: string }>; }) { - const { workspaceSlug, environmentId } = await props.params; - const workspace = await api.workspace.bySlug(workspaceSlug); + const { environmentId } = await props.params; const environment = await api.environment.byId(environmentId); - if (workspace == null || environment == null) return notFound(); + if (environment == null) return notFound(); + + const { resourceFilter } = environment; + if (resourceFilter == null) + return ( + + + Resources + + Resources managed in this environment + + + +

No resource filter set for this environment

+
+
+ ); + return ( @@ -25,10 +41,7 @@ export default async function ResourcesPage(props: { Resources managed in this environment - + ); 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/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 index a0f466250..9aba8659e 100644 --- a/packages/api/src/router/environment-page/router.ts +++ b/packages/api/src/router/environment-page/router.ts @@ -1,6 +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/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;