Working version 6.2

This commit is contained in:
2026-04-10 14:44:03 +02:00
parent d526c1ff4a
commit f20d5f09ae
28 changed files with 1231 additions and 1554 deletions

View File

@@ -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<TenantRow[]>([]);
const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("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;
}) => (
<th
onClick={() => 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 && (
<span className="ml-1 text-teal-400">
{sortDir === "asc" ? "↑" : "↓"}
</span>
)}
</th>
);
if (loading) {
return (
<div className="animate-pulse">
<div className="h-8 w-40 bg-zinc-800 rounded mb-4" />
<div className="h-64 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-zinc-100">
{t("tenants")}
</h1>
<span className="text-xs text-zinc-500">
{tenants.length} {t("total")}
</span>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-zinc-800">
<tr>
<SortHeader label={t("name")} field="name" />
<SortHeader label={t("phase")} field="phase" />
<SortHeader label={t("packages")} field="packages" />
<SortHeader label={t("created")} field="created" />
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/50">
{sorted.map((row) => (
<tr
key={row.name}
className="hover:bg-zinc-800/30 transition-colors"
>
<td className="px-3 py-2.5">
<div className="text-zinc-200">
{row.displayName || row.name}
</div>
{row.agentName && (
<div className="text-[10px] text-zinc-600">
{row.agentName}
</div>
)}
</td>
<td className="px-3 py-2.5">
<PhaseBadge phase={row.phase} />
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{row.packages.length === 0 ? (
<span className="text-xs text-zinc-600"></span>
) : (
row.packages.map((p) => (
<span
key={p}
className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400"
>
{p}
</span>
))
)}
</div>
</td>
<td className="px-3 py-2.5 text-xs text-zinc-500">
{new Date(row.created).toLocaleDateString()}
</td>
<td className="px-3 py-2.5 text-right">
<button
onClick={() => router.push(`/tenants/${row.name}`)}
className="text-xs text-teal-400 hover:text-teal-300"
>
{t("manage")}
</button>
</td>
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && (
<div className="py-12 text-center text-sm text-zinc-600">
{t("noTenants")}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<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>
);
}

View File

@@ -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<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>
);
}

View File

@@ -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 (
<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>
);
}

View File

@@ -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 (
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
className="w-full h-36"
preserveAspectRatio="xMinYMid meet"
>
{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 (
<g key={d.date}>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {usd(d.spend)}</title>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && (
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
)}
</g>
);
})}
</svg>
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
</span>
</div>
</div>
);
}
export function UsageDisplay({ teamId }: { teamId: string | null }) {
const t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
{/* Month selector */}
<div className="flex items-center justify-between">
<button
onClick={() => setMonth((m) => shiftMonth(m, -1))}
className="rounded-md px-2 py-1 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
>
</button>
<span className="font-display text-sm font-medium text-text-primary">
{formatMonth(month, "en")}
</span>
<button
onClick={() => setMonth((m) => shiftMonth(m, 1))}
disabled={isCurrentMonth}
className="rounded-md px-2 py-1 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
{loading ? (
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
<div className="h-4 w-32 bg-surface-3 rounded mb-4" />
<div className="h-36 bg-surface-2 rounded" />
</div>
) : error || !data ? (
<div className="bg-surface-1 border border-border rounded-xl p-6">
<p className="text-sm text-text-secondary">{error || t("noData")}</p>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
<StatCard
label={t("budget")}
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
/>
</div>
<div className="bg-surface-1 border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("dailyBreakdown")}
</h3>
<span className="text-xs text-text-muted tabular-nums">
{data.currentPeriod.requestCount} {t("requests")}
</span>
</div>
<UsageChart data={data.dailyUsage} />
</div>
</>
)}
</div>
);
}
function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="bg-surface-1 border border-border rounded-xl p-4">
<div className="text-xs text-text-muted mb-1">{label}</div>
<div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div>
</div>
);
}

View File

