Skip to content
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type * as SCHEMA from "@ctrlplane/db/schema";
import { useState } from "react";
import { IconTrash } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import {
IconChartBar,
IconClipboardCopy,
IconExternalLink,
IconLock,
IconRefresh,
IconSettings,
IconTrash,
IconVariable,
} from "@tabler/icons-react";

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ctrlplane/ui/dropdown-menu";
import { toast } from "@ctrlplane/ui/toast";

import { urls } from "~/app/urls";
import { DeleteEnvironmentDialog } from "./DeleteEnvironmentDialog";

type EnvironmentDropdownProps = {
Expand All @@ -21,10 +35,94 @@ export const EnvironmentDropdown: React.FC<EnvironmentDropdownProps> = ({
children,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, systemSlug } = useParams<{
workspaceSlug: string;
systemSlug: string;
}>();

// Use URL builder for constructing environment URLs
const environmentUrls = urls
.workspace(workspaceSlug)
.system(systemSlug)
.environment(environment.id);

const copyEnvironmentId = () => {
navigator.clipboard.writeText(environment.id);
toast.success("Environment ID copied", {
description: environment.id,
duration: 2000,
});
};

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuGroup>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.baseUrl())}
>
<IconExternalLink className="h-4 w-4" />
View Details
</DropdownMenuItem>

<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.deployments())}
>
<IconChartBar className="h-4 w-4" />
View Deployments
</DropdownMenuItem>

<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.resources())}
>
<IconRefresh className="h-4 w-4" />
View Resources
</DropdownMenuItem>
</DropdownMenuGroup>

<DropdownMenuSeparator />

<DropdownMenuGroup>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.settings())}
>
<IconSettings className="h-4 w-4" />
Settings
</DropdownMenuItem>

<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.policies())}
>
<IconLock className="h-4 w-4" />
Policies
</DropdownMenuItem>

<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={() => router.push(environmentUrls.variables())}
>
<IconVariable className="h-4 w-4" />
Variables
</DropdownMenuItem>

<DropdownMenuItem
className="flex cursor-pointer items-center gap-2"
onClick={copyEnvironmentId}
>
<IconClipboardCopy className="h-4 w-4" />
Copy ID
</DropdownMenuItem>
</DropdownMenuGroup>

<DropdownMenuSeparator />

<DeleteEnvironmentDialog environment={environment}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"use client";

import type { RouterOutputs } from "@ctrlplane/api";
import React from "react";
import React, { useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { IconDots } from "@tabler/icons-react";
import { IconCheck, IconCopy, IconDots } from "@tabler/icons-react";
import { subWeeks } from "date-fns";
import { useInView } from "react-intersection-observer";

import { cn } from "@ctrlplane/ui";
import { Button } from "@ctrlplane/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card";
import { Skeleton } from "@ctrlplane/ui/skeleton";
import { toast } from "@ctrlplane/ui/toast";

import { urls } from "~/app/urls";
import { api } from "~/trpc/react";
Expand Down Expand Up @@ -72,6 +75,171 @@ const LazyEnvironmentHealth: React.FC<EnvironmentHealthProps> = (props) => {
);
};

