Skip to content
Merged
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
2 changes: 1 addition & 1 deletion dashboard/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function DashboardLayout({
<div className="container flex h-14 items-center justify-between px-6">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight">
InferCost
KubeCostAI
</span>
<span className="hidden text-sm text-muted-foreground sm:inline">
GPU Cost Attribution
Expand Down
232 changes: 232 additions & 0 deletions dashboard/app/dashboard/namespaces/[namespace]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { WorkloadCostsChart } from "@/components/charts/workload-costs";
import { getWorkloadCosts, getNamespaceCosts } from "@/lib/api";
import { exportToCSV } from "@/lib/csv-export";
import type { WorkloadCost, NamespaceCost } from "@/types";

const DEFAULT_CLUSTER = "default";

export default function NamespaceDrillDownPage() {
const params = useParams<{ namespace: string }>();
const router = useRouter();
const namespace = params.namespace;

const [workloads, setWorkloads] = useState<WorkloadCost[]>([]);
const [namespaceSummary, setNamespaceSummary] = useState<NamespaceCost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const sortedWorkloads = useMemo(
() => [...workloads].sort((a, b) => b.gpu_hours - a.gpu_hours),
[workloads]
);

useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const [workloadData, namespaceData] = await Promise.all([
getWorkloadCosts(DEFAULT_CLUSTER, namespace),
getNamespaceCosts(DEFAULT_CLUSTER),
]);
setWorkloads(workloadData);
const summary = namespaceData.find((ns) => ns.namespace === namespace) ?? null;
setNamespaceSummary(summary);
} catch (err) {
console.error("Failed to fetch namespace data:", err);
setError(
err instanceof Error ? err.message : "Failed to load namespace data"
);
} finally {
setLoading(false);
}
}

fetchData();
}, [namespace]);

return (
<div className="space-y-6">
{/* Back button and breadcrumb */}
<div className="space-y-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/dashboard")}
>
<ArrowLeft className="size-4" />
Back to Dashboard
</Button>
<nav className="text-sm text-muted-foreground">
<Link href="/dashboard" className="hover:text-foreground transition-colors">
Dashboard
</Link>
<span className="mx-2">/</span>
<span className="text-foreground font-medium">{decodeURIComponent(namespace)}</span>
</nav>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{decodeURIComponent(namespace)}
</h1>
<p className="text-muted-foreground">
Workload GPU usage for this namespace.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={loading}
onClick={() => {
const rows = sortedWorkloads.map((w) => ({
pod_name: w.pod_name,
gpu_hours: w.gpu_hours,
avg_utilization: w.avg_utilization,
}));
const date = new Date().toISOString().slice(0, 10);
exportToCSV(
rows,
`kubecostai-${encodeURIComponent(namespace)}-workloads-${date}.csv`
);
}}
>
<Download className="size-4" />
Export CSV
</Button>
</div>
</div>

{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}

{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Total GPU Hours
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<p className="text-2xl font-bold">
{namespaceSummary ? namespaceSummary.gpu_hours.toFixed(1) : "--"}
</p>
<p className="text-xs text-muted-foreground mt-1">Last 7 days</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg Utilization
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<p className="text-2xl font-bold">
{namespaceSummary ? `${namespaceSummary.avg_utilization.toFixed(1)}%` : "--"}
</p>
<p className="text-xs text-muted-foreground mt-1">Across all GPUs</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Pod Count
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<p className="text-2xl font-bold">
{namespaceSummary ? namespaceSummary.pod_count : "--"}
</p>
<p className="text-xs text-muted-foreground mt-1">Active pods</p>
</>
)}
</CardContent>
</Card>
</div>

{/* Workload chart */}
<WorkloadCostsChart data={sortedWorkloads} loading={loading} />

