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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions src/app/(dashboard)/fleet/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useEnvironmentStore } from "@/stores/environment-store";
import { usePollingInterval } from "@/hooks/use-polling-interval";
import { FleetKpiCards } from "@/components/fleet/fleet-kpi-cards";
import { FleetVolumeChart } from "@/components/fleet/fleet-volume-chart";
import { FleetThroughputChart } from "@/components/fleet/fleet-throughput-chart";
import { FleetCapacityChart } from "@/components/fleet/fleet-capacity-chart";
import { DataLossTable } from "@/components/fleet/data-loss-table";
import { DeploymentMatrix } from "@/components/fleet/deployment-matrix";
import { EmptyState } from "@/components/empty-state";
import { QueryError } from "@/components/query-error";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ArrowLeft } from "lucide-react";

type TimeRange = "1h" | "6h" | "1d" | "7d" | "30d";

export default function FleetOverviewPage() {
const trpc = useTRPC();
const { selectedEnvironmentId } = useEnvironmentStore();
const [range, setRange] = useState<TimeRange>("1d");
const [lossThreshold, setLossThreshold] = useState(0.05);
const polling = usePollingInterval(15_000);

const overview = useQuery({
...trpc.fleet.overview.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

const volumeTrend = useQuery({
...trpc.fleet.volumeTrend.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

const nodeThroughput = useQuery({
...trpc.fleet.nodeThroughput.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

const nodeCapacity = useQuery({
...trpc.fleet.nodeCapacity.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

const dataLoss = useQuery({
...trpc.fleet.dataLoss.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
threshold: lossThreshold,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

const matrixThroughput = useQuery({
...trpc.fleet.matrixThroughput.queryOptions({
environmentId: selectedEnvironmentId ?? "",
range,
}),
enabled: !!selectedEnvironmentId,
refetchInterval: polling,
});

if (!selectedEnvironmentId) {
return (
<div className="space-y-6">
<EmptyState title="Select an environment to view fleet overview" />
</div>
);
}

if (overview.isError) {
return (
<div className="space-y-6">
<QueryError
message="Failed to load fleet overview"
onRetry={() => overview.refetch()}
/>
</div>
);
}

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
href="/fleet"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Fleet
</Link>
<h1 className="text-2xl font-bold">Fleet Overview</h1>
</div>
<div className="flex items-center gap-1">
{(["1h", "6h", "1d", "7d", "30d"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setRange(v)}
className={cn(
"rounded-full px-3 h-7 text-xs font-medium border transition-colors",
range === v
? "bg-accent text-accent-foreground border-transparent"
: "bg-transparent text-muted-foreground border-border hover:bg-muted",
)}
>
{v}
</button>
))}
</div>
</div>

<FleetKpiCards data={overview.data} isLoading={overview.isLoading} />

<FleetVolumeChart
data={volumeTrend.data}
isLoading={volumeTrend.isLoading}
range={range}
/>

<FleetThroughputChart
data={nodeThroughput.data}
isLoading={nodeThroughput.isLoading}
/>

<FleetCapacityChart
data={nodeCapacity.data}
isLoading={nodeCapacity.isLoading}
range={range}
/>

<DataLossTable
data={dataLoss.data}
isLoading={dataLoss.isLoading}
threshold={lossThreshold}
onThresholdChange={setLossThreshold}
/>

<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Deployment Matrix</CardTitle>
</CardHeader>
<CardContent>
<DeploymentMatrix
environmentId={selectedEnvironmentId}
range={range}
lossThreshold={lossThreshold}
throughputData={matrixThroughput.data}
/>
</CardContent>
</Card>
</div>
);
}
12 changes: 12 additions & 0 deletions src/app/(dashboard)/fleet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ export default function FleetPage() {

return (
<div className="space-y-6">
<div className="flex items-center gap-1">
<Link
href="/fleet/overview"
className="rounded-full px-3 h-7 text-xs font-medium border transition-colors bg-transparent text-muted-foreground border-border hover:bg-muted inline-flex items-center"
>
Overview
</Link>
<span className="rounded-full px-3 h-7 text-xs font-medium border transition-colors bg-accent text-accent-foreground border-transparent inline-flex items-center">
Nodes
</span>
</div>

{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
Expand Down
162 changes: 162 additions & 0 deletions src/components/fleet/data-loss-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use client";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { formatCount, formatPercent } from "@/lib/format";

interface PipelineDataLoss {
pipelineId: string;
pipelineName: string;
nodeId: string | null;
nodeName: string | null;
eventsIn: number;
eventsOut: number;
lossRate: number;
}

interface DataLossTableProps {
data: PipelineDataLoss[] | undefined;
isLoading: boolean;
threshold: number;
onThresholdChange: (value: number) => void;
}

export function DataLossTable({
data,
isLoading,
threshold,
onThresholdChange,
}: DataLossTableProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</CardContent>
</Card>
);
}

const thresholdPct = Math.round(threshold * 100);

return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="flex items-center gap-2">
<CardTitle className="text-base font-semibold">Data Loss Detection</CardTitle>
{data && data.length > 0 ? (
<Badge variant="destructive" className="text-xs">
{data.length} flagged
</Badge>
) : (
<Badge variant="outline" className="text-xs text-green-600 dark:text-green-400 border-green-500/50">
No loss detected
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
Threshold
</span>
<Input
type="number"
value={thresholdPct}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 1 && v <= 50) onThresholdChange(v / 100);
}}
className="h-7 w-14 text-xs text-center"
min={1}
max={50}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</CardHeader>
<CardContent>
{!data || data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<CheckCircle2 className="h-8 w-8 text-green-500 mb-2" />
<p className="text-sm text-muted-foreground">
No pipelines exceed the {thresholdPct}% loss threshold
</p>
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium text-muted-foreground">Pipeline</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">Node</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Events In</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Events Out</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Loss Rate</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">Severity</th>
</tr>
</thead>
<tbody className="divide-y">
{data.map((row) => {
const severity = row.lossRate >= 0.2 ? "critical" : row.lossRate >= 0.1 ? "warning" : "minor";
return (
<tr
key={`${row.pipelineId}-${row.nodeId ?? "all"}`}
className={`transition-colors hover:bg-muted/50 ${
severity === "critical"
? "bg-red-50/50 dark:bg-red-950/10"
: severity === "warning"
? "bg-orange-50/50 dark:bg-orange-950/10"
: ""
}`}
>
<td className="px-3 py-2 font-medium">
<Link
href={`/pipelines/${row.pipelineId}`}
className="hover:underline"
>
{row.pipelineName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">
{row.nodeName ?? "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCount(row.eventsIn)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCount(row.eventsOut)}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium text-red-600 dark:text-red-400">
{formatPercent(row.lossRate)}
</td>
<td className="px-3 py-2 text-center">
<div className="flex items-center justify-center">
<AlertTriangle
className={`h-4 w-4 ${
severity === "critical"
? "text-red-500"
: severity === "warning"
? "text-orange-500"
: "text-yellow-500"
}`}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
Loading
Loading