From f20d5f09aebd269544570b33c69300d9e2ab430c Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 10 Apr 2026 14:44:03 +0200 Subject: [PATCH] Working version 6.2 --- src/app/[locale]/admin/page.tsx | 107 +++++++- src/app/[locale]/dashboard/page.tsx | 241 +++++++++++++++++- src/app/[locale]/tenants/[name]/page.tsx | 83 +++++- src/app/api/packages/route.ts | 8 +- src/app/api/tenants/[name]/route.ts | 52 ++-- src/app/api/tenants/[name]/secrets/route.ts | 66 ++--- src/app/api/usage/route.ts | 137 +++++----- src/components/admin/AdminTenantsClient.tsx | 193 -------------- src/components/dashboard/DashboardClient.tsx | 124 --------- src/components/dashboard/InstanceStatus.tsx | 111 -------- src/components/dashboard/UsageDisplay.tsx | 216 ---------------- src/components/dashboard/usage-display.tsx | 185 ++++++++++++++ src/components/packages/PackageCard.tsx | 224 ---------------- src/components/packages/WorkspaceEditor.tsx | 87 ------- src/components/packages/package-card.tsx | 192 ++++++++++++++ src/components/packages/package-list.tsx | 40 +++ src/components/packages/workspace-editor.tsx | 88 +++++++ src/components/tenants/TenantDetailClient.tsx | 196 -------------- src/lib/auth.ts | 2 + src/lib/litellm.ts | 17 ++ src/lib/openbao.ts | 15 +- src/lib/packages.ts | 54 ++-- src/messages/de.json | 86 +++---- src/messages/en.json | 86 +++---- src/messages/fr.json | 86 +++---- src/messages/it.json | 86 +++---- src/middleware.ts | 2 +- src/types/index.ts | 1 + 28 files changed, 1231 insertions(+), 1554 deletions(-) delete mode 100644 src/components/admin/AdminTenantsClient.tsx delete mode 100644 src/components/dashboard/DashboardClient.tsx delete mode 100644 src/components/dashboard/InstanceStatus.tsx delete mode 100644 src/components/dashboard/UsageDisplay.tsx create mode 100644 src/components/dashboard/usage-display.tsx delete mode 100644 src/components/packages/PackageCard.tsx delete mode 100644 src/components/packages/WorkspaceEditor.tsx create mode 100644 src/components/packages/package-card.tsx create mode 100644 src/components/packages/package-list.tsx create mode 100644 src/components/packages/workspace-editor.tsx delete mode 100644 src/components/tenants/TenantDetailClient.tsx diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx index 56f0468..bf75d93 100644 --- a/src/app/[locale]/admin/page.tsx +++ b/src/app/[locale]/admin/page.tsx @@ -1,5 +1,106 @@ -import AdminTenantsClient from "@/components/admin/AdminTenantsClient"; +import { getSessionUser } from "@/lib/session"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { listTenants } from "@/lib/k8s"; +import { StatusBadge } from "@/components/ui/status-badge"; -export default function AdminPage() { - return ; +export default async function AdminPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + const t = await getTranslations("admin"); + + if (!user.isPlatform) { + return ( +
+

{t("noAccess")}

+
+ ); + } + + const tenants = await listTenants(); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+
+

+ {t("allTenants")} +

+ + {tenants.length} + +
+ + {tenants.length === 0 ? ( +
+

{t("noTenants")}

+
+ ) : ( +
+
+ + + + + + + + + + + + {tenants.map((tenant) => ( + + + + + + + + ))} + +
+ {t("name")} + + {t("displayName")} + + {t("phase")} + + {t("packages")} + + {t("created")} +
+ {tenant.metadata.name} + + {tenant.spec.displayName} + + + + {tenant.spec.packages?.join(", ") || "—"} + + {tenant.metadata.creationTimestamp + ? new Date( + tenant.metadata.creationTimestamp + ).toLocaleDateString() + : "—"} +
+
+
+ )} +
+
+ ); } diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index e3d3962..ac2f3c6 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -1,5 +1,240 @@ -import DashboardClient from "@/components/dashboard/DashboardClient"; +import { getSessionUser } from "@/lib/session"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { listTenants } from "@/lib/k8s"; +import { Card, CardHeader } from "@/components/ui/card"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { UsageDisplay } from "@/components/dashboard/usage-display"; +import Link from "next/link"; -export default function DashboardPage() { - return ; +export default async function DashboardPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + const t = await getTranslations("dashboard"); + const tAdmin = await getTranslations("admin"); + + const allTenants = await listTenants(); + + // Platform users see overview of all tenants + if (user.isPlatform) { + const phaseCount = allTenants.reduce>((acc, t) => { + const phase = t.status?.phase ?? "Pending"; + acc[phase] = (acc[phase] || 0) + 1; + return acc; + }, {}); + + return ( +
+
+

+ {t("title")} +

+

+ {t("welcome", { name: user.name || user.email })} +

+
+ + + {tAdmin("title")} + + + {/* Summary cards */} +
+ + {tAdmin("allTenants")} + + {allTenants.length} + + + {Object.entries(phaseCount).map(([phase, count]) => ( + + {phase} +
+ + {count} + + +
+
+ ))} +
+ + {/* Tenant table */} + {allTenants.length > 0 && ( +
+
+ + + + + + + + + + + {allTenants.map((tenant) => ( + + + + + + + + ))} + +
+ {tAdmin("name")} + + {tAdmin("phase")} + + {tAdmin("packages")} + + {tAdmin("created")} + +
+
+ {tenant.metadata.name} +
+ {tenant.spec.displayName && ( +
+ {tenant.spec.displayName} +
+ )} +
+ + + {tenant.spec.packages?.join(", ") || "—"} + + {tenant.metadata.creationTimestamp + ? new Date(tenant.metadata.creationTimestamp).toLocaleDateString() + : "—"} + + + {t("manage")} → + +
+
+
+ )} +
+ ); + } + + // Regular user: find their tenant + const myTenant = allTenants.find( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId + ); + + if (!myTenant) { + return ( +
+
+ + Session Debug + +
+    {JSON.stringify(user, null, 2)}
+  
+
+
+