export const EnvironmentCard: React.FC<{
environment: Environment;
}> = ({ environment }) => {
const { workspaceSlug, systemSlug } = useParams<{
workspaceSlug: string;
systemSlug: string;
}>();

const allResourcesQ = api.resource.byWorkspaceId.list.useQuery(
{
workspaceId: environment.system.workspaceId,
filter: environment.resourceFilter ?? undefined,
limit: 0,
},
{ enabled: environment.resourceFilter != null },
);

const unhealthyResourcesQ = api.environment.stats.unhealthyResources.useQuery(
environment.id,
);

const endDate = new Date();
const startDate = subWeeks(endDate, 1);
const failureRateQ = api.environment.stats.failureRate.useQuery({
environmentId: environment.id,
startDate,
endDate,
});

const unhealthyCount = unhealthyResourcesQ.data?.length ?? 0;
const totalCount = allResourcesQ.data?.total ?? 0;
const healthyCount = totalCount - unhealthyCount;
const status =
totalCount > 0
? unhealthyCount === 0
? "Healthy"
: "Issues Detected"
: "No Resources";
const statusColor =
totalCount > 0 ? (unhealthyCount === 0 ? "green" : "red") : "neutral";

const environmentUrl = urls
.workspace(workspaceSlug)
.system(systemSlug)
.environment(environment.id)
.baseUrl();

return (
<Link href={environmentUrl} className="block">
<Card className="transition-shadow duration-300 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center space-x-2">
<div
className={cn(
"h-3 w-3 animate-pulse rounded-full",
statusColor === "green"
? "bg-green-400"
: statusColor === "red"
? "bg-red-400"
: "bg-neutral-400",
)}
/>
<CardTitle className="text-sm font-medium">
{environment.name}
</CardTitle>
</div>
<div className="flex items-center">
<span
className={cn(
"rounded-full px-2.5 py-1 text-xs font-semibold",
statusColor === "green"
? "bg-green-500/20 text-green-400"
: statusColor === "red"
? "bg-red-500/20 text-red-400"
: "bg-neutral-500/20 text-neutral-400",
)}
>
{status}
</span>
<div className="ml-2">
<EnvironmentDropdown environment={environment}>
<Button variant="ghost" size="icon" className="h-6 w-6">
<IconDots className="h-4 w-4" />
</Button>
</EnvironmentDropdown>
</div>
</div>
</CardHeader>

<CardContent className="mt-4 space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Resources</span>
<span className="text-sm font-medium">
{totalCount > 0 ? `${totalCount} total` : "None"}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Health</span>
<span
className={cn(
"text-sm font-medium",
statusColor === "green"
? "text-green-400"
: statusColor === "red"
? "text-red-400"
: "text-neutral-400",
)}
>
{totalCount > 0
? `${healthyCount}/${totalCount} Healthy`
: "No resources"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Environment ID
</span>
<div className="flex items-center gap-1">
<span className="text-sm font-medium">
{environment.id.substring(0, 8)}...
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full hover:bg-neutral-800/50"
onClick={useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
navigator.clipboard.writeText(environment.id);
toast.success("Environment ID copied to clipboard", {
description: environment.id,
duration: 2000,
icon: <IconCheck className="h-4 w-4" />,
});
},
[environment.id],
)}
title="Copy environment ID"
>
<IconCopy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Failure Rate</span>
{!failureRateQ.isLoading && failureRateQ.data != null && (
<span
className={cn(
"text-sm font-medium",
failureRateQ.data > 5 ? "text-red-400" : "text-green-400",
)}
>
{Number(failureRateQ.data).toFixed(0)}% failure rate
</span>
)}
{(failureRateQ.isLoading || failureRateQ.data == null) && (
<span className="text-sm font-medium">-</span>
)}
</div>
</CardContent>
</Card>
</Link>
);
};

export const EnvironmentRow: React.FC<{
environment: Environment;
}> = ({ environment }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PageHeader } from "~/app/[workspaceSlug]/(app)/_components/PageHeader";
import { api } from "~/trpc/server";
import { SystemBreadcrumb } from "../_components/SystemBreadcrumb";
import { CreateEnvironmentDialog } from "./CreateEnvironmentDialog";
import { EnvironmentRow } from "./EnvironmentRow";
import { EnvironmentCard } from "./EnvironmentRow";

export const generateMetadata = async (props: {
params: { workspaceSlug: string; systemSlug: string };
Expand Down Expand Up @@ -46,9 +46,19 @@ export default async function EnvironmentsPage(props: {
</CreateEnvironmentDialog>
</PageHeader>

{environments.map((environment) => (
<EnvironmentRow key={environment.id} environment={environment} />
))}
<div className="m-2 grid grid-cols-1 gap-6 p-4 md:grid-cols-2 lg:grid-cols-3">
{environments.map((environment) => (
<EnvironmentCard key={environment.id} environment={environment} />
))}

{environments.length === 0 && (
<div className="col-span-full flex h-32 items-center justify-center rounded-lg border border-dashed border-neutral-800">
<p className="text-sm text-neutral-400">
No environments found. Create your first environment.
</p>
</div>
)}
</div>
</div>
);
}
Loading
Loading