@@ -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<string, string>
) => Promise<void>;
}
export default function PackageCard({
pkg,
enabled,
status,
tenantName,
onToggle,
}: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200">
{pkg.name}
</span>
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500 uppercase tracking-wide">
{pkg.category}
</span>
</div>
<p className="text-xs text-zinc-500 mt-1">
{t(pkg.descriptionKey)}
</p>
</div>
{enabled && status && (
<span className={`text-xs ${statusStyles[status] || ""}`}>
{t(`packages.status.${status}`)}
</span>
)}
</div>
<div className="flex items-center justify-between mt-auto pt-2 border-t border-zinc-800/50">
{pkg.requiresSecrets && (
<span className="text-[10px] text-zinc-600">
{t("packages.requiresApiKey")}
</span>
)}
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded px-3 py-1 text-xs font-medium transition-colors ${
enabled
? "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "bg-teal-600 text-white hover:bg-teal-500"
} disabled:opacity-50`}
>
{saving
? "..."
: enabled
? t("packages.disable")
: t("packages.enable")}
</button>
</div>
</div>
{/* Secret input modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-6 space-y-4">
<h3 className="text-base font-medium text-zinc-100">
{t("packages.configure")} {pkg.name}
</h3>
{pkg.customerInstructionsKey && (
<div className="rounded bg-zinc-800/50 border border-zinc-700/50 p-3 text-xs text-zinc-400 leading-relaxed">
{t(pkg.customerInstructionsKey)}
</div>
)}
<div className="space-y-3">
{(pkg.secrets || []).map((secret) => (
<label key={secret.key} className="block">
<span className="text-xs text-zinc-400 mb-1 block">
{t(secret.labelKey)}
</span>
<input
type="password"
placeholder={t(secret.placeholderKey)}
value={secrets[secret.key] || ""}
onChange={(e) =>
setSecrets((prev) => ({
...prev,
[secret.key]: e.target.value,
}))
}
className="w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-teal-600 focus:outline-none"
/>
</label>
))}
</div>
{pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-zinc-400">
<input
type="checkbox"
checked={disclaimerAccepted}
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 accent-teal-500"
/>
<span>{t(pkg.disclaimerKey)}</span>
</label>
)}
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowModal(false)}
className="rounded px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
{t("common.cancel")}
</button>
<button
onClick={handleSubmitSecrets}
disabled={
saving || (!!pkg.disclaimerKey && !disclaimerAccepted)
}
className="rounded bg-teal-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-50"
>
{saving ? "..." : t("packages.enableAndSave")}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -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<void>;
}
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<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<WorkspaceFile[]>(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 (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50">
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-2">
<div className="flex gap-1">
{FILE_TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`rounded px-2.5 py-1 text-xs font-mono transition-colors ${
activeTab === tab
? "bg-zinc-800 text-teal-400"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
{tab}
</button>
))}
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded bg-teal-600 px-3 py-1 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-40"
>
{saving ? "..." : t("save")}
</button>
</div>
<textarea
value={activeFile?.content || ""}
onChange={(e) => handleChange(e.target.value)}
spellCheck={false}
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-zinc-300 placeholder:text-zinc-700 focus:outline-none"
placeholder={t("placeholder", { file: activeTab })}
/>
<div className="border-t border-zinc-800 px-4 py-2">
<p className="text-[10px] text-zinc-600 leading-relaxed">
{t("seedingNote")}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"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;
onToggled: () => void;
}
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleEnable() {
if (pkg.requiresSecrets) {
setShowModal(true);
setSecrets({});
setAccepted(false);
setError(null);
return;
}
await togglePackage(true);
}
async function togglePackage(enable: boolean) {
setSaving(true);
try {
const res = await fetch(`/api/tenants/${tenantName}`);
const tenant = await res.json();
const current: string[] = tenant.spec?.packages || [];
const next = enable
? [...current, pkg.id]
: current.filter((p: string) => p !== pkg.id);
const patchRes = await fetch(`/api/tenants/${tenantName}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packages: next }),
});
if (!patchRes.ok) throw new Error("Failed to update packages");
onToggled();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function handleSubmitSecrets() {
if (pkg.disclaimerKey && !accepted) return;
const required = (pkg.secrets || []).map((s) => s.key);
const missing = required.filter((k) => !secrets[k]?.trim());
if (missing.length) { setError(t("packages.missingFields")); return; }
setSaving(true);
setError(null);
try {
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");
}
await togglePackage(true);
setShowModal(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
const statusColors: Record<string, string> = {
pending: "text-warning",
active: "text-success",
error: "text-error",
};
return (
<>
<div className="bg-surface-1 border border-border rounded-xl p-5 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">{pkg.name}</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted bg-surface-3 px-1.5 py-0.5 rounded">
{pkg.category}
</span>
</div>
<p className="text-xs text-text-secondary mt-1">{t(pkg.descriptionKey)}</p>
</div>
{enabled && status && (
<span className={`text-xs font-medium ${statusColors[status] || ""}`}>
{t(`packages.status.${status}`)}
</span>
)}
</div>
<div className="flex items-center justify-between mt-auto pt-3 border-t border-border">
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
</div>
</div>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
<h3 className="font-display text-base font-semibold text-text-primary">
{t("packages.configure")} {pkg.name}
</h3>
{pkg.instructionsKey && (
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
{t(pkg.instructionsKey)}
</div>
)}
<div className="space-y-3">
{(pkg.secrets || []).map((s) => (
<label key={s.key} className="block">
<span className="text-xs text-text-secondary mb-1 block">{t(s.labelKey)}</span>
<input
type="password"
placeholder={t(s.placeholderKey)}
value={secrets[s.key] || ""}
onChange={(e) => setSecrets((p) => ({ ...p, [s.key]: e.target.value }))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
/>
</label>
))}
</div>
{pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-text-secondary">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span>{t(pkg.disclaimerKey)}</span>
</label>
)}
{error && <p className="text-xs text-error">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowModal(false)}
className="rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary cursor-pointer"
>
{t("common.cancel")}
</button>
<button
onClick={handleSubmitSecrets}
disabled={saving || (!!pkg.disclaimerKey && !accepted)}
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
>
{saving ? "…" : t("packages.enableAndSave")}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { PACKAGE_CATALOG } from "@/lib/packages";
import { PackageCard } from "./package-card";
import type { PiecedTenantStatus } from "@/types";
interface Props {
tenantName: string;
enabledPackages: string[];
conditions?: PiecedTenantStatus["conditions"];
}
export function PackageList({ tenantName, enabledPackages, conditions }: Props) {
const router = useRouter();
function getStatus(pkgId: string): "pending" | "active" | "error" | undefined {
if (!conditions) return enabledPackages.includes(pkgId) ? "pending" : undefined;
const cond = conditions.find((c) => c.type === `Package/${pkgId}`);
if (!cond) return enabledPackages.includes(pkgId) ? "pending" : undefined;
if (cond.status === "True") return "active";
if (cond.status === "False") return "error";
return "pending";
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PACKAGE_CATALOG.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
enabled={enabledPackages.includes(pkg.id)}
status={enabledPackages.includes(pkg.id) ? getStatus(pkg.id) : undefined}
tenantName={tenantName}
onToggled={() => router.refresh()}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props {
tenantName: string;
files: Record<string, string>;
}
export function WorkspaceEditor({ tenantName, files }: Props) {
const t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleChange(content: string) {
setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true);
}
async function handleSave() {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/tenants/${tenantName}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceFiles: localFiles }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Save failed");
}
setDirty(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex gap-1">
{FILE_TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`rounded-md px-2.5 py-1 text-xs font-mono transition-colors cursor-pointer ${
activeTab === tab
? "bg-surface-3 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{tab}
</button>
))}
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
</div>
<textarea
value={localFiles[activeTab] || ""}
onChange={(e) => handleChange(e.target.value)}
spellCheck={false}
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none"
placeholder={t("placeholder", { file: activeTab })}
/>
<div className="border-t border-border px-4 py-2 flex items-center justify-between">
<p className="text-[10px] text-text-muted leading-relaxed">{t("seedingNote")}</p>
{error && <p className="text-[10px] text-error">{error}</p>}
</div>
</div>
);
}