+ {t("title")} +

+

+ {t("welcome", { name: user.name || user.email })} +

+
+ +
+
+
+
+

+ {t("noInstance")} +

+

+ {t("noInstanceDescription")} +

+
+
+ ); + } + + const tenantName = myTenant.metadata.name; + const teamId = myTenant.status?.litellmTeamId || tenantName; + + return ( +
+
+

+ {t("title")} +

+

+ {t("welcome", { name: user.name || user.email })} +

+
+ + {/* Instance status card */} +
+ + {t("instanceStatus")} +
+ + {myTenant.spec.agentName && ( + + {myTenant.spec.agentName} + + )} +
+ {myTenant.spec.packages && myTenant.spec.packages.length > 0 && ( +
+ {myTenant.spec.packages.map((pkg) => ( + + {pkg} + + ))} +
+ )} +
+
+ + {/* Usage */} +
+

+ {t("usage")} +

+ +
+ + {/* Link to tenant detail */} + + {t("manage")} + +
+ + Session Debug + +
+    {JSON.stringify(user, null, 2)}
+  
+
+
+ ); } diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx index c926199..8703053 100644 --- a/src/app/[locale]/tenants/[name]/page.tsx +++ b/src/app/[locale]/tenants/[name]/page.tsx @@ -1,5 +1,82 @@ -import TenantDetailClient from "@/components/tenants/TenantDetailClient"; +import { getSessionUser } from "@/lib/session"; +import { getTranslations } from "next-intl/server"; +import { redirect, notFound } from "next/navigation"; +import { getTenant } from "@/lib/k8s"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { UsageDisplay } from "@/components/dashboard/usage-display"; +import { PackageList } from "@/components/packages/package-list"; +import { WorkspaceEditor } from "@/components/packages/workspace-editor"; -export default function TenantDetailPage() { - return ; +export default async function TenantDetailPage({ + params, +}: { + params: Promise<{ name: string; locale: string }>; +}) { + const user = await getSessionUser(); + if (!user) redirect("/login"); + + const { name } = await params; + const t = await getTranslations("tenantDetail"); + + const tenant = await getTenant(name); + if (!tenant) notFound(); + console.log("tenant spec:", JSON.stringify(tenant.spec)); + + // Scope check + if ( + !user.isPlatform && + tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId + ) { + notFound(); + } + + const enabledPackages = tenant.spec.packages || []; + const workspaceFiles = tenant.spec.workspaceFiles || {}; + + return ( +
+ {/* Header */} +
+
+

+ {tenant.spec.displayName || name} +

+ +
+ {tenant.spec.agentName && ( +

+ {t("agent")}: {tenant.spec.agentName} +

+ )} +
+ + {/* Usage */} +
+

+ {t("usage")} +

+ +
+ + {/* Packages */} +
+

+ {t("packages")} +

+ +
+ + {/* Workspace files */} +
+

+ {t("workspaceFiles")} +

+ +
+
+ ); } diff --git a/src/app/api/packages/route.ts b/src/app/api/packages/route.ts index c74106c..67a1ea0 100644 --- a/src/app/api/packages/route.ts +++ b/src/app/api/packages/route.ts @@ -1,12 +1,10 @@ import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; +import { getSessionUser } from "@/lib/session"; import { PACKAGE_CATALOG } from "@/lib/packages"; export async function GET() { - const session = await auth(); - if (!session?.user) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - return NextResponse.json(PACKAGE_CATALOG); } diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts index 123341c..b3b8412 100644 --- a/src/app/api/tenants/[name]/route.ts +++ b/src/app/api/tenants/[name]/route.ts @@ -1,35 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; +import { getSessionUser } from "@/lib/session"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; -function isPlatformRole(roles: string[]): boolean { - return roles.some((r) => - ["platform_admin", "platform_operator"].includes(r) - ); -} - export async function GET( _req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { - const session = await auth(); - if (!session?.user) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } const { name } = await params; - const { orgId, roles } = session.user as any; try { const tenant = await getTenant(name); - if (!tenant) { + if (!tenant) return NextResponse.json({ error: "Not found" }, { status: 404 }); - } - // Scope check: non-platform users can only see their own org's tenants if ( - !isPlatformRole(roles || []) && - tenant.metadata?.labels?.["zitadel-org-id"] !== orgId + !user.isPlatform && + tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } @@ -37,7 +27,7 @@ export async function GET( return NextResponse.json(tenant); } catch (e: any) { return NextResponse.json( - { error: "K8s API error", detail: e.message }, + { error: e.message }, { status: e.statusCode || 500 } ); } @@ -47,35 +37,29 @@ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { - const session = await auth(); - if (!session?.user) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { name } = await params; - const { orgId, roles } = session.user as any; - const body = await req.json(); - const userRoles = roles || []; - - // Only owner or platform roles can patch - if (!isPlatformRole(userRoles) && !userRoles.includes("owner")) { + if (!user.isPlatform && !user.roles.includes("owner")) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + const { name } = await params; + const body = await req.json(); + try { - // Ownership check const existing = await getTenant(name); - if (!existing) { + if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); - } + if ( - !isPlatformRole(userRoles) && - existing.metadata?.labels?.["zitadel-org-id"] !== orgId + !user.isPlatform && + existing.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - // Build partial spec — only allow specific fields const specPatch: Record = {}; if (body.packages !== undefined) specPatch.packages = body.packages; if (body.workspaceFiles !== undefined) @@ -88,7 +72,7 @@ export async function PATCH( return NextResponse.json(updated); } catch (e: any) { return NextResponse.json( - { error: "Patch failed", detail: e.message }, + { error: e.message }, { status: e.statusCode || 500 } ); } diff --git a/src/app/api/tenants/[name]/secrets/route.ts b/src/app/api/tenants/[name]/secrets/route.ts index 7de08e2..6b13d98 100644 --- a/src/app/api/tenants/[name]/secrets/route.ts +++ b/src/app/api/tenants/[name]/secrets/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; +import { getSessionUser } from "@/lib/session"; import { getTenant } from "@/lib/k8s"; import { writePackageSecrets } from "@/lib/openbao"; import { getPackageDef } from "@/lib/packages"; @@ -8,23 +8,15 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { - const session = await auth(); - if (!session?.user) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { name } = await params; - const { orgId, roles } = session.user as any; - const userRoles = roles || []; - - const isPlatform = userRoles.some((r: string) => - ["platform_admin", "platform_operator"].includes(r) - ); - - if (!isPlatform && !userRoles.includes("owner")) { + if (!user.isPlatform && !user.roles.includes("owner")) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + const { name } = await params; const body = await req.json(); const { packageId, secrets } = body as { packageId: string; @@ -38,63 +30,43 @@ export async function POST( ); } - // Validate package exists and requires secrets const pkgDef = getPackageDef(packageId); - if (!pkgDef) { - return NextResponse.json( - { error: "Unknown package" }, - { status: 400 } - ); - } - if (!pkgDef.requiresSecrets) { + if (!pkgDef) + return NextResponse.json({ error: "Unknown package" }, { status: 400 }); + if (!pkgDef.requiresSecrets) return NextResponse.json( { error: "Package does not require secrets" }, { status: 400 } ); - } - // Verify all required secret keys are present const requiredKeys = (pkgDef.secrets || []).map((s) => s.key); - const missingKeys = requiredKeys.filter((k) => !secrets[k]?.trim()); - if (missingKeys.length > 0) { + const missing = requiredKeys.filter((k) => !secrets[k]?.trim()); + if (missing.length > 0) { return NextResponse.json( - { error: `Missing required secrets: ${missingKeys.join(", ")}` }, + { error: `Missing: ${missing.join(", ")}` }, { status: 400 } ); } - // Verify tenant ownership try { const tenant = await getTenant(name); - if (!tenant) { - return NextResponse.json( - { error: "Tenant not found" }, - { status: 404 } - ); - } + if (!tenant) + return NextResponse.json({ error: "Not found" }, { status: 404 }); + if ( - !isPlatform && - tenant.metadata?.labels?.["zitadel-org-id"] !== orgId + !user.isPlatform && + tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - } catch (e: any) { - return NextResponse.json( - { error: "Tenant lookup failed" }, - { status: e.statusCode || 500 } - ); - } - // Write to OpenBao - try { await writePackageSecrets(name, packageId, secrets); - } catch (err: any) { - console.error("OpenBao write error:", err.message); + return NextResponse.json({ ok: true }); + } catch (e: any) { + console.error("Secret write error:", e.message); return NextResponse.json( { error: "Failed to store secrets" }, { status: 500 } ); } - - return NextResponse.json({ ok: true }); } diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts index e73540c..bca1c9a 100644 --- a/src/app/api/usage/route.ts +++ b/src/app/api/usage/route.ts @@ -1,107 +1,84 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; -import { getTeamInfo, getTeamSpendLogs } from "@/lib/litellm"; - -// Pricing constants (CHF) -const INPUT_RATE = 3; // CHF per MTok -const OUTPUT_RATE = 15; // CHF per MTok +import { getSessionUser } from "@/lib/session"; +import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; export async function GET(req: NextRequest) { - const session = await auth(); - if (!session?.user) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { orgId } = session.user as any; - if (!orgId) { - return NextResponse.json( - { error: "No org context" }, - { status: 400 } - ); - } + const teamId = req.nextUrl.searchParams.get("teamId"); + if (!teamId) + return NextResponse.json({ error: "teamId required" }, { status: 400 }); - // The LiteLLM team_id maps to the tenant name, which is derived from orgId - // Convention: team_id = "pieced-{orgId}" or looked up from the tenant CR - const searchParams = req.nextUrl.searchParams; - const teamId = searchParams.get("teamId"); + // Month param: YYYY-MM, defaults to current month + const now = new Date(); + const monthParam = req.nextUrl.searchParams.get("month") + || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - if (!teamId) { - return NextResponse.json( - { error: "teamId query param required" }, - { status: 400 } - ); - } + const [year, month] = monthParam.split("-").map(Number); + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0); // last day of month + + const startStr = startDate.toISOString().split("T")[0]; + const endStr = endDate.toISOString().split("T")[0]; try { - // Current period info const teamInfo = await getTeamInfo(teamId); - // Historical spend logs (last 30 days) - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 30); + // Fetch all pages + const allRequests: any[] = []; + let page = 1; + while (true) { + const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100); + allRequests.push(...(result.data || [])); + if (page >= (result.total_pages || 1)) break; + page++; + } - const spendLogs = await getTeamSpendLogs( - teamId, - startDate.toISOString().split("T")[0], - endDate.toISOString().split("T")[0] - ); + // Aggregate by day + const byDay: Record = {}; + for (const r of allRequests) { + const day = (r.startTime || r.endTime || "").slice(0, 10); + if (!day) continue; + if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; + byDay[day].inputTokens += r.prompt_tokens || 0; + byDay[day].outputTokens += r.completion_tokens || 0; + byDay[day].spend += r.spend || 0; + } - // Calculate CHF costs from token counts - const dailyUsage = (spendLogs || []).map((day: any) => ({ - date: day.date || day.day, - inputTokens: day.prompt_tokens || 0, - outputTokens: day.completion_tokens || 0, - inputCostCHF: - ((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE, - outputCostCHF: - ((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE, - totalCostCHF: - ((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE + - ((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE, - })); + const dailyUsage = Object.entries(byDay) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]) => ({ date, ...d })); - // Totals for current period - const totalInputTokens = dailyUsage.reduce( - (s: number, d: any) => s + d.inputTokens, - 0 - ); - const totalOutputTokens = dailyUsage.reduce( - (s: number, d: any) => s + d.outputTokens, - 0 - ); - const totalCostCHF = dailyUsage.reduce( - (s: number, d: any) => s + d.totalCostCHF, - 0 - ); + const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0); + const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0); + const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0); return NextResponse.json({ teamId, + month: monthParam, currentPeriod: { - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE, - outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE, - totalCostCHF, + inputTokens: totalInput, + outputTokens: totalOutput, + totalSpend, + requestCount: allRequests.length, }, budget: { - maxBudget: teamInfo?.max_budget ?? null, - spend: teamInfo?.spend ?? 0, - remaining: teamInfo?.max_budget - ? teamInfo.max_budget - (teamInfo.spend ?? 0) + maxBudget: teamInfo?.team_info?.max_budget ?? null, + spend: teamInfo?.team_info?.spend ?? 0, + remaining: teamInfo?.team_info?.max_budget + ? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0) : null, }, rateLimits: { - rpm: teamInfo?.rpm_limit ?? null, - tpm: teamInfo?.tpm_limit ?? null, + rpm: teamInfo?.team_info?.rpm_limit ?? null, + tpm: teamInfo?.team_info?.tpm_limit ?? null, }, dailyUsage, }); - } catch (err: any) { - console.error("Usage fetch error:", err.message); - return NextResponse.json( - { error: "Failed to fetch usage data" }, - { status: 500 } - ); + } catch (e: any) { + console.error("Usage fetch error:", e.message); + return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/src/components/admin/AdminTenantsClient.tsx b/src/components/admin/AdminTenantsClient.tsx deleted file mode 100644 index b0f279d..0000000 --- a/src/components/admin/AdminTenantsClient.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { useEffect, useState, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { PhaseBadge } from "@/components/dashboard/InstanceStatus"; - -interface TenantRow { - name: string; - displayName?: string; - phase: string; - packages: string[]; - agentName?: string; - created: string; - orgId?: string; -} - -type SortKey = "name" | "phase" | "packages" | "created"; -type SortDir = "asc" | "desc"; - -export default function AdminTenantsClient() { - const t = useTranslations("admin"); - const router = useRouter(); - const [tenants, setTenants] = useState([]); - const [loading, setLoading] = useState(true); - const [sortKey, setSortKey] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); - - useEffect(() => { - fetch("/api/tenants") - .then((r) => r.json()) - .then((data) => { - const items = data.items || data || []; - setTenants( - items.map((t: any) => ({ - name: t.metadata.name, - displayName: t.spec?.displayName, - phase: t.status?.phase || "Pending", - packages: t.spec?.packages || [], - agentName: t.spec?.agentName, - created: t.metadata.creationTimestamp, - orgId: t.metadata?.labels?.["zitadel-org-id"], - })) - ); - }) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); - - function toggleSort(key: SortKey) { - if (sortKey === key) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortKey(key); - setSortDir("asc"); - } - } - - const sorted = useMemo(() => { - return [...tenants].sort((a, b) => { - let cmp = 0; - switch (sortKey) { - case "name": - cmp = (a.displayName || a.name).localeCompare( - b.displayName || b.name - ); - break; - case "phase": - cmp = a.phase.localeCompare(b.phase); - break; - case "packages": - cmp = a.packages.length - b.packages.length; - break; - case "created": - cmp = - new Date(a.created).getTime() - new Date(b.created).getTime(); - break; - } - return sortDir === "asc" ? cmp : -cmp; - }); - }, [tenants, sortKey, sortDir]); - - const SortHeader = ({ - label, - field, - }: { - label: string; - field: SortKey; - }) => ( - toggleSort(field)} - className="cursor-pointer select-none px-3 py-2 text-left text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors" - > - {label} - {sortKey === field && ( - - {sortDir === "asc" ? "↑" : "↓"} - - )} - - ); - - if (loading) { - return ( -
-
-
-
- ); - } - - return ( -
-
-

- {t("tenants")} -

- - {tenants.length} {t("total")} - -
- -
- - - - - - - - - - - {sorted.map((row) => ( - - - - - - - - ))} - -
-
-
- {row.displayName || row.name} -
- {row.agentName && ( -
- {row.agentName} -
- )} -
- - -
- {row.packages.length === 0 ? ( - - ) : ( - row.packages.map((p) => ( - - {p} - - )) - )} -
-
- {new Date(row.created).toLocaleDateString()} - - -
- - {sorted.length === 0 && ( -
- {t("noTenants")} -
- )} -
-
- ); -} diff --git a/src/components/dashboard/DashboardClient.tsx b/src/components/dashboard/DashboardClient.tsx deleted file mode 100644 index 84fdd4c..0000000 --- a/src/components/dashboard/DashboardClient.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"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(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 ( -
-
-
-
- ); - } - - // No tenant yet — show CTA - if (!tenant) { - return ( -
-
- - - -
-

