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,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() { export default async function AdminPage() {
return <AdminTenantsClient />; const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("admin");
if (!user.isPlatform) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-error text-sm">{t("noAccess")}</p>
</div>
);
}
const tenants = await listTenants();
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
</div>
<div className="animate-in animate-in-delay-1">
<div className="flex items-baseline gap-3 mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("allTenants")}
</h2>
<span className="font-mono text-xs text-text-muted tabular-nums">
{tenants.length}
</span>
</div>
{tenants.length === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("name")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("displayName")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("phase")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("packages")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("created")}
</th>
</tr>
</thead>
<tbody>
{tenants.map((tenant) => (
<tr
key={tenant.metadata.name}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
>
<td className="px-5 py-3 font-mono text-xs text-accent">
{tenant.metadata.name}
</td>
<td className="px-5 py-3 text-text-primary">
{tenant.spec.displayName}
</td>
<td className="px-5 py-3">
<StatusBadge
phase={tenant.status?.phase ?? "Pending"}
/>
</td>
<td className="px-5 py-3 text-xs text-text-secondary font-mono">
{tenant.spec.packages?.join(", ") || "—"}
</td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp
? new Date(
tenant.metadata.creationTimestamp
).toLocaleDateString()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
} }

View File

@@ -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() { export default async function DashboardPage() {
return <DashboardClient />; 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<Record<string, number>>((acc, t) => {
const phase = t.status?.phase ?? "Pending";
acc[phase] = (acc[phase] || 0) + 1;
return acc;
}, {});
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-1.5 mb-6 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-1"
>
<span></span> {tAdmin("title")}
</Link>
{/* Summary cards */}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8 animate-in animate-in-delay-1">
<Card>
<CardHeader>{tAdmin("allTenants")}</CardHeader>
<span className="font-display text-3xl font-semibold text-text-primary tabular-nums">
{allTenants.length}
</span>
</Card>
{Object.entries(phaseCount).map(([phase, count]) => (
<Card key={phase}>
<CardHeader>{phase}</CardHeader>
<div className="flex items-center gap-2">
<span className="font-display text-3xl font-semibold text-text-primary tabular-nums">
{count}
</span>
<StatusBadge phase={phase} />
</div>
</Card>
))}
</div>
{/* Tenant table */}
{allTenants.length > 0 && (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden animate-in animate-in-delay-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{tAdmin("name")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{tAdmin("phase")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{tAdmin("packages")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{tAdmin("created")}
</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody>
{allTenants.map((tenant) => (
<tr
key={tenant.metadata.name}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
>
<td className="px-5 py-3">
<div className="font-mono text-xs text-accent">
{tenant.metadata.name}
</div>
{tenant.spec.displayName && (
<div className="text-xs text-text-secondary">
{tenant.spec.displayName}
</div>
)}
</td>
<td className="px-5 py-3">
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
</td>
<td className="px-5 py-3 text-xs text-text-secondary font-mono">
{tenant.spec.packages?.join(", ") || "—"}
</td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
: "—"}
</td>
<td className="px-5 py-3 text-right">
<Link
href={`/tenants/${tenant.metadata.name}`}
className="text-xs font-medium text-accent hover:text-accent-dim transition-colors"
>
{t("manage")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// Regular user: find their tenant
const myTenant = allTenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (!myTenant) {
return (
<div>
<details className="mt-12 text-xs text-text-muted">
<summary className="cursor-pointer hover:text-text-secondary transition-colors">
Session Debug
</summary>
<pre className="mt-3 bg-surface-2 border border-border rounded-lg p-4 overflow-auto font-mono text-[11px] text-text-secondary">
{JSON.stringify(user, null, 2)}
</pre>
</details>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
<div className="flex flex-col items-center justify-center py-16 text-center animate-in animate-in-delay-1">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mb-4">
<div className="h-8 w-8 rounded-lg bg-accent/40" />
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
{t("noInstance")}
</h2>
<p className="text-sm text-text-secondary mb-6 max-w-sm">
{t("noInstanceDescription")}
</p>
</div>
</div>
);
}
const tenantName = myTenant.metadata.name;
const teamId = myTenant.status?.litellmTeamId || tenantName;
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
{/* Instance status card */}
<div className="mb-6 animate-in animate-in-delay-1">
<Card>
<CardHeader>{t("instanceStatus")}</CardHeader>
<div className="flex items-center gap-4">
<StatusBadge phase={myTenant.status?.phase ?? "Pending"} />
{myTenant.spec.agentName && (
<span className="text-sm text-text-secondary">
{myTenant.spec.agentName}
</span>
)}
</div>
{myTenant.spec.packages && myTenant.spec.packages.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{myTenant.spec.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2.5 py-0.5"
>
{pkg}
</span>
))}
</div>
)}
</Card>
</div>
{/* Usage */}
<div className="mb-6 animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay teamId={myTenant.status?.litellmTeamId || teamId} />
</div>
{/* Link to tenant detail */}
<Link
href={`/tenants/${tenantName}`}
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-3"
>
<span></span> {t("manage")}
</Link>
<details className="mt-12 text-xs text-text-muted">
<summary className="cursor-pointer hover:text-text-secondary transition-colors">
Session Debug
</summary>
<pre className="mt-3 bg-surface-2 border border-border rounded-lg p-4 overflow-auto font-mono text-[11px] text-text-secondary">
{JSON.stringify(user, null, 2)}
</pre>
</details>
</div>
);
} }

View File

@@ -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() { export default async function TenantDetailPage({
return <TenantDetailClient />; 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 (
<div>
{/* Header */}
<div className="mb-8 animate-in">
<div className="flex items-center gap-4">
<h1 className="font-display text-2xl font-semibold accent-rule">
{tenant.spec.displayName || name}
</h1>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
</div>
{tenant.spec.agentName && (
<p className="text-sm text-text-secondary mt-3">
{t("agent")}: {tenant.spec.agentName}
</p>
)}
</div>
{/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay teamId={tenant.status?.litellmTeamId || name} />
</section>
{/* Packages */}
<section className="mb-8 animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("packages")}
</h2>
<PackageList
tenantName={name}
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
/>
</section>
{/* Workspace files */}
<section className="animate-in animate-in-delay-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")}
</h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
</section>
</div>
);
} }

View File

@@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { getSessionUser } from "@/lib/session";
import { PACKAGE_CATALOG } from "@/lib/packages"; import { PACKAGE_CATALOG } from "@/lib/packages";
export async function GET() { export async function GET() {
const session = await auth(); const user = await getSessionUser();
if (!session?.user) { if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json(PACKAGE_CATALOG); return NextResponse.json(PACKAGE_CATALOG);
} }

View File

@@ -1,35 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { getSessionUser } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s"; 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( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: Promise<{ name: string }> } { params }: { params: Promise<{ name: string }> }
) { ) {
const session = await auth(); const user = await getSessionUser();
if (!session?.user) { if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params; const { name } = await params;
const { orgId, roles } = session.user as any;
try { try {
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) { if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Scope check: non-platform users can only see their own org's tenants
if ( if (
!isPlatformRole(roles || []) && !user.isPlatform &&
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) { ) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
@@ -37,7 +27,7 @@ export async function GET(
return NextResponse.json(tenant); return NextResponse.json(tenant);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: "K8s API error", detail: e.message }, { error: e.message },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }
@@ -47,35 +37,29 @@ export async function PATCH(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ name: string }> } { params }: { params: Promise<{ name: string }> }
) { ) {
const session = await auth(); const user = await getSessionUser();
if (!session?.user) { if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params; if (!user.isPlatform && !user.roles.includes("owner")) {
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")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const { name } = await params;
const body = await req.json();
try { try {
// Ownership check
const existing = await getTenant(name); const existing = await getTenant(name);
if (!existing) { if (!existing)
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if ( if (
!isPlatformRole(userRoles) && !user.isPlatform &&
existing.metadata?.labels?.["zitadel-org-id"] !== orgId existing.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) { ) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Build partial spec — only allow specific fields
const specPatch: Record<string, any> = {}; const specPatch: Record<string, any> = {};
if (body.packages !== undefined) specPatch.packages = body.packages; if (body.packages !== undefined) specPatch.packages = body.packages;
if (body.workspaceFiles !== undefined) if (body.workspaceFiles !== undefined)
@@ -88,7 +72,7 @@ export async function PATCH(
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: "Patch failed", detail: e.message }, { error: e.message },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { getSessionUser } from "@/lib/session";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { writePackageSecrets } from "@/lib/openbao"; import { writePackageSecrets } from "@/lib/openbao";
import { getPackageDef } from "@/lib/packages"; import { getPackageDef } from "@/lib/packages";
@@ -8,23 +8,15 @@ export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ name: string }> } { params }: { params: Promise<{ name: string }> }
) { ) {
const session = await auth(); const user = await getSessionUser();
if (!session?.user) { if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params; if (!user.isPlatform && !user.roles.includes("owner")) {
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")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const { name } = await params;
const body = await req.json(); const body = await req.json();
const { packageId, secrets } = body as { const { packageId, secrets } = body as {
packageId: string; packageId: string;
@@ -38,63 +30,43 @@ export async function POST(
); );
} }
// Validate package exists and requires secrets
const pkgDef = getPackageDef(packageId); const pkgDef = getPackageDef(packageId);
if (!pkgDef) { if (!pkgDef)
return NextResponse.json( return NextResponse.json({ error: "Unknown package" }, { status: 400 });
{ error: "Unknown package" }, if (!pkgDef.requiresSecrets)
{ status: 400 }
);
}
if (!pkgDef.requiresSecrets) {
return NextResponse.json( return NextResponse.json(
{ error: "Package does not require secrets" }, { error: "Package does not require secrets" },
{ status: 400 } { status: 400 }
); );
}
// Verify all required secret keys are present
const requiredKeys = (pkgDef.secrets || []).map((s) => s.key); const requiredKeys = (pkgDef.secrets || []).map((s) => s.key);
const missingKeys = requiredKeys.filter((k) => !secrets[k]?.trim()); const missing = requiredKeys.filter((k) => !secrets[k]?.trim());
if (missingKeys.length > 0) { if (missing.length > 0) {
return NextResponse.json( return NextResponse.json(
{ error: `Missing required secrets: ${missingKeys.join(", ")}` }, { error: `Missing: ${missing.join(", ")}` },
{ status: 400 } { status: 400 }
); );
} }
// Verify tenant ownership
try { try {
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) { if (!tenant)
return NextResponse.json( return NextResponse.json({ error: "Not found" }, { status: 404 });
{ error: "Tenant not found" },
{ status: 404 }
);
}
if ( if (
!isPlatform && !user.isPlatform &&
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) { ) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 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); await writePackageSecrets(name, packageId, secrets);
} catch (err: any) { return NextResponse.json({ ok: true });
console.error("OpenBao write error:", err.message); } catch (e: any) {
console.error("Secret write error:", e.message);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to store secrets" }, { error: "Failed to store secrets" },
{ status: 500 } { status: 500 }
); );
} }
return NextResponse.json({ ok: true });
} }

View File

@@ -1,107 +1,84 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { getSessionUser } from "@/lib/session";
import { getTeamInfo, getTeamSpendLogs } from "@/lib/litellm"; import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
// Pricing constants (CHF)
const INPUT_RATE = 3; // CHF per MTok
const OUTPUT_RATE = 15; // CHF per MTok
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const session = await auth(); const user = await getSessionUser();
if (!session?.user) { if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { orgId } = session.user as any; const teamId = req.nextUrl.searchParams.get("teamId");
if (!orgId) { if (!teamId)
return NextResponse.json( return NextResponse.json({ error: "teamId required" }, { status: 400 });
{ error: "No org context" },
{ status: 400 }
);
}
// The LiteLLM team_id maps to the tenant name, which is derived from orgId // Month param: YYYY-MM, defaults to current month
// Convention: team_id = "pieced-{orgId}" or looked up from the tenant CR const now = new Date();
const searchParams = req.nextUrl.searchParams; const monthParam = req.nextUrl.searchParams.get("month")
const teamId = searchParams.get("teamId"); || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
if (!teamId) { const [year, month] = monthParam.split("-").map(Number);
return NextResponse.json( const startDate = new Date(year, month - 1, 1);
{ error: "teamId query param required" }, const endDate = new Date(year, month, 0); // last day of month
{ status: 400 }
); const startStr = startDate.toISOString().split("T")[0];
} const endStr = endDate.toISOString().split("T")[0];
try { try {
// Current period info
const teamInfo = await getTeamInfo(teamId); const teamInfo = await getTeamInfo(teamId);
// Historical spend logs (last 30 days) // Fetch all pages
const endDate = new Date(); const allRequests: any[] = [];
const startDate = new Date(); let page = 1;
startDate.setDate(startDate.getDate() - 30); 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( // Aggregate by day
teamId, const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {};
startDate.toISOString().split("T")[0], for (const r of allRequests) {
endDate.toISOString().split("T")[0] 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 = Object.entries(byDay)
const dailyUsage = (spendLogs || []).map((day: any) => ({ .sort(([a], [b]) => a.localeCompare(b))
date: day.date || day.day, .map(([date, d]) => ({ date, ...d }));
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,
}));
// Totals for current period const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0);
const totalInputTokens = dailyUsage.reduce( const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0);
(s: number, d: any) => s + d.inputTokens, const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
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
);
return NextResponse.json({ return NextResponse.json({
teamId, teamId,
month: monthParam,
currentPeriod: { currentPeriod: {
inputTokens: totalInputTokens, inputTokens: totalInput,
outputTokens: totalOutputTokens, outputTokens: totalOutput,
inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE, totalSpend,
outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE, requestCount: allRequests.length,
totalCostCHF,
}, },
budget: { budget: {
maxBudget: teamInfo?.max_budget ?? null, maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.spend ?? 0, spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.max_budget remaining: teamInfo?.team_info?.max_budget
? teamInfo.max_budget - (teamInfo.spend ?? 0) ? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
: null, : null,
}, },
rateLimits: { rateLimits: {
rpm: teamInfo?.rpm_limit ?? null, rpm: teamInfo?.team_info?.rpm_limit ?? null,
tpm: teamInfo?.tpm_limit ?? null, tpm: teamInfo?.team_info?.tpm_limit ?? null,
}, },
dailyUsage, dailyUsage,
}); });
} catch (err: any) { } catch (e: any) {
console.error("Usage fetch error:", err.message); console.error("Usage fetch error:", e.message);
return NextResponse.json( return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 });
{ error: "Failed to fetch usage data" },
{ status: 500 }
);
} }
} }

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

View File

@@ -20,6 +20,7 @@ export const authConfig: NextAuthConfig = {
issuer: process.env.ZITADEL_ISSUER!, issuer: process.env.ZITADEL_ISSUER!,
clientId: process.env.ZITADEL_CLIENT_ID!, clientId: process.env.ZITADEL_CLIENT_ID!,
clientSecret: process.env.ZITADEL_CLIENT_SECRET!, clientSecret: process.env.ZITADEL_CLIENT_SECRET!,
idToken: false,
authorization: { authorization: {
params: { params: {
scope: scope:
@@ -38,6 +39,7 @@ export const authConfig: NextAuthConfig = {
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
if (account && profile) { if (account && profile) {
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
const claims = profile as unknown as ZitadelClaims; const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"]; token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];

View File

@@ -31,3 +31,20 @@ export async function getTeamSpendLogs(
if (endDate) params.set("end_date", endDate); if (endDate) params.set("end_date", endDate);
return litellmFetch(`/global/spend/logs?${params}`); return litellmFetch(`/global/spend/logs?${params}`);
} }
export async function getTeamSpendLogsV2(
teamId: string,
startDate: string,
endDate: string,
page: number = 1,
pageSize: number = 100
) {
const params = new URLSearchParams({
team_id: teamId,
start_date: `${startDate} 00:00:00`,
end_date: `${endDate} 23:59:59`,
page: String(page),
page_size: String(pageSize),
});
return litellmFetch(`/spend/logs/v2?${params}`);
}

View File

@@ -2,8 +2,7 @@ import { readFileSync } from "fs";
const OPENBAO_ADDR = const OPENBAO_ADDR =
process.env.OPENBAO_ADDR || "http://openbao.openbao.svc:8200"; process.env.OPENBAO_ADDR || "http://openbao.openbao.svc:8200";
const SA_TOKEN_PATH = const SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
"/var/run/secrets/kubernetes.io/serviceaccount/token";
const K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal"; const K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal";
const K8S_AUTH_MOUNT = process.env.OPENBAO_K8S_MOUNT || "kubernetes"; const K8S_AUTH_MOUNT = process.env.OPENBAO_K8S_MOUNT || "kubernetes";
@@ -15,7 +14,6 @@ async function authenticate(): Promise<string> {
} }
const jwt = readFileSync(SA_TOKEN_PATH, "utf-8").trim(); const jwt = readFileSync(SA_TOKEN_PATH, "utf-8").trim();
const res = await fetch( const res = await fetch(
`${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`, `${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`,
{ {
@@ -38,14 +36,9 @@ async function authenticate(): Promise<string> {
token, token,
expiresAt: Date.now() + leaseDuration * 1000, expiresAt: Date.now() + leaseDuration * 1000,
}; };
return token; return token;
} }
/**
* Write secrets for a tenant package to OpenBao KV v2.
* Path: secret/data/tenants/{tenantId}/{packageId}
*/
export async function writePackageSecrets( export async function writePackageSecrets(
tenantId: string, tenantId: string,
packageId: string, packageId: string,
@@ -53,7 +46,6 @@ export async function writePackageSecrets(
): Promise<void> { ): Promise<void> {
const token = await authenticate(); const token = await authenticate();
const path = `secret/data/tenants/${tenantId}/${packageId}`; const path = `secret/data/tenants/${tenantId}/${packageId}`;
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, { const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -69,17 +61,12 @@ export async function writePackageSecrets(
} }
} }
/**
* Delete secrets for a tenant package from OpenBao KV v2.
* Uses metadata delete to remove all versions.
*/
export async function deletePackageSecrets( export async function deletePackageSecrets(
tenantId: string, tenantId: string,
packageId: string packageId: string
): Promise<void> { ): Promise<void> {
const token = await authenticate(); const token = await authenticate();
const path = `secret/metadata/tenants/${tenantId}/${packageId}`; const path = `secret/metadata/tenants/${tenantId}/${packageId}`;
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, { const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
method: "DELETE", method: "DELETE",
headers: { "X-Vault-Token": token }, headers: { "X-Vault-Token": token },

View File

@@ -1,16 +1,17 @@
export interface PackageSecretField {
key: string;
labelKey: string;
placeholderKey: string;
}
export interface PackageDef { export interface PackageDef {
id: string; id: string;
name: string; name: string;
descriptionKey: string; // i18n key descriptionKey: string;
icon: string; // emoji or lucide icon name
requiresSecrets: boolean; requiresSecrets: boolean;
secrets?: { secrets?: PackageSecretField[];
key: string; instructionsKey?: string;
labelKey: string; disclaimerKey?: string;
placeholderKey: string;
}[];
customerInstructionsKey?: string; // i18n key for how-to
disclaimerKey?: string; // i18n key
category: "channel" | "skill"; category: "channel" | "skill";
} }
@@ -19,7 +20,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
id: "telegram", id: "telegram",
name: "Telegram", name: "Telegram",
descriptionKey: "packages.telegram.description", descriptionKey: "packages.telegram.description",
icon: "MessageCircle",
requiresSecrets: true, requiresSecrets: true,
secrets: [ secrets: [
{ {
@@ -28,7 +28,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
placeholderKey: "packages.telegram.botTokenPlaceholder", placeholderKey: "packages.telegram.botTokenPlaceholder",
}, },
], ],
customerInstructionsKey: "packages.telegram.instructions", instructionsKey: "packages.telegram.instructions",
disclaimerKey: "packages.telegram.disclaimer", disclaimerKey: "packages.telegram.disclaimer",
category: "channel", category: "channel",
}, },
@@ -36,7 +36,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
id: "discord", id: "discord",
name: "Discord", name: "Discord",
descriptionKey: "packages.discord.description", descriptionKey: "packages.discord.description",
icon: "Hash",
requiresSecrets: true, requiresSecrets: true,
secrets: [ secrets: [
{ {
@@ -45,7 +44,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
placeholderKey: "packages.discord.botTokenPlaceholder", placeholderKey: "packages.discord.botTokenPlaceholder",
}, },
], ],
customerInstructionsKey: "packages.discord.instructions", instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer", disclaimerKey: "packages.discord.disclaimer",
category: "channel", category: "channel",
}, },
@@ -53,31 +52,14 @@ export const PACKAGE_CATALOG: PackageDef[] = [
id: "email", id: "email",
name: "Email", name: "Email",
descriptionKey: "packages.email.description", descriptionKey: "packages.email.description",
icon: "Mail",
requiresSecrets: true, requiresSecrets: true,
secrets: [ secrets: [
{ { key: "smtp-host", labelKey: "packages.email.smtpHostLabel", placeholderKey: "packages.email.smtpHostPlaceholder" },
key: "smtp-host", { key: "smtp-user", labelKey: "packages.email.smtpUserLabel", placeholderKey: "packages.email.smtpUserPlaceholder" },
labelKey: "packages.email.smtpHostLabel", { key: "smtp-password", labelKey: "packages.email.smtpPasswordLabel", placeholderKey: "packages.email.smtpPasswordPlaceholder" },
placeholderKey: "packages.email.smtpHostPlaceholder", { key: "imap-host", labelKey: "packages.email.imapHostLabel", placeholderKey: "packages.email.imapHostPlaceholder" },
},
{
key: "smtp-user",
labelKey: "packages.email.smtpUserLabel",
placeholderKey: "packages.email.smtpUserPlaceholder",
},
{
key: "smtp-password",
labelKey: "packages.email.smtpPasswordLabel",
placeholderKey: "packages.email.smtpPasswordPlaceholder",
},
{
key: "imap-host",
labelKey: "packages.email.imapHostLabel",
placeholderKey: "packages.email.imapHostPlaceholder",
},
], ],
customerInstructionsKey: "packages.email.instructions", instructionsKey: "packages.email.instructions",
disclaimerKey: "packages.email.disclaimer", disclaimerKey: "packages.email.disclaimer",
category: "channel", category: "channel",
}, },
@@ -85,7 +67,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
id: "web-search", id: "web-search",
name: "Web Search", name: "Web Search",
descriptionKey: "packages.webSearch.description", descriptionKey: "packages.webSearch.description",
icon: "Search",
requiresSecrets: false, requiresSecrets: false,
category: "skill", category: "skill",
}, },
@@ -93,7 +74,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
id: "document-processing", id: "document-processing",
name: "Document Processing", name: "Document Processing",
descriptionKey: "packages.documentProcessing.description", descriptionKey: "packages.documentProcessing.description",
icon: "FileText",
requiresSecrets: false, requiresSecrets: false,
category: "skill", category: "skill",
}, },

View File

@@ -14,44 +14,57 @@
}, },
"login": { "login": {
"title": "PieCed Portal", "title": "PieCed Portal",
"subtitle": "Melden Sie sich an, um Ihren KI-Assistenten zu verwalten", "subtitle": "Melde dich an, um deinen KI-Assistenten zu verwalten",
"button": "Weiter mit ZITADEL", "button": "Weiter mit ZITADEL",
"footer": "On-Premises gehostet in der Schweiz" "footer": "On-Premises gehostet in der Schweiz"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Willkommen, {name}", "welcome": "Willkommen zurück, {name}",
"instanceStatus": "Instanz-Status", "instanceStatus": "Instanz-Status",
"usage": "Nutzung", "usage": "Nutzung",
"packages": "Pakete", "packages": "Pakete",
"noInstance": "Noch keine Instanz", "noInstance": "Noch keine Instanz bereitgestellt.",
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
"comingSoon": "Detailansicht folgt in Session 6.2", "comingSoon": "Detailansicht folgt in Session 6.2",
"getStarted": "Loslegen", "noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
"agentName": "Agent", "manage": "Instanz & Pakete verwalten"
},
"admin": {
"title": "Plattform-Admin",
"subtitle": "Alle Tenants der Plattform",
"allTenants": "Tenants",
"noTenants": "Noch keine Tenants bereitgestellt.",
"noAccess": "Unzureichende Berechtigungen für diese Ansicht.",
"name": "Name",
"displayName": "Anzeigename",
"phase": "Phase",
"packages": "Pakete",
"created": "Erstellt", "created": "Erstellt",
"usageTitle": "Nutzung & Kosten", "manage": "Verwalten"
"inputTokens": "Input-Tokens",
"outputTokens": "Output-Tokens",
"totalCost": "Gesamtkosten",
"budgetRemaining": "Budget verbleibend",
"budget": "Budget",
"noBudgetSet": "Kein Limit",
"last30Days": "Letzte 30 Tage",
"usageError": "Nutzungsdaten konnten nicht geladen werden.",
"noUsageData": "Keine Nutzungsdaten verfügbar.",
"manageInstance": "Instanz & Pakete verwalten"
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Pakete", "packages": "Pakete",
"workspaceFiles": "Workspace-Dateien", "workspaceFiles": "Workspace-Dateien",
"notFound": "Mandant nicht gefunden." "notFound": "Mandant nicht gefunden.",
"usage": "Nutzung & Kosten"
},
"usage": {
"inputTokens": "Input-Tokens",
"outputTokens": "Output-Tokens",
"totalSpend": "Gesamtausgaben",
"totalCost": "Gesamtkosten",
"budget": "Budget",
"noLimit": "Kein Limit",
"last30Days": "Letzte 30 Tage",
"noData": "Keine Nutzungsdaten verfügbar.",
"dailyBreakdown": "Tagesübersicht",
"requests": "Anfragen"
}, },
"workspace": { "workspace": {
"save": "Speichern", "save": "Speichern",
"placeholder": "Inhalt für {file} eingeben…", "placeholder": "Inhalt für {file} eingeben…",
"seedingNote": "Hinweis: Workspace-Dateien werden beim ersten Start eingerichtet. Eine Aktualisierung bei bestehenden Instanzen löst ein ConfigMap-Update und Pod-Neustart aus." "seedingNote": "Workspace-Dateien werden beim ersten Start eingerichtet. Aktualisierung löst ConfigMap-Update und Pod-Neustart aus."
}, },
"packages": { "packages": {
"enable": "Aktivieren", "enable": "Aktivieren",
@@ -69,18 +82,18 @@
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.", "description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
"botTokenLabel": "Telegram Bot-Token", "botTokenLabel": "Telegram Bot-Token",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Öffnen Sie @BotFather auf Telegram\n2. Senden Sie /newbot und folgen Sie den Anweisungen\n3. Kopieren Sie den bereitgestellten Bot-Token", "instructions": "1. Öffnen Sie @BotFather auf Telegram\n2. Senden Sie /newbot und folgen Sie den Anweisungen\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Telegram-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden." "disclaimer": "Ich bestätige, dass ich Eigentümer dieses Telegram-Bots bin und PieCed IT autorisiere, ihn mit meiner Instanz zu verbinden."
}, },
"discord": { "discord": {
"description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit einem Discord-Server.", "description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit Discord.",
"botTokenLabel": "Discord Bot-Token", "botTokenLabel": "Discord Bot-Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token von der Bot-Einstellungsseite", "instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden." "disclaimer": "Ich bestätige, dass ich Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner Instanz zu verbinden."
}, },
"email": { "email": {
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.", "description": "Ermöglichen Sie Ihrem KI-Assistenten E-Mails zu senden und empfangen.",
"smtpHostLabel": "SMTP-Host", "smtpHostLabel": "SMTP-Host",
"smtpHostPlaceholder": "smtp.example.com", "smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP-Benutzername", "smtpUserLabel": "SMTP-Benutzername",
@@ -89,29 +102,14 @@
"smtpPasswordPlaceholder": "••••••••", "smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP-Host", "imapHostLabel": "IMAP-Host",
"imapHostPlaceholder": "imap.example.com", "imapHostPlaceholder": "imap.example.com",
"instructions": "Geben Sie die SMTP- und IMAP-Zugangsdaten Ihres E-Mail-Servers an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.", "instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und PieCed IT in meinem Auftrag auf dieses Postfach zugreifen darf." "disclaimer": "Ich bestätige, dass ich berechtigt bin, diese Zugangsdaten zu verwenden und PieCed IT auf dieses Postfach zugreifen darf."
}, },
"webSearch": { "webSearch": {
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web nach aktuellen Informationen zu suchen." "description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
}, },
"documentProcessing": { "documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion." "description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
} }
},
"admin": {
"title": "Plattform-Admin",
"subtitle": "Alle Plattform-Mandanten",
"allTenants": "Mandanten",
"tenants": "Alle Mandanten",
"total": "gesamt",
"noTenants": "Keine Mandanten provisioniert.",
"noAccess": "Unzureichende Berechtigungen für diese Ansicht.",
"name": "Name",
"displayName": "Anzeigename",
"phase": "Status",
"packages": "Pakete",
"created": "Erstellt",
"manage": "Verwalten"
} }
} }

View File

@@ -2,8 +2,8 @@
"common": { "common": {
"appName": "PieCed", "appName": "PieCed",
"tagline": "AI Platform", "tagline": "AI Platform",
"login": "Login", "login": "Sign In",
"logout": "Logout", "logout": "Sign Out",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"admin": "Admin", "admin": "Admin",
"loading": "Loading…", "loading": "Loading…",
@@ -20,38 +20,51 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Welcome, {name}", "welcome": "Welcome back, {name}",
"instanceStatus": "Instance Status", "instanceStatus": "Instance Status",
"usage": "Usage", "usage": "Usage",
"packages": "Packages", "packages": "Packages",
"noInstance": "No Instance Yet", "noInstance": "No instance provisioned yet.",
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
"comingSoon": "Detailed view coming in Session 6.2", "comingSoon": "Detailed view coming in Session 6.2",
"getStarted": "Get Started", "noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
"agentName": "Agent", "manage": "Manage instance & packages"
},
"admin": {
"title": "Platform Admin",
"subtitle": "All tenants across the platform",
"allTenants": "Tenants",
"noTenants": "No tenants provisioned yet.",
"noAccess": "Insufficient permissions for this view.",
"name": "Name",
"displayName": "Display Name",
"phase": "Phase",
"packages": "Packages",
"created": "Created", "created": "Created",
"usageTitle": "Usage & Spend", "manage": "Manage"
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"totalCost": "Total Cost",
"budgetRemaining": "Budget Remaining",
"budget": "Budget",
"noBudgetSet": "No limit",
"last30Days": "Last 30 Days",
"usageError": "Failed to load usage data.",
"noUsageData": "No usage data available.",
"manageInstance": "Manage instance & packages"
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Packages", "packages": "Packages",
"workspaceFiles": "Workspace Files", "workspaceFiles": "Workspace Files",
"notFound": "Tenant not found." "notFound": "Tenant not found.",
"usage": "Usage & Spend"
},
"usage": {
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"totalSpend": "Total Spend",
"totalCost": "Total Cost",
"budget": "Budget",
"noLimit": "No limit",
"last30Days": "Last 30 Days",
"noData": "No usage data available.",
"dailyBreakdown": "Daily Breakdown",
"requests": "requests"
}, },
"workspace": { "workspace": {
"save": "Save", "save": "Save",
"placeholder": "Enter content for {file}…", "placeholder": "Enter content for {file}…",
"seedingNote": "Note: Workspace files are seeded on first boot. Updating files on an existing instance will trigger a ConfigMap update and pod restart." "seedingNote": "Workspace files are seeded on first boot. Updating on an existing instance triggers a ConfigMap update and pod restart."
}, },
"packages": { "packages": {
"enable": "Enable", "enable": "Enable",
@@ -66,18 +79,18 @@
"error": "Error" "error": "Error"
}, },
"telegram": { "telegram": {
"description": "Connect your AI assistant to a Telegram bot for messaging.", "description": "Connect your AI assistant to a Telegram bot.",
"botTokenLabel": "Telegram Bot Token", "botTokenLabel": "Telegram Bot Token",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Open @BotFather on Telegram\n2. Send /newbot and follow the prompts\n3. Copy the bot token provided", "instructions": "1. Open @BotFather on Telegram\n2. Send /newbot and follow the prompts\n3. Copy the bot token provided",
"disclaimer": "I confirm that I own this Telegram bot and authorize PieCed IT to connect it to my AI assistant instance." "disclaimer": "I confirm I own this Telegram bot and authorize PieCed IT to connect it to my AI assistant."
}, },
"discord": { "discord": {
"description": "Connect your AI assistant to a Discord server via a bot.", "description": "Connect your AI assistant to a Discord server via a bot.",
"botTokenLabel": "Discord Bot Token", "botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token from the Bot settings page", "instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token",
"disclaimer": "I confirm that I own this Discord bot and authorize PieCed IT to connect it to my AI assistant instance." "disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant."
}, },
"email": { "email": {
"description": "Enable your AI assistant to send and receive email.", "description": "Enable your AI assistant to send and receive email.",
@@ -89,29 +102,14 @@
"smtpPasswordPlaceholder": "••••••••", "smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP Host", "imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com", "imapHostPlaceholder": "imap.example.com",
"instructions": "Provide your email server's SMTP and IMAP credentials. The assistant will use these to send replies and monitor incoming messages.", "instructions": "Provide SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
"disclaimer": "I confirm that I am authorized to use these email credentials and that PieCed IT may access this mailbox on my behalf." "disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
}, },
"webSearch": { "webSearch": {
"description": "Give your AI assistant the ability to search the web for current information." "description": "Give your AI assistant the ability to search the web."
}, },
"documentProcessing": { "documentProcessing": {
"description": "Enable document parsing, summarization, and extraction capabilities." "description": "Enable document parsing, summarization, and extraction."
} }
},
"admin": {
"title": "Platform Admin",
"subtitle": "All platform tenants",
"allTenants": "Tenants",
"tenants": "All Tenants",
"total": "total",
"noTenants": "No tenants provisioned.",
"noAccess": "Insufficient permissions for this view.",
"name": "Name",
"displayName": "Display Name",
"phase": "Status",
"packages": "Packages",
"created": "Created",
"manage": "Manage"
} }
} }