View File

@@ -1,196 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { PhaseBadge } from "@/components/dashboard/InstanceStatus";
import PackageCard from "@/components/packages/PackageCard";
import WorkspaceEditor from "@/components/packages/WorkspaceEditor";
interface TenantCR {
metadata: {
name: string;
creationTimestamp: string;
resourceVersion: string;
};
spec: {
agentName?: string;
displayName?: string;
packages?: string[];
workspaceFiles?: { name: string; content: string }[];
litellmTeamId?: string;
};
status?: {
phase: string;
conditions?: {
type: string;
status: string;
message?: string;
reason?: string;
}[];
};
}
export default function TenantDetailClient() {
const t = useTranslations("tenantDetail");
const { name } = useParams<{ name: string }>();
const [tenant, setTenant] = useState<TenantCR | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchTenant = useCallback(async () => {
try {
const res = await fetch(`/api/tenants/${name}`);
if (!res.ok) throw new Error(`${res.status}`);
setTenant(await res.json());
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchTenant();
}, [fetchTenant]);
async function handlePackageToggle(
packageId: string,
enable: boolean
) {
if (!tenant) return;
const currentPackages = tenant.spec.packages || [];
const newPackages = enable
? [...currentPackages, packageId]
: currentPackages.filter((p) => p !== packageId);
const res = await fetch(`/api/tenants/${name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packages: newPackages }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to update packages");
}
// Refetch tenant state
await fetchTenant();
}
async function handleWorkspaceSave(
files: { name: string; content: string }[]
) {
const res = await fetch(`/api/tenants/${name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceFiles: files }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to update workspace files");
}
await fetchTenant();
}
function getPackageStatus(
pkgId: string
): "pending" | "active" | "error" | undefined {
if (!tenant?.status?.conditions) return undefined;
const cond = tenant.status.conditions.find(
(c) => c.type === `Package/${pkgId}`
);
if (!cond) return "pending";
if (cond.status === "True") return "active";
if (cond.status === "False") return "error";
return "pending";
}
if (loading) {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 w-48 bg-zinc-800 rounded" />
<div className="h-40 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
))}
</div>
</div>
);
}
if (error || !tenant) {
return (
<div className="rounded-lg border border-red-900/30 bg-red-950/20 p-4 text-sm text-red-400">
{error || t("notFound")}
</div>
);
}
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || [
{ name: "SOUL.md", content: "" },
{ name: "AGENTS.md", content: "" },
{ name: "TOOLS.md", content: "" },
];
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">
{tenant.spec.displayName || name}
</h1>
{tenant.spec.agentName && (
<p className="text-sm text-zinc-500 mt-0.5">
{t("agent")}: {tenant.spec.agentName}
</p>
)}
</div>
<PhaseBadge phase={tenant.status?.phase || "Pending"} />
</div>
{/* Packages */}
<section>
<h2 className="text-sm font-medium text-zinc-300 mb-3">
{t("packages")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{PACKAGE_CATALOG.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
enabled={enabledPackages.includes(pkg.id)}
status={
enabledPackages.includes(pkg.id)
? getPackageStatus(pkg.id)
: undefined
}
tenantName={name}
onToggle={handlePackageToggle}
/>
))}
</div>
</section>
{/* Workspace files */}
<section>
<h2 className="text-sm font-medium text-zinc-300 mb-3">
{t("workspaceFiles")}
</h2>
<WorkspaceEditor
tenantName={name}
files={workspaceFiles}
onSave={handleWorkspaceSave}
/>
</section>
</div>
);
}