diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentDropdown.tsx index 2a208b36a..5531c6845 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentDropdown.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentDropdown.tsx @@ -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 = { @@ -21,10 +35,94 @@ export const EnvironmentDropdown: React.FC = ({ 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 ( {children} - e.stopPropagation()}> + e.stopPropagation()}> + + router.push(environmentUrls.baseUrl())} + > + + View Details + + + router.push(environmentUrls.deployments())} + > + + View Deployments + + + router.push(environmentUrls.resources())} + > + + View Resources + + + + + + + router.push(environmentUrls.settings())} + > + + Settings + + + router.push(environmentUrls.policies())} + > + + Policies + + + router.push(environmentUrls.variables())} + > + + Variables + + + + + Copy ID + + + + + e.preventDefault()} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentRow.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentRow.tsx index 0237e5833..e29e841b2 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentRow.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/EnvironmentRow.tsx @@ -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"; @@ -72,6 +75,171 @@ const LazyEnvironmentHealth: React.FC = (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 ( + + + +
+
+ + {environment.name} + +
+
+ + {status} + +
+ + + +
+
+ + + +
+ Resources + + {totalCount > 0 ? `${totalCount} total` : "None"} + +
+
+ Health + + {totalCount > 0 + ? `${healthyCount}/${totalCount} Healthy` + : "No resources"} + +
+
+ + Environment ID + +
+ + {environment.id.substring(0, 8)}... + + +
+
+
+ Failure Rate + {!failureRateQ.isLoading && failureRateQ.data != null && ( + 5 ? "text-red-400" : "text-green-400", + )} + > + {Number(failureRateQ.data).toFixed(0)}% failure rate + + )} + {(failureRateQ.isLoading || failureRateQ.data == null) && ( + - + )} +
+
+ + + ); +}; + export const EnvironmentRow: React.FC<{ environment: Environment; }> = ({ environment }) => { diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/page.tsx index 478239892..7abd63f55 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(sidebar)/environments/page.tsx @@ -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 }; @@ -46,9 +46,19 @@ export default async function EnvironmentsPage(props: { - {environments.map((environment) => ( - - ))} +
+ {environments.map((environment) => ( + + ))} + + {environments.length === 0 && ( +
+

+ No environments found. Create your first environment. +

+
+ )} +
); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/DeploymentPerformance.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/DeploymentPerformance.tsx new file mode 100644 index 000000000..25f46e4e0 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/DeploymentPerformance.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { format } from "date-fns"; + +import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +type DeploymentPerformanceProps = { + deployments: any[]; + startDate: Date; + endDate: Date; +}; + +export const DeploymentPerformance: React.FC = ({ + deployments, + startDate, + endDate, +}) => { + const router = useRouter(); + + 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`; + }; + + const handleRowClick = (systemSlug: string, deploymentSlug: string) => { + // Navigate to the deployment details + router.push(`/${systemSlug}/deployments/${deploymentSlug}`); + }; + + return ( + + +
+ Deployment Performance + + {format(startDate, "MMM d")} - {format(endDate, "MMM d, yyyy")} + +
+
+ +
+ + + + Deployment + System + Jobs + Success + Avg + p90 + Last Run + + + + {deployments.length === 0 ? ( + + + + No deployments found + + + + ) : ( + deployments.map((deployment) => ( + + handleRowClick(deployment.systemSlug, deployment.slug) + } + > + + {deployment.name} + + + {deployment.systemName} + + + {typeof deployment.totalJobs === "number" + ? String(deployment.totalJobs).replace( + /\B(?=(\d{3})+(?!\d))/g, + ",", + ) + : 0} + + + {deployment.successRate ? ( +
= 90 + ? "text-green-600" + : deployment.successRate >= 70 + ? "text-yellow-600" + : "text-red-600" + }`} + > + {(deployment.successRate as number).toFixed(1)}% +
+ ) : ( + - + )} +
+ + {formatDuration(deployment.p50) !== "N/A" + ? formatDuration(deployment.p50) + : "-"} + + + {formatDuration(deployment.p90) !== "N/A" + ? formatDuration(deployment.p90) + : "-"} + + + {deployment.lastRunAt ? ( +
+ {format( + new Date(deployment.lastRunAt), + "MMM d, HH:mm", + )} +
+ ) : ( + - + )} +
+
+ )) + )} +
+
+
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/InsightsFilters.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/InsightsFilters.tsx new file mode 100644 index 000000000..850bc27e6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/InsightsFilters.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; + +type System = { + id: string; + name: string; + slug: string; +}; + +type InsightsFiltersProps = { + workspaceSlug: string; + systems: System[]; + currentSystemId?: string; + currentTimeRange?: string; +}; + +export const InsightsFilters: React.FC = ({ + workspaceSlug, + systems, + currentSystemId, + currentTimeRange = "30", +}) => { + const router = useRouter(); + + const handleSystemChange = useCallback( + (value: string) => { + const params = new URLSearchParams(); + if (value !== "all") { + params.set("systemId", value); + } + if (currentTimeRange !== "30") { + params.set("timeRange", currentTimeRange); + } + router.push(`/${workspaceSlug}/insights?${params.toString()}`); + }, + [router, workspaceSlug, currentTimeRange], + ); + + const handleTimeRangeChange = useCallback( + (value: string) => { + const params = new URLSearchParams(); + if (currentSystemId) { + params.set("systemId", currentSystemId); + } + if (value !== "30") { + params.set("timeRange", value); + } + router.push(`/${workspaceSlug}/insights?${params.toString()}`); + }, + [router, workspaceSlug, currentSystemId], + ); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/ResourceTypeBreakdown.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/ResourceTypeBreakdown.tsx new file mode 100644 index 000000000..382a398ff --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/ResourceTypeBreakdown.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import colors from "tailwindcss/colors"; + +import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs"; + +import { api } from "~/trpc/react"; + +type ResourceTypeBreakdownProps = { + workspaceId: string; + systemId?: string; +}; + +const COLORS = [ + colors.blue[500], + colors.purple[500], + colors.green[500], + colors.yellow[500], + colors.red[500], + colors.indigo[500], + colors.pink[500], + colors.cyan[500], + colors.amber[500], + colors.emerald[500], +]; + +export const ResourceTypeBreakdown: React.FC = ({ + workspaceId, + systemId, +}) => { + const [groupBy, setGroupBy] = useState("kind"); + + const { data: resources, isLoading } = + api.resource.byWorkspaceId.list.useQuery({ + workspaceId, + limit: 500, + }); + + const [chartData, setChartData] = useState([]); + + useEffect(() => { + if (!resources) return; + + // Filter by system if systemId is provided + const filteredResources = resources.items; + + // Group resources by the selected attribute + const groupedResources = filteredResources.reduce( + (acc, resource) => { + let key; + if (groupBy === "kind") { + key = resource.kind || "Unknown"; + } else if (groupBy === "provider") { + key = resource.provider ?? "Unknown"; + } else if (groupBy === "apiVersion") { + key = resource.version || "Unknown"; + } + + if (!acc[key as string]) { + acc[key as string] = (acc[key as string] ?? 0) + 1; + } + return acc; + }, + {} as Record, + ); + + // Convert to chart data + const data = Object.entries(groupedResources) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + + setChartData(data); + }, [resources, groupBy, systemId]); + + if (isLoading) { + return ( + + + Resource Breakdown + + + + + + ); + } + + return ( + + +
+ Resource Breakdown + + + + Kind + + + Provider + + + Version + + + +
+
+ + {chartData.length === 0 ? ( +
+

+ No resource data available +

+
+ ) : ( + + + + percent > 0.05 + ? `${name.length > 12 ? `${name.substring(0, 12)}...` : name}` + : "" + } + > + {chartData.map((entry, index) => ( + + ))} + + [`${value} resources`, "Count"]} + labelFormatter={(label) => `${label}`} + contentStyle={{ + borderRadius: "4px", + boxShadow: "0 1px 4px rgba(0,0,0,0.1)", + }} + /> + + value.length > 20 ? `${value.substring(0, 20)}...` : value + } + /> + + + )} +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/SystemHealthOverview.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/SystemHealthOverview.tsx new file mode 100644 index 000000000..650caaa8f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/SystemHealthOverview.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import colors from "tailwindcss/colors"; + +import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; + +// import { JobStatus } from "@ctrlplane/validators/jobs"; + +import { api } from "~/trpc/react"; + +type SystemHealthOverviewProps = { + systemId: string; + workspaceId: string; +}; + +export const SystemHealthOverview: React.FC = ({ + systemId, + // workspaceId, +}) => { + const { data: system, isLoading: isLoadingSystem } = + api.system.byId.useQuery(systemId); + const { data: resources, isLoading: isLoadingResources } = + api.system.resources.useQuery({ + systemId, + limit: 100, + }); + + const { data: environments, isLoading: isLoadingEnvironments } = + api.environment.bySystemId.useQuery(systemId); + + const [healthData, setHealthData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Wait for all data to load + if (isLoadingSystem || isLoadingResources || isLoadingEnvironments) { + setIsLoading(true); + return; + } + + setIsLoading(false); + + // Format data for the environments health chart + if (environments && resources) { + const envHealthData = environments.map((env) => { + const envResources = resources.items.filter((_resource) => { + // This is a simplified check - you'd need to implement logic to properly + // check if a resource belongs to an environment based on filter criteria + return true as boolean; + }); + + const total = envResources.length; + const healthy = 0; + const unhealthy = total - healthy; + + return { + name: env.name, + healthy, + unhealthy, + total, + }; + }); + + setHealthData(envHealthData); + } + }, [ + system, + resources, + environments, + isLoadingSystem, + isLoadingResources, + isLoadingEnvironments, + ]); + + if (isLoading === true) { + return ( + + + System Health Overview + + +
+ +
+
+
+ ); + } + + const systemName = system?.name ?? "Selected System"; + + return ( + + +
+ System Health Overview + {systemName} +
+
+ + {healthData.length === 0 ? ( +
+
+

+ No health data available +

+
+
+ ) : ( + + + + + + [ + value, + name === "healthy" ? "Healthy" : "Unhealthy", + ]} + contentStyle={{ borderRadius: "4px", fontSize: "12px" }} + /> + { + return value === "healthy" ? "Healthy" : "Unhealthy"; + }} + /> + + + + + )} +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/SuccessRate.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/SuccessRate.tsx index 9be38e382..e27996838 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/SuccessRate.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/SuccessRate.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Card, CardContent } from "@ctrlplane/ui/card"; import { Skeleton } from "@ctrlplane/ui/skeleton"; import { api } from "~/trpc/react"; @@ -25,14 +25,21 @@ export const SuccessRate: React.FC = ({ const prettySuccessRate = (data?.successRate ?? 0).toFixed(2); return ( - - - Success Rate - - - {isLoading && } - {!isLoading && ( -

{prettySuccessRate}%

+ + + {isLoading ? ( + + ) : ( +
+

+ Success Rate +

+

= 90 ? "text-green-600" : Number(prettySuccessRate) >= 70 ? "text-yellow-600" : "text-red-600"}`} + > + {prettySuccessRate}% +

+
)}
diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/TotalJobs.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/TotalJobs.tsx index de0fa5341..e771bd5e0 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/TotalJobs.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/TotalJobs.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Card, CardContent } from "@ctrlplane/ui/card"; import { Skeleton } from "@ctrlplane/ui/skeleton"; import { api } from "~/trpc/react"; @@ -23,14 +23,19 @@ export const TotalJobs: React.FC = ({ }); return ( - - - Total Jobs - - - {isLoading && } - {!isLoading && ( -

{data?.totalJobs ?? 0}

+ + + {isLoading ? ( + + ) : ( +
+

+ Total Jobs +

+

+ {(data?.totalJobs ?? 0).toLocaleString()} +

+
)}
diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/WorkspaceResources.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/WorkspaceResources.tsx index 18abfa06e..34ff604ce 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/WorkspaceResources.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/overview-cards/WorkspaceResources.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; +import { Card, CardContent } from "@ctrlplane/ui/card"; import { Skeleton } from "@ctrlplane/ui/skeleton"; import { api } from "~/trpc/react"; @@ -20,13 +20,20 @@ export const WorkspaceResources: React.FC = ({ const numResources = data?.total ?? 0; return ( - - - Total Resources - - - {isLoading && } - {!isLoading &&

{numResources}

} + + + {isLoading ? ( + + ) : ( +
+

+ Total Resources +

+

+ {numResources.toLocaleString()} +

+
+ )}
); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/page.tsx index f66eadc9b..a9f78923c 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/insights/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/insights/page.tsx @@ -1,3 +1,7 @@ +import type { + StatsColumn, + StatsOrder, +} from "@ctrlplane/validators/deployments"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { endOfDay, startOfDay, subDays, subWeeks } from "date-fns"; @@ -7,9 +11,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; import { api } from "~/trpc/server"; import { DailyJobsChart } from "./DailyJobsChart"; import { DailyResourceCountGraph } from "./DailyResourcesCountGraph"; +import { DeploymentPerformance } from "./DeploymentPerformance"; +import { InsightsFilters } from "./InsightsFilters"; import { SuccessRate } from "./overview-cards/SuccessRate"; import { TotalJobs } from "./overview-cards/TotalJobs"; import { WorkspaceResources } from "./overview-cards/WorkspaceResources"; +import { ResourceTypeBreakdown } from "./ResourceTypeBreakdown"; +import { SystemHealthOverview } from "./SystemHealthOverview"; export const generateMetadata = async (props: { params: { workspaceSlug: string }; @@ -31,16 +39,33 @@ export const generateMetadata = async (props: { type Props = { params: Promise<{ workspaceSlug: string }>; + searchParams?: { + systemId?: string; + resourceKind?: string; + timeRange?: string; + }; }; export default async function InsightsPage(props: Props) { const { workspaceSlug } = await props.params; + const { systemId, timeRange = "30" } = props.searchParams ?? {}; + const workspace = await api.workspace.bySlug(workspaceSlug); if (workspace == null) notFound(); + // Calculate date range based on timeRange param (default to 30 days) + const days = parseInt(timeRange, 10) || 30; const endDate = endOfDay(new Date()); - const startDate = startOfDay(subDays(endDate, 30)); + const startDate = startOfDay(subDays(endDate, days)); + + // Get systems for the filter dropdown + const systems = await api.system.list({ + workspaceId: workspace.id, + limit: 100, + offset: 0, + }); + // Get resources stats with optional filtering const resources = await api.resource.stats.dailyCount.byWorkspaceId({ workspaceId: workspace.id, startDate, @@ -49,19 +74,47 @@ export default async function InsightsPage(props: Props) { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const jobs = await api.job.config.byWorkspaceId.dailyCount({ + // Get jobs stats with optional filtering by system + const jobsParams = { workspaceId: workspace.id, startDate: subWeeks(endDate, 6), endDate, timezone, + }; + + const jobs = await api.job.config.byWorkspaceId.dailyCount(jobsParams); + + // Get deployment stats with filtering + const deploymentStatsParams = { + startDate, + endDate, + timezone, + orderBy: "last-run-at", + order: "desc", + ...(systemId ? { systemId } : { workspaceId: workspace.id }), + }; + const deploymentStats = await api.deployment.stats.byWorkspaceId({ + ...deploymentStatsParams, + orderBy: "last-run-at" as StatsColumn, + order: "desc" as StatsOrder, }); return ( -
-
-

Insights

+
+ {/* Header with filters on right */} +
+

Insights

+ +
-
+ + {/* Overview metrics */} +
- - - Jobs per day - - - - - - - - - Resources over 30 days - - - - - + {/* Activity trends section */} +
+

+ Activity Trends +

+
+ + + Jobs per day + + + + + + + + + + Resources over {days} days + + + + + + +
+
+ + {/* Distribution & Health section */} +
+

+ Distribution & Health +

+
+ + + {systemId ? ( + + ) : ( + + + + System Health Overview + + + +
+

+ Select a system from the dropdown above to view health + information about environments and resources. +

+
+
+
+ )} +
+
+ + {/* Performance section */} +
+

+ Performance Metrics +

+ +
); } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx index 1210adc5e..a578dfcc6 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/layout.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { IconChartBar, IconCube, IconSettings } from "@tabler/icons-react"; +import { IconCube, IconSettings } from "@tabler/icons-react"; import { EnvironmentDrawer } from "~/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer"; import { EnvironmentPolicyDrawer } from "~/app/[workspaceSlug]/(app)/_components/policy/drawer/EnvironmentPolicyDrawer"; @@ -34,11 +34,11 @@ export default async function Layout(props: { label="Resources" href={workspaceUrls.resources().baseUrl()} /> - } label="Insights" href={workspaceUrls.insights()} - /> + /> */}
{/* } diff --git a/apps/webservice/src/app/urls.ts b/apps/webservice/src/app/urls.ts index 03a096d27..f08440dee 100644 --- a/apps/webservice/src/app/urls.ts +++ b/apps/webservice/src/app/urls.ts @@ -138,6 +138,7 @@ const environment = (params: EnvironmentParams) => { policies: () => buildUrl(...base, "policies"), resources: () => buildUrl(...base, "resources"), variables: () => buildUrl(...base, "variables"), + settings: () => buildUrl(...base, "settings"), }; }; type DeploymentParams = SystemParams & { diff --git a/packages/api/src/router/environment-stats.ts b/packages/api/src/router/environment-stats.ts index 6ae69538a..3b7a45977 100644 --- a/packages/api/src/router/environment-stats.ts +++ b/packages/api/src/router/environment-stats.ts @@ -4,15 +4,18 @@ import { and, eq, exists, + gt, + inArray, isNotNull, isNull, + lte, ne, sql, takeFirstOrNull, } from "@ctrlplane/db"; import * as SCHEMA from "@ctrlplane/db/schema"; import { Permission } from "@ctrlplane/validators/auth"; -import { JobStatus } from "@ctrlplane/validators/jobs"; +import { analyticsStatuses, JobStatus } from "@ctrlplane/validators/jobs"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -90,4 +93,50 @@ export const environmentStatsRouter = createTRPCRouter({ ), ); }), + + failureRate: protectedProcedure + .input( + z.object({ + environmentId: z.string().uuid(), + startDate: z.date(), + endDate: z.date(), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.EnvironmentGet) + .on({ type: "environment", id: input.environmentId }), + }) + .query(({ ctx, input }) => + ctx.db + .select({ + failureRate: sql` + CAST( + SUM( + CASE + WHEN ${SCHEMA.job.status} = ${JobStatus.Successful} THEN 0 ELSE 1 + END + ) AS FLOAT + ) / + NULLIF(COUNT(*), 0) * 100 + `.as("failureRate"), + }) + .from(SCHEMA.job) + .innerJoin( + SCHEMA.releaseJobTrigger, + eq(SCHEMA.releaseJobTrigger.jobId, SCHEMA.job.id), + ) + .where( + and( + eq(SCHEMA.releaseJobTrigger.environmentId, input.environmentId), + gt(SCHEMA.job.createdAt, input.startDate), + lte(SCHEMA.job.createdAt, input.endDate), + inArray(SCHEMA.job.status, analyticsStatuses), + ), + ) + .limit(1) + .then(takeFirstOrNull) + .then((r) => r?.failureRate ?? null), + ), });