{/* Workload table */}
<Card>
<CardHeader>
<CardTitle>Workload Details</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
) : sortedWorkloads.length === 0 ? (
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
No workload data available
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Pod Name</TableHead>
<TableHead className="text-right">GPU Hours</TableHead>
<TableHead className="text-right">Avg Utilization</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedWorkloads.map((workload) => (
<TableRow key={workload.pod_name}>
<TableCell className="font-medium">
{workload.pod_name}
</TableCell>
<TableCell className="text-right">
{workload.gpu_hours.toFixed(1)}
</TableCell>
<TableCell className="text-right">
{workload.avg_utilization.toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
96 changes: 88 additions & 8 deletions dashboard/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
"use client";

import { useEffect, useState } from "react";
import { Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { OverviewCards } from "@/components/overview-cards";
import { NamespaceCostsChart } from "@/components/charts/namespace-costs";
import { CostTrendChart } from "@/components/charts/cost-trend";
import { GPUHeatmap } from "@/components/charts/gpu-heatmap";
import {
getClusterOverview,
getNamespaceCosts,
getGPUUtilization,
} from "@/lib/api";
import type { ClusterOverview, NamespaceCost, GPUUtilPoint } from "@/types";
import { exportToCSV } from "@/lib/csv-export";
import { BudgetAlerts } from "@/components/budget-alerts";
import type {
ClusterOverview,
NamespaceCost,
GPUUtilPoint,
BudgetAlert,
} from "@/types";

const DEFAULT_CLUSTER = "default";

const DEMO_ALERTS: BudgetAlert[] = [
{
id: "alert-1",
severity: "critical",
message:
'Namespace "ml-training" exceeded $5,000/week budget (currently $6,230)',
namespace: "ml-training",
current_spend: 6230,
threshold: 5000,
triggered_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
},
{
id: "alert-2",
severity: "warning",
message:
'Namespace "inference" at 85% of $3,000/week budget (currently $2,550)',
namespace: "inference",
current_spend: 2550,
threshold: 3000,
triggered_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), // 5 hours ago
},
{
id: "alert-3",
severity: "warning",
message:
'Daily spend 2.3x rolling average for "dev-experiments"',
namespace: "dev-experiments",
current_spend: 1840,
threshold: 800,
triggered_at: new Date(Date.now() - 45 * 60 * 1000).toISOString(), // 45 minutes ago
},
];

export default function DashboardPage() {
const [overview, setOverview] = useState<ClusterOverview | null>(null);
const [namespaces, setNamespaces] = useState<NamespaceCost[]>([]);
Expand Down Expand Up @@ -48,13 +97,33 @@ export default function DashboardPage() {

return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Cluster Overview
</h1>
<p className="text-muted-foreground">
GPU usage and cost attribution for your Kubernetes cluster.
</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Cluster Overview
</h1>
<p className="text-muted-foreground">
GPU usage and cost attribution for your Kubernetes cluster.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={loading}
onClick={() => {
const rows = namespaces.map((ns) => ({
namespace: ns.namespace,
gpu_hours: ns.gpu_hours,
avg_utilization: ns.avg_utilization,
pod_count: ns.pod_count,
}));
const date = new Date().toISOString().slice(0, 10);
exportToCSV(rows, `kubecostai-namespaces-${date}.csv`);
}}
>
<Download className="size-4" />
Export CSV
</Button>
</div>

{error && (
Expand All @@ -69,6 +138,17 @@ export default function DashboardPage() {
<NamespaceCostsChart data={namespaces} loading={loading} />
<CostTrendChart data={utilization} loading={loading} />
</div>

<GPUHeatmap data={utilization} loading={loading} />

<Card>
<CardHeader>
<CardTitle>Budget Alerts</CardTitle>
</CardHeader>
<CardContent>
<BudgetAlerts alerts={DEMO_ALERTS} />
</CardContent>
</Card>
</div>
);
}
2 changes: 1 addition & 1 deletion dashboard/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "./globals.css";
const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "InferCost — AI Infrastructure Cost Attribution",
title: "KubeCostAI — AI Infrastructure Cost Attribution",
description: "See where your GPU and LLM spend goes.",
};

Expand Down
Loading
Loading