- {t("noInstance")} -

-

- {t("noInstanceDescription")} -

- -
- ); - } - - const tenantName = tenant.metadata.name; - const teamId = tenant.spec.litellmTeamId || tenantName; - - return ( -
-

- {t("title")} -

- - - -
-

- {t("usageTitle")} -

- -
- - {/* Quick link to tenant settings */} - -
- ); -} diff --git a/src/components/dashboard/InstanceStatus.tsx b/src/components/dashboard/InstanceStatus.tsx deleted file mode 100644 index 7a5dd4a..0000000 --- a/src/components/dashboard/InstanceStatus.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"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 = { - 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 ( - - - {phase} - - ); -} - -export default function InstanceStatus({ - phase, - conditions, - agentName, - packages, - createdAt, - compact = false, -}: Props) { - const t = useTranslations("dashboard"); - - if (compact) { - return ; - } - - const errorCondition = conditions?.find( - (c) => c.status === "False" && c.message - ); - - return ( -
-
-

- {t("instanceStatus")} -

- -
- - {agentName && ( -
- {t("agentName")}:{" "} - {agentName} -
- )} - - {packages && packages.length > 0 && ( -
- {packages.map((pkg) => ( - - {pkg} - - ))} -
- )} - - {createdAt && ( -
- {t("created")}: {new Date(createdAt).toLocaleDateString()} -
- )} - - {errorCondition && ( -
- {errorCondition.message} -
- )} -
- ); -} diff --git a/src/components/dashboard/UsageDisplay.tsx b/src/components/dashboard/UsageDisplay.tsx deleted file mode 100644 index 71fc65b..0000000 --- a/src/components/dashboard/UsageDisplay.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"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 ( -
- - {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 ( - - - {d.date}: {formatTokens(d.inputTokens)} in / {formatTokens(d.outputTokens)} out - - {/* Output tokens (bottom) */} - - {/* Input tokens (top) */} - - {/* Date label (every 7th) */} - {i % 7 === 0 && ( - - {d.date.slice(5)} - - )} - - ); - })} - -
- - Input - - - Output - -
-
- ); -} - -export default function UsageDisplay({ teamId }: { teamId: string | null }) { - const t = useTranslations("dashboard"); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
-
-
-
- ); - } - - if (error || !data) { - return ( -
-

- {error ? t("usageError") : t("noUsageData")} -

-
- ); - } - - const { currentPeriod, budget, rateLimits, dailyUsage } = data; - - return ( -
- {/* Spend summary cards */} -
- - - - {budget.remaining !== null ? ( - - ) : ( - - )} -
- - {/* Rate limits */} - {(rateLimits.rpm || rateLimits.tpm) && ( -
- {rateLimits.rpm && RPM limit: {rateLimits.rpm}} - {rateLimits.tpm && TPM limit: {formatTokens(rateLimits.tpm)}} -
- )} - - {/* Chart */} -
-

- {t("last30Days")} -

- -
-
- ); -} - -function StatCard({ - label, - value, - sub, - accent, -}: { - label: string; - value: string; - sub?: string; - accent?: boolean; -}) { - return ( -
-
{label}
-
- {value} -
- {sub &&
{sub}
} -
- ); -} diff --git a/src/components/dashboard/usage-display.tsx b/src/components/dashboard/usage-display.tsx new file mode 100644 index 0000000..155035b --- /dev/null +++ b/src/components/dashboard/usage-display.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useEffect, useState, useCallback } from "react"; + +interface DailyUsage { + date: string; + inputTokens: number; + outputTokens: number; + spend: number; +} + +interface UsageData { + month: string; + currentPeriod: { + inputTokens: number; + outputTokens: number; + totalSpend: number; + requestCount: number; + }; + budget: { maxBudget: number | null; spend: number; remaining: number | null }; + rateLimits: { rpm: number | null; tpm: number | null }; + dailyUsage: DailyUsage[]; +} + +function fmt(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 usd(n: number): string { + return `$${n.toFixed(4)}`; +} + +function getCurrentMonth(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +function shiftMonth(month: string, delta: number): string { + const [y, m] = month.split("-").map(Number); + const d = new Date(y, m - 1 + delta, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function formatMonth(month: string, locale: string): string { + const [y, m] = month.split("-").map(Number); + return new Date(y, m - 1).toLocaleDateString(locale, { year: "numeric", month: "long" }); +} + +function UsageChart({ data }: { data: DailyUsage[] }) { + if (!data.length) return null; + const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1); + const barW = Math.max(4, Math.floor(600 / data.length) - 2); + const h = 120; + + return ( +
+ + {data.map((d, i) => { + const total = d.inputTokens + d.outputTokens; + const totalH = (total / maxTokens) * h; + const inputH = (d.inputTokens / maxTokens) * h; + const x = i * (barW + 2); + return ( + + {d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {usd(d.spend)} + + + {i % 7 === 0 && ( + {d.date.slice(8)} + )} + + ); + })} + +
+ + Input + + + Output + +
+
+ ); +} + +export function UsageDisplay({ teamId }: { teamId: string | null }) { + const t = useTranslations("usage"); + const [month, setMonth] = useState(getCurrentMonth); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isCurrentMonth = month === getCurrentMonth(); + + const fetchUsage = useCallback(() => { + if (!teamId) { setLoading(false); return; } + setLoading(true); + setError(null); + fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}&month=${month}`) + .then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); }) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [teamId, month]); + + useEffect(() => { fetchUsage(); }, [fetchUsage]); + + if (!teamId) return null; + + return ( +
+ {/* Month selector */} +
+ + + {formatMonth(month, "en")} + + +
+ + {loading ? ( +
+
+
+
+ ) : error || !data ? ( +
+

{error || t("noData")}

+
+ ) : ( + <> +
+ + + + +
+ +
+
+

+ {t("dailyBreakdown")} +

+ + {data.currentPeriod.requestCount} {t("requests")} + +
+ +
+ + )} +
+ ); +} + +function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} \ No newline at end of file diff --git a/src/components/packages/PackageCard.tsx b/src/components/packages/PackageCard.tsx deleted file mode 100644 index 5de5ee8..0000000 --- a/src/components/packages/PackageCard.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import type { PackageDef } from "@/lib/packages"; - -interface Props { - pkg: PackageDef; - enabled: boolean; - status?: "pending" | "active" | "error"; - tenantName: string; - onToggle: ( - packageId: string, - enable: boolean, - secrets?: Record - ) => Promise; -} - -export default function PackageCard({ - pkg, - enabled, - status, - tenantName, - onToggle, -}: Props) { - const t = useTranslations(); - const [showModal, setShowModal] = useState(false); - const [secrets, setSecrets] = useState>({}); - const [disclaimerAccepted, setDisclaimerAccepted] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const statusStyles = { - pending: "text-amber-400", - active: "text-emerald-400", - error: "text-red-400", - }; - - async function handleEnable() { - if (pkg.requiresSecrets) { - setShowModal(true); - setSecrets({}); - setDisclaimerAccepted(false); - setError(null); - return; - } - setSaving(true); - try { - await onToggle(pkg.id, true); - } finally { - setSaving(false); - } - } - - async function handleDisable() { - setSaving(true); - try { - await onToggle(pkg.id, false); - } finally { - setSaving(false); - } - } - - async function handleSubmitSecrets() { - if (!disclaimerAccepted && pkg.disclaimerKey) return; - - const requiredKeys = (pkg.secrets || []).map((s) => s.key); - const missing = requiredKeys.filter((k) => !secrets[k]?.trim()); - if (missing.length > 0) { - setError(t("packages.missingFields")); - return; - } - - setSaving(true); - setError(null); - try { - // Write secrets first - const secretRes = await fetch( - `/api/tenants/${tenantName}/secrets`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ packageId: pkg.id, secrets }), - } - ); - if (!secretRes.ok) { - const err = await secretRes.json(); - throw new Error(err.error || "Failed to store secrets"); - } - - // Then enable the package - await onToggle(pkg.id, true); - setShowModal(false); - } catch (err: any) { - setError(err.message); - } finally { - setSaving(false); - } - } - - return ( - <> -
-
-
-
- - {pkg.name} - - - {pkg.category} - -
-

- {t(pkg.descriptionKey)} -

-
- - {enabled && status && ( - - {t(`packages.status.${status}`)} - - )} -
- -
- {pkg.requiresSecrets && ( - - {t("packages.requiresApiKey")} - - )} - -
-
- - {/* Secret input modal */} - {showModal && ( -
-
-

- {t("packages.configure")} {pkg.name} -

- - {pkg.customerInstructionsKey && ( -
- {t(pkg.customerInstructionsKey)} -
- )} - -
- {(pkg.secrets || []).map((secret) => ( - - ))} -
- - {pkg.disclaimerKey && ( - - )} - - {error && ( -

{error}

- )} - -
- - -
-
-
- )} - - ); -} diff --git a/src/components/packages/WorkspaceEditor.tsx b/src/components/packages/WorkspaceEditor.tsx deleted file mode 100644 index 656b57a..0000000 --- a/src/components/packages/WorkspaceEditor.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { useState } from "react"; - -interface WorkspaceFile { - name: string; - content: string; -} - -interface Props { - tenantName: string; - files: WorkspaceFile[]; - onSave: (files: WorkspaceFile[]) => Promise; -} - -const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const; - -export default function WorkspaceEditor({ tenantName, files, onSave }: Props) { - const t = useTranslations("workspace"); - const [activeTab, setActiveTab] = useState("SOUL.md"); - const [localFiles, setLocalFiles] = useState(files); - const [saving, setSaving] = useState(false); - const [dirty, setDirty] = useState(false); - - const activeFile = localFiles.find((f) => f.name === activeTab); - - function handleChange(content: string) { - setLocalFiles((prev) => - prev.map((f) => (f.name === activeTab ? { ...f, content } : f)) - ); - setDirty(true); - } - - async function handleSave() { - setSaving(true); - try { - await onSave(localFiles); - setDirty(false); - } finally { - setSaving(false); - } - } - - return ( -
-
-
- {FILE_TABS.map((tab) => ( - - ))} -
- -
- -