View File

@@ -24,34 +24,47 @@
"instanceStatus": "État de l'instance", "instanceStatus": "État de l'instance",
"usage": "Utilisation", "usage": "Utilisation",
"packages": "Paquets", "packages": "Paquets",
"noInstance": "Aucune instance provisionnée", "noInstance": "Aucune instance provisionnée.",
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
"comingSoon": "Vue détaillée à venir dans la Session 6.2", "comingSoon": "Vue détaillée à venir dans la Session 6.2",
"getStarted": "Commencer", "noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
"agentName": "Agent", "manage": "Gérer l'instance & les paquets"
},
"admin": {
"title": "Admin plateforme",
"subtitle": "Tous les tenants de la plateforme",
"allTenants": "Tenants",
"noTenants": "Aucun tenant provisionné.",
"noAccess": "Permissions insuffisantes pour cette vue.",
"name": "Nom",
"displayName": "Nom d'affichage",
"phase": "Phase",
"packages": "Paquets",
"created": "Créé", "created": "Créé",
"usageTitle": "Utilisation & Dépenses", "manage": "Gérer"
"inputTokens": "Tokens d'entrée",
"outputTokens": "Tokens de sortie",
"totalCost": "Coût total",
"budgetRemaining": "Budget restant",
"budget": "Budget",
"noBudgetSet": "Pas de limite",
"last30Days": "30 derniers jours",
"usageError": "Impossible de charger les données d'utilisation.",
"noUsageData": "Aucune donnée d'utilisation disponible.",
"manageInstance": "Gérer l'instance & les paquets"
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
"packages": "Paquets", "packages": "Paquets",
"workspaceFiles": "Fichiers Workspace", "workspaceFiles": "Fichiers Workspace",
"notFound": "Tenant introuvable." "notFound": "Tenant introuvable.",
"usage": "Utilisation & Dépenses"
},
"usage": {
"inputTokens": "Tokens d'entrée",
"outputTokens": "Tokens de sortie",
"totalSpend": "Dépenses totales",
"totalCost": "Coût total",
"budget": "Budget",
"noLimit": "Pas de limite",
"last30Days": "30 derniers jours",
"noData": "Aucune donnée d'utilisation.",
"dailyBreakdown": "Détail journalier",
"requests": "requêtes"
}, },
"workspace": { "workspace": {
"save": "Enregistrer", "save": "Enregistrer",
"placeholder": "Saisissez le contenu de {file}…", "placeholder": "Saisissez le contenu de {file}…",
"seedingNote": "Remarque : les fichiers workspace sont initialisés au premier démarrage. Leur mise à jour sur une instance existante déclenche une mise à jour de la ConfigMap et un redémarrage du pod." "seedingNote": "Les fichiers workspace sont initialisés au premier démarrage. La mise à jour déclenche un redémarrage du pod."
}, },
"packages": { "packages": {
"enable": "Activer", "enable": "Activer",
@@ -66,21 +79,21 @@
"error": "Erreur" "error": "Erreur"
}, },
"telegram": { "telegram": {
"description": "Connectez votre assistant IA à un bot Telegram pour la messagerie.", "description": "Connectez votre assistant IA à un bot Telegram.",
"botTokenLabel": "Token du bot Telegram", "botTokenLabel": "Token du bot Telegram",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Ouvrez @BotFather sur Telegram\n2. Envoyez /newbot et suivez les instructions\n3. Copiez le token du bot fourni", "instructions": "1. Ouvrez @BotFather sur Telegram\n2. Envoyez /newbot et suivez les instructions\n3. Copiez le token fourni",
"disclaimer": "Je confirme être propriétaire de ce bot Telegram et j'autorise PieCed IT à le connecter à mon instance d'assistant IA." "disclaimer": "Je confirme être propriétaire de ce bot Telegram et autorise PieCed IT à le connecter à mon assistant IA."
}, },
"discord": { "discord": {
"description": "Connectez votre assistant IA à un serveur Discord via un bot.", "description": "Connectez votre assistant IA à Discord via un bot.",
"botTokenLabel": "Token du bot Discord", "botTokenLabel": "Token du bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot depuis la page de configuration", "instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une application et ajoutez un bot\n3. Copiez le token",
"disclaimer": "Je confirme être propriétaire de ce bot Discord et j'autorise PieCed IT à le connecter à mon instance d'assistant IA." "disclaimer": "Je confirme être propriétaire de ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
}, },
"email": { "email": {
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.", "description": "Permettez à votre assistant IA d'envoyer et recevoir des e-mails.",
"smtpHostLabel": "Hôte SMTP", "smtpHostLabel": "Hôte SMTP",
"smtpHostPlaceholder": "smtp.example.com", "smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Utilisateur SMTP", "smtpUserLabel": "Utilisateur SMTP",
@@ -89,29 +102,14 @@
"smtpPasswordPlaceholder": "••••••••", "smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Hôte IMAP", "imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com", "imapHostPlaceholder": "imap.example.com",
"instructions": "Fournissez les identifiants SMTP et IMAP de votre serveur de messagerie. L'assistant les utilisera pour envoyer des réponses et surveiller les messages entrants.", "instructions": "Fournissez les identifiants SMTP et IMAP pour l'envoi et la réception de messages.",
"disclaimer": "Je confirme être autorisé(e) à utiliser ces identifiants de messagerie et que PieCed IT peut accéder à cette boîte aux lettres en mon nom." "disclaimer": "Je confirme être autorisé(e) à utiliser ces identifiants et que PieCed IT peut accéder à cette boîte."
}, },
"webSearch": { "webSearch": {
"description": "Donnez à votre assistant IA la possibilité de rechercher des informations actuelles sur le web." "description": "Donnez à votre assistant IA la possibilité de rechercher sur le web."
}, },
"documentProcessing": { "documentProcessing": {
"description": "Activez les capacités d'analyse, de résumé et d'extraction de documents." "description": "Activez l'analyse, le résumé et l'extraction de documents."
} }
},
"admin": {
"title": "Admin plateforme",
"subtitle": "Tous les tenants de la plateforme",
"allTenants": "Tenants",
"tenants": "Tous les tenants",
"total": "total",
"noTenants": "Aucun tenant provisionné.",
"noAccess": "Permissions insuffisantes pour cette vue.",
"name": "Nom",
"displayName": "Nom d'affichage",
"phase": "Statut",
"packages": "Paquets",
"created": "Créé",
"manage": "Gérer"
} }
} }

