Add initial Portal version
This commit is contained in:
124
src/components/dashboard/DashboardClient.tsx
Normal file
124
src/components/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import InstanceStatus from "@/components/dashboard/InstanceStatus";
|
||||
import UsageDisplay from "@/components/dashboard/UsageDisplay";
|
||||
|
||||
interface TenantSummary {
|
||||
metadata: {
|
||||
name: string;
|
||||
creationTimestamp: string;
|
||||
};
|
||||
spec: {
|
||||
agentName?: string;
|
||||
packages?: string[];
|
||||
litellmTeamId?: string;
|
||||
};
|
||||
status?: {
|
||||
phase: string;
|
||||
conditions?: { type: string; status: string; message?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations("dashboard");
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [tenant, setTenant] = useState<TenantSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/tenants")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// For non-platform users: pick first tenant from the list
|
||||
const items = data.items || data || [];
|
||||
if (items.length > 0) {
|
||||
setTenant(items[0]);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-32 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||
<div className="h-64 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No tenant yet — show CTA
|
||||
if (!tenant) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="rounded-full bg-teal-950/50 border border-teal-800/30 p-4 mb-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-teal-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-zinc-200 mb-1">
|
||||
{t("noInstance")}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 mb-6 max-w-sm">
|
||||
{t("noInstanceDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/onboarding")}
|
||||
className="rounded-lg bg-teal-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-teal-500 transition-colors"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantName = tenant.metadata.name;
|
||||
const teamId = tenant.spec.litellmTeamId || tenantName;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
<InstanceStatus
|
||||
phase={tenant.status?.phase || "Pending"}
|
||||
conditions={tenant.status?.conditions}
|
||||
agentName={tenant.spec.agentName}
|
||||
packages={tenant.spec.packages}
|
||||
createdAt={tenant.metadata.creationTimestamp}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("usageTitle")}
|
||||
</h2>
|
||||
<UsageDisplay teamId={teamId} />
|
||||
</div>
|
||||
|
||||
{/* Quick link to tenant settings */}
|
||||
<button
|
||||
onClick={() => router.push(`/tenants/${tenantName}`)}
|
||||
className="text-xs text-teal-400 hover:text-teal-300 transition-colors"
|
||||
>
|
||||
{t("manageInstance")} →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/dashboard/InstanceStatus.tsx
Normal file
111
src/components/dashboard/InstanceStatus.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Phase = "Running" | "Provisioning" | "Pending" | "Error" | string;
|
||||
|
||||
interface Props {
|
||||
phase: Phase;
|
||||
conditions?: { type: string; status: string; message?: string }[];
|
||||
agentName?: string;
|
||||
packages?: string[];
|
||||
createdAt?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PHASE_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
Running: {
|
||||
bg: "bg-emerald-950/50 border-emerald-800/50",
|
||||
text: "text-emerald-400",
|
||||
dot: "bg-emerald-400 animate-pulse",
|
||||
},
|
||||
Provisioning: {
|
||||
bg: "bg-amber-950/50 border-amber-800/50",
|
||||
text: "text-amber-400",
|
||||
dot: "bg-amber-400 animate-pulse",
|
||||
},
|
||||
Pending: {
|
||||
bg: "bg-zinc-800/50 border-zinc-700/50",
|
||||
text: "text-zinc-400",
|
||||
dot: "bg-zinc-400",
|
||||
},
|
||||
Error: {
|
||||
bg: "bg-red-950/50 border-red-800/50",
|
||||
text: "text-red-400",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||
const style = PHASE_STYLES[phase] || PHASE_STYLES.Pending;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
|
||||
{phase}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InstanceStatus({
|
||||
phase,
|
||||
conditions,
|
||||
agentName,
|
||||
packages,
|
||||
createdAt,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const t = useTranslations("dashboard");
|
||||
|
||||
if (compact) {
|
||||
return <PhaseBadge phase={phase} />;
|
||||
}
|
||||
|
||||
const errorCondition = conditions?.find(
|
||||
(c) => c.status === "False" && c.message
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-zinc-300">
|
||||
{t("instanceStatus")}
|
||||
</h3>
|
||||
<PhaseBadge phase={phase} />
|
||||
</div>
|
||||
|
||||
{agentName && (
|
||||
<div className="text-sm">
|
||||
<span className="text-zinc-500">{t("agentName")}:</span>{" "}
|
||||
<span className="text-zinc-200">{agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packages && packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="rounded bg-teal-950/50 border border-teal-800/30 px-2 py-0.5 text-xs text-teal-300"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
{t("created")}: {new Date(createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorCondition && (
|
||||
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||
{errorCondition.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/components/dashboard/UsageDisplay.tsx
Normal file
216
src/components/dashboard/UsageDisplay.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DailyUsage {
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostCHF: number;
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
currentPeriod: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputCostCHF: number;
|
||||
outputCostCHF: number;
|
||||
totalCostCHF: number;
|
||||
};
|
||||
budget: {
|
||||
maxBudget: number | null;
|
||||
spend: number;
|
||||
remaining: number | null;
|
||||
};
|
||||
rateLimits: {
|
||||
rpm: number | null;
|
||||
tpm: number | null;
|
||||
};
|
||||
dailyUsage: DailyUsage[];
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function formatCHF(n: number): string {
|
||||
return `CHF ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||
if (!data.length) return null;
|
||||
|
||||
const maxTokens = Math.max(
|
||||
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||
1
|
||||
);
|
||||
const barWidth = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||
const chartHeight = 120;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * (barWidth + 2), 600)} ${chartHeight + 24}`}
|
||||
className="w-full h-36"
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const total = d.inputTokens + d.outputTokens;
|
||||
const totalH = (total / maxTokens) * chartHeight;
|
||||
const inputH = (d.inputTokens / maxTokens) * chartHeight;
|
||||
const x = i * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<title>
|
||||
{d.date}: {formatTokens(d.inputTokens)} in / {formatTokens(d.outputTokens)} out
|
||||
</title>
|
||||
{/* Output tokens (bottom) */}
|
||||
<rect
|
||||
x={x}
|
||||
y={chartHeight - totalH}
|
||||
width={barWidth}
|
||||
height={totalH - inputH}
|
||||
rx={1}
|
||||
className="fill-teal-700/60"
|
||||
/>
|
||||
{/* Input tokens (top) */}
|
||||
<rect
|
||||
x={x}
|
||||
y={chartHeight - inputH}
|
||||
width={barWidth}
|
||||
height={inputH}
|
||||
rx={1}
|
||||
className="fill-teal-400/80"
|
||||
/>
|
||||
{/* Date label (every 7th) */}
|
||||
{i % 7 === 0 && (
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-zinc-500 text-[8px]"
|
||||
>
|
||||
{d.date.slice(5)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-500 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-teal-400/80" /> Input
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-teal-700/60" /> Output
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsageDisplay({ teamId }: { teamId: string | null }) {
|
||||
const t = useTranslations("dashboard");
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [teamId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 animate-pulse">
|
||||
<div className="h-4 w-32 bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-36 bg-zinc-800/50 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{error ? t("usageError") : t("noUsageData")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { currentPeriod, budget, rateLimits, dailyUsage } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Spend summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label={t("inputTokens")} value={formatTokens(currentPeriod.inputTokens)} sub={formatCHF(currentPeriod.inputCostCHF)} />
|
||||
<StatCard label={t("outputTokens")} value={formatTokens(currentPeriod.outputTokens)} sub={formatCHF(currentPeriod.outputCostCHF)} />
|
||||
<StatCard label={t("totalCost")} value={formatCHF(currentPeriod.totalCostCHF)} accent />
|
||||
{budget.remaining !== null ? (
|
||||
<StatCard label={t("budgetRemaining")} value={formatCHF(budget.remaining)} />
|
||||
) : (
|
||||
<StatCard label={t("budget")} value={t("noBudgetSet")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rate limits */}
|
||||
{(rateLimits.rpm || rateLimits.tpm) && (
|
||||
<div className="flex gap-4 text-xs text-zinc-500">
|
||||
{rateLimits.rpm && <span>RPM limit: {rateLimits.rpm}</span>}
|
||||
{rateLimits.tpm && <span>TPM limit: {formatTokens(rateLimits.tpm)}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<h3 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("last30Days")}
|
||||
</h3>
|
||||
<UsageChart data={dailyUsage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">{label}</div>
|
||||
<div
|
||||
className={`text-lg font-semibold tabular-nums ${
|
||||
accent ? "text-teal-400" : "text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-zinc-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user