View File

@@ -20,38 +20,51 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Benvenuto, {name}", "welcome": "Bentornato, {name}",
"instanceStatus": "Stato dell'istanza", "instanceStatus": "Stato dell'istanza",
"usage": "Utilizzo", "usage": "Utilizzo",
"packages": "Pacchetti", "packages": "Pacchetti",
"noInstance": "Nessuna istanza ancora", "noInstance": "Nessuna istanza ancora provisioned.",
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2", "comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
"getStarted": "Inizia", "noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
"agentName": "Agente", "manage": "Gestisci istanza e pacchetti"
},
"admin": {
"title": "Admin piattaforma",
"subtitle": "Tutti i tenant della piattaforma",
"allTenants": "Tenant",
"noTenants": "Nessun tenant ancora provisionato.",
"noAccess": "Permessi insufficienti per questa vista.",
"name": "Nome",
"displayName": "Nome visualizzato",
"phase": "Fase",
"packages": "Pacchetti",
"created": "Creato", "created": "Creato",
"usageTitle": "Utilizzo & Spese", "manage": "Gestisci"
"inputTokens": "Token di input",
"outputTokens": "Token di output",
"totalCost": "Costo totale",
"budgetRemaining": "Budget rimanente",
"budget": "Budget",
"noBudgetSet": "Nessun limite",
"last30Days": "Ultimi 30 giorni",
"usageError": "Impossibile caricare i dati di utilizzo.",
"noUsageData": "Nessun dato di utilizzo disponibile.",
"manageInstance": "Gestisci istanza e pacchetti"
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agente", "agent": "Agente",
"packages": "Pacchetti", "packages": "Pacchetti",
"workspaceFiles": "File Workspace", "workspaceFiles": "File Workspace",
"notFound": "Tenant non trovato." "notFound": "Tenant non trovato.",
"usage": "Utilizzo & Spese"
},
"usage": {
"inputTokens": "Token di input",
"outputTokens": "Token di output",
"totalSpend": "Spesa totale",
"totalCost": "Costo totale",
"budget": "Budget",
"noLimit": "Nessun limite",
"last30Days": "Ultimi 30 giorni",
"noData": "Nessun dato di utilizzo.",
"dailyBreakdown": "Dettaglio giornaliero",
"requests": "richieste"
}, },
"workspace": { "workspace": {
"save": "Salva", "save": "Salva",
"placeholder": "Inserisci il contenuto per {file}…", "placeholder": "Inserisci il contenuto per {file}…",
"seedingNote": "Nota: i file workspace vengono inizializzati al primo avvio. L'aggiornamento su un'istanza esistente attiva un aggiornamento della ConfigMap e un riavvio del pod." "seedingNote": "I file workspace vengono inizializzati al primo avvio. L'aggiornamento attiva un riavvio del pod."
}, },
"packages": { "packages": {
"enable": "Attiva", "enable": "Attiva",
@@ -66,18 +79,18 @@
"error": "Errore" "error": "Errore"
}, },
"telegram": { "telegram": {
"description": "Collega il tuo assistente IA a un bot Telegram per la messaggistica.", "description": "Collega il tuo assistente IA a un bot Telegram.",
"botTokenLabel": "Token del bot Telegram", "botTokenLabel": "Token del bot Telegram",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot fornito", "instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot",
"disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA." "disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
}, },
"discord": { "discord": {
"description": "Collega il tuo assistente IA a un server Discord tramite un bot.", "description": "Collega il tuo assistente IA a Discord tramite un bot.",
"botTokenLabel": "Token del bot Discord", "botTokenLabel": "Token del bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot dalla pagina delle impostazioni", "instructions": "1. Vai su discord.com/developers/applications\n2. Crea un'applicazione e aggiungi un bot\n3. Copia il token del bot",
"disclaimer": "Confermo di essere il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA." "disclaimer": "Confermo di essere il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
}, },
"email": { "email": {
"description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.", "description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.",
@@ -89,29 +102,14 @@
"smtpPasswordPlaceholder": "••••••••", "smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Host IMAP", "imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com", "imapHostPlaceholder": "imap.example.com",
"instructions": "Fornisci le credenziali SMTP e IMAP del tuo server di posta. L'assistente le utilizzerà per inviare risposte e monitorare i messaggi in arrivo.", "instructions": "Fornisci le credenziali SMTP e IMAP per l'invio e la ricezione dei messaggi.",
"disclaimer": "Confermo di essere autorizzato/a a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta per mio conto." "disclaimer": "Confermo di essere autorizzato/a a usare queste credenziali e che PieCed IT può accedere a questa casella."
}, },
"webSearch": { "webSearch": {
"description": "Dai al tuo assistente IA la possibilità di cercare informazioni attuali sul web." "description": "Dai al tuo assistente IA la possibilità di cercare sul web."
}, },
"documentProcessing": { "documentProcessing": {
"description": "Attiva le funzionalità di analisi, riassunto ed estrazione dei documenti." "description": "Attiva analisi, riassunto ed estrazione dei documenti."
} }
},
"admin": {
"title": "Admin piattaforma",
"subtitle": "Tutti i tenant della piattaforma",
"allTenants": "Tenant",
"tenants": "Tutti i tenant",
"total": "totale",
"noTenants": "Nessun tenant provisionato.",
"noAccess": "Permessi insufficienti per questa vista.",
"name": "Nome",
"displayName": "Nome visualizzato",
"phase": "Stato",
"packages": "Pacchetti",
"created": "Creato",
"manage": "Gestisci"
} }
} }

View File

@@ -39,5 +39,5 @@ export default async function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next|favicon.ico|api/auth).*)"], matcher: ["/((?!_next|favicon.ico|api).*)" ],
}; };

View File

@@ -35,6 +35,7 @@ export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Error" | "Deleting"; phase: "Pending" | "Provisioning" | "Running" | "Error" | "Deleting";
message?: string; message?: string;
observedGeneration?: number; observedGeneration?: number;
litellmTeamId?: string;
conditions?: Array<{ conditions?: Array<{
type: string; type: string;
status: string; status: string;