Working version 6.2
This commit is contained in:
@@ -1,5 +1,106 @@
|
||||
import AdminTenantsClient from "@/components/admin/AdminTenantsClient";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminTenantsClient />;
|
||||
export default async function AdminPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,240 @@
|
||||
import DashboardClient from "@/components/dashboard/DashboardClient";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
export default async function DashboardPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tAdmin = await getTranslations("admin");
|
||||
|
||||
const allTenants = await listTenants();
|
||||
|
||||
// Platform users see overview of all tenants
|
||||
if (user.isPlatform) {
|
||||
const phaseCount = allTenants.reduce<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
import TenantDetailClient from "@/components/tenants/TenantDetailClient";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { PackageList } from "@/components/packages/package-list";
|
||||
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
||||
|
||||
export default function TenantDetailPage() {
|
||||
return <TenantDetailClient />;
|
||||
export default async function TenantDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ name: string; locale: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { name } = await params;
|
||||
const t = await getTranslations("tenantDetail");
|
||||
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) notFound();
|
||||
console.log("tenant spec:", JSON.stringify(tenant.spec));
|
||||
|
||||
// Scope check
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enabledPackages = tenant.spec.packages || [];
|
||||
const workspaceFiles = tenant.spec.workspaceFiles || {};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(PACKAGE_CATALOG);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
|
||||
function isPlatformRole(roles: string[]): boolean {
|
||||
return roles.some((r) =>
|
||||
["platform_admin", "platform_operator"].includes(r)
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
if (!tenant)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Scope check: non-platform users can only see their own org's tenants
|
||||
if (
|
||||
!isPlatformRole(roles || []) &&
|
||||
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -37,7 +27,7 @@ export async function GET(
|
||||
return NextResponse.json(tenant);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "K8s API error", detail: e.message },
|
||||
{ error: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
@@ -47,35 +37,29 @@ export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
const body = await req.json();
|
||||
const userRoles = roles || [];
|
||||
|
||||
// Only owner or platform roles can patch
|
||||
if (!isPlatformRole(userRoles) && !userRoles.includes("owner")) {
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
try {
|
||||
// Ownership check
|
||||
const existing = await getTenant(name);
|
||||
if (!existing) {
|
||||
if (!existing)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
!isPlatformRole(userRoles) &&
|
||||
existing.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
!user.isPlatform &&
|
||||
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build partial spec — only allow specific fields
|
||||
const specPatch: Record<string, any> = {};
|
||||
if (body.packages !== undefined) specPatch.packages = body.packages;
|
||||
if (body.workspaceFiles !== undefined)
|
||||
@@ -88,7 +72,7 @@ export async function PATCH(
|
||||
return NextResponse.json(updated);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Patch failed", detail: e.message },
|
||||
{ error: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
@@ -8,23 +8,15 @@ export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
const userRoles = roles || [];
|
||||
|
||||
const isPlatform = userRoles.some((r: string) =>
|
||||
["platform_admin", "platform_operator"].includes(r)
|
||||
);
|
||||
|
||||
if (!isPlatform && !userRoles.includes("owner")) {
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const body = await req.json();
|
||||
const { packageId, secrets } = body as {
|
||||
packageId: string;
|
||||
@@ -38,63 +30,43 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate package exists and requires secrets
|
||||
const pkgDef = getPackageDef(packageId);
|
||||
if (!pkgDef) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unknown package" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!pkgDef.requiresSecrets) {
|
||||
if (!pkgDef)
|
||||
return NextResponse.json({ error: "Unknown package" }, { status: 400 });
|
||||
if (!pkgDef.requiresSecrets)
|
||||
return NextResponse.json(
|
||||
{ error: "Package does not require secrets" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify all required secret keys are present
|
||||
const requiredKeys = (pkgDef.secrets || []).map((s) => s.key);
|
||||
const missingKeys = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||
if (missingKeys.length > 0) {
|
||||
const missing = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||
if (missing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required secrets: ${missingKeys.join(", ")}` },
|
||||
{ error: `Missing: ${missing.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!tenant)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (
|
||||
!isPlatform &&
|
||||
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant lookup failed" },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Write to OpenBao
|
||||
try {
|
||||
await writePackageSecrets(name, packageId, secrets);
|
||||
} catch (err: any) {
|
||||
console.error("OpenBao write error:", err.message);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
console.error("Secret write error:", e.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to store secrets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,107 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getTeamInfo, getTeamSpendLogs } from "@/lib/litellm";
|
||||
|
||||
// Pricing constants (CHF)
|
||||
const INPUT_RATE = 3; // CHF per MTok
|
||||
const OUTPUT_RATE = 15; // CHF per MTok
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { orgId } = session.user as any;
|
||||
if (!orgId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No org context" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const teamId = req.nextUrl.searchParams.get("teamId");
|
||||
if (!teamId)
|
||||
return NextResponse.json({ error: "teamId required" }, { status: 400 });
|
||||
|
||||
// The LiteLLM team_id maps to the tenant name, which is derived from orgId
|
||||
// Convention: team_id = "pieced-{orgId}" or looked up from the tenant CR
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
// Month param: YYYY-MM, defaults to current month
|
||||
const now = new Date();
|
||||
const monthParam = req.nextUrl.searchParams.get("month")
|
||||
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId query param required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const [year, month] = monthParam.split("-").map(Number);
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0); // last day of month
|
||||
|
||||
const startStr = startDate.toISOString().split("T")[0];
|
||||
const endStr = endDate.toISOString().split("T")[0];
|
||||
|
||||
try {
|
||||
// Current period info
|
||||
const teamInfo = await getTeamInfo(teamId);
|
||||
|
||||
// Historical spend logs (last 30 days)
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
// Fetch all pages
|
||||
const allRequests: any[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100);
|
||||
allRequests.push(...(result.data || []));
|
||||
if (page >= (result.total_pages || 1)) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
const spendLogs = await getTeamSpendLogs(
|
||||
teamId,
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[0]
|
||||
);
|
||||
// Aggregate by day
|
||||
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {};
|
||||
for (const r of allRequests) {
|
||||
const day = (r.startTime || r.endTime || "").slice(0, 10);
|
||||
if (!day) continue;
|
||||
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
|
||||
byDay[day].inputTokens += r.prompt_tokens || 0;
|
||||
byDay[day].outputTokens += r.completion_tokens || 0;
|
||||
byDay[day].spend += r.spend || 0;
|
||||
}
|
||||
|
||||
// Calculate CHF costs from token counts
|
||||
const dailyUsage = (spendLogs || []).map((day: any) => ({
|
||||
date: day.date || day.day,
|
||||
inputTokens: day.prompt_tokens || 0,
|
||||
outputTokens: day.completion_tokens || 0,
|
||||
inputCostCHF:
|
||||
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE,
|
||||
outputCostCHF:
|
||||
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||
totalCostCHF:
|
||||
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE +
|
||||
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||
}));
|
||||
const dailyUsage = Object.entries(byDay)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, d]) => ({ date, ...d }));
|
||||
|
||||
// Totals for current period
|
||||
const totalInputTokens = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.inputTokens,
|
||||
0
|
||||
);
|
||||
const totalOutputTokens = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.outputTokens,
|
||||
0
|
||||
);
|
||||
const totalCostCHF = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.totalCostCHF,
|
||||
0
|
||||
);
|
||||
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0);
|
||||
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0);
|
||||
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
|
||||
|
||||
return NextResponse.json({
|
||||
teamId,
|
||||
month: monthParam,
|
||||
currentPeriod: {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE,
|
||||
outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE,
|
||||
totalCostCHF,
|
||||
inputTokens: totalInput,
|
||||
outputTokens: totalOutput,
|
||||
totalSpend,
|
||||
requestCount: allRequests.length,
|
||||
},
|
||||
budget: {
|
||||
maxBudget: teamInfo?.max_budget ?? null,
|
||||
spend: teamInfo?.spend ?? 0,
|
||||
remaining: teamInfo?.max_budget
|
||||
? teamInfo.max_budget - (teamInfo.spend ?? 0)
|
||||
maxBudget: teamInfo?.team_info?.max_budget ?? null,
|
||||
spend: teamInfo?.team_info?.spend ?? 0,
|
||||
remaining: teamInfo?.team_info?.max_budget
|
||||
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
|
||||
: null,
|
||||
},
|
||||
rateLimits: {
|
||||
rpm: teamInfo?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.tpm_limit ?? null,
|
||||
rpm: teamInfo?.team_info?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.team_info?.tpm_limit ?? null,
|
||||
},
|
||||
dailyUsage,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Usage fetch error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch usage data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("Usage fetch error:", e.message);
|
||||
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
185
src/components/dashboard/usage-display.tsx
Normal file
185
src/components/dashboard/usage-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
192
src/components/packages/package-card.tsx
Normal file
192
src/components/packages/package-card.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
src/components/packages/package-list.tsx
Normal file
40
src/components/packages/package-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/packages/workspace-editor.tsx
Normal file
88
src/components/packages/workspace-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const authConfig: NextAuthConfig = {
|
||||
issuer: process.env.ZITADEL_ISSUER!,
|
||||
clientId: process.env.ZITADEL_CLIENT_ID!,
|
||||
clientSecret: process.env.ZITADEL_CLIENT_SECRET!,
|
||||
idToken: false,
|
||||
authorization: {
|
||||
params: {
|
||||
scope:
|
||||
@@ -38,6 +39,7 @@ export const authConfig: NextAuthConfig = {
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && profile) {
|
||||
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
|
||||
|
||||
@@ -31,3 +31,20 @@ export async function getTeamSpendLogs(
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
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}`);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { readFileSync } from "fs";
|
||||
|
||||
const OPENBAO_ADDR =
|
||||
process.env.OPENBAO_ADDR || "http://openbao.openbao.svc:8200";
|
||||
const SA_TOKEN_PATH =
|
||||
"/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
const SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
const K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal";
|
||||
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 res = await fetch(
|
||||
`${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`,
|
||||
{
|
||||
@@ -38,14 +36,9 @@ async function authenticate(): Promise<string> {
|
||||
token,
|
||||
expiresAt: Date.now() + leaseDuration * 1000,
|
||||
};
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write secrets for a tenant package to OpenBao KV v2.
|
||||
* Path: secret/data/tenants/{tenantId}/{packageId}
|
||||
*/
|
||||
export async function writePackageSecrets(
|
||||
tenantId: string,
|
||||
packageId: string,
|
||||
@@ -53,7 +46,6 @@ export async function writePackageSecrets(
|
||||
): Promise<void> {
|
||||
const token = await authenticate();
|
||||
const path = `secret/data/tenants/${tenantId}/${packageId}`;
|
||||
|
||||
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
|
||||
method: "POST",
|
||||
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(
|
||||
tenantId: string,
|
||||
packageId: string
|
||||
): Promise<void> {
|
||||
const token = await authenticate();
|
||||
const path = `secret/metadata/tenants/${tenantId}/${packageId}`;
|
||||
|
||||
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Vault-Token": token },
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
export interface PackageSecretField {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
placeholderKey: string;
|
||||
}
|
||||
|
||||
export interface PackageDef {
|
||||
id: string;
|
||||
name: string;
|
||||
descriptionKey: string; // i18n key
|
||||
icon: string; // emoji or lucide icon name
|
||||
descriptionKey: string;
|
||||
requiresSecrets: boolean;
|
||||
secrets?: {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
placeholderKey: string;
|
||||
}[];
|
||||
customerInstructionsKey?: string; // i18n key for how-to
|
||||
disclaimerKey?: string; // i18n key
|
||||
secrets?: PackageSecretField[];
|
||||
instructionsKey?: string;
|
||||
disclaimerKey?: string;
|
||||
category: "channel" | "skill";
|
||||
}
|
||||
|
||||
@@ -19,7 +20,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
descriptionKey: "packages.telegram.description",
|
||||
icon: "MessageCircle",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
@@ -28,7 +28,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
placeholderKey: "packages.telegram.botTokenPlaceholder",
|
||||
},
|
||||
],
|
||||
customerInstructionsKey: "packages.telegram.instructions",
|
||||
instructionsKey: "packages.telegram.instructions",
|
||||
disclaimerKey: "packages.telegram.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
@@ -36,7 +36,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
descriptionKey: "packages.discord.description",
|
||||
icon: "Hash",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
@@ -45,7 +44,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
placeholderKey: "packages.discord.botTokenPlaceholder",
|
||||
},
|
||||
],
|
||||
customerInstructionsKey: "packages.discord.instructions",
|
||||
instructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
@@ -53,31 +52,14 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
id: "email",
|
||||
name: "Email",
|
||||
descriptionKey: "packages.email.description",
|
||||
icon: "Mail",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "smtp-host",
|
||||
labelKey: "packages.email.smtpHostLabel",
|
||||
placeholderKey: "packages.email.smtpHostPlaceholder",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{ key: "smtp-host", labelKey: "packages.email.smtpHostLabel", placeholderKey: "packages.email.smtpHostPlaceholder" },
|
||||
{ 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",
|
||||
category: "channel",
|
||||
},
|
||||
@@ -85,7 +67,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
id: "web-search",
|
||||
name: "Web Search",
|
||||
descriptionKey: "packages.webSearch.description",
|
||||
icon: "Search",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
@@ -93,7 +74,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
id: "document-processing",
|
||||
name: "Document Processing",
|
||||
descriptionKey: "packages.documentProcessing.description",
|
||||
icon: "FileText",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
|
||||
@@ -14,44 +14,57 @@
|
||||
},
|
||||
"login": {
|
||||
"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",
|
||||
"footer": "On-Premises gehostet in der Schweiz"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Willkommen, {name}",
|
||||
"welcome": "Willkommen zurück, {name}",
|
||||
"instanceStatus": "Instanz-Status",
|
||||
"usage": "Nutzung",
|
||||
"packages": "Pakete",
|
||||
"noInstance": "Noch keine Instanz",
|
||||
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
|
||||
"noInstance": "Noch keine Instanz bereitgestellt.",
|
||||
"comingSoon": "Detailansicht folgt in Session 6.2",
|
||||
"getStarted": "Loslegen",
|
||||
"agentName": "Agent",
|
||||
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
|
||||
"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",
|
||||
"usageTitle": "Nutzung & Kosten",
|
||||
"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"
|
||||
"manage": "Verwalten"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Pakete",
|
||||
"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": {
|
||||
"save": "Speichern",
|
||||
"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": {
|
||||
"enable": "Aktivieren",
|
||||
@@ -69,18 +82,18 @@
|
||||
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
|
||||
"botTokenLabel": "Telegram Bot-Token",
|
||||
"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",
|
||||
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Telegram-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden."
|
||||
"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 Instanz zu verbinden."
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden."
|
||||
"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 Instanz zu verbinden."
|
||||
},
|
||||
"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",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP-Benutzername",
|
||||
@@ -89,29 +102,14 @@
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "IMAP-Host",
|
||||
"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.",
|
||||
"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."
|
||||
"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 Zugangsdaten zu verwenden und PieCed IT auf dieses Postfach zugreifen darf."
|
||||
},
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "AI Platform",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"login": "Sign In",
|
||||
"logout": "Sign Out",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Loading…",
|
||||
@@ -20,38 +20,51 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {name}",
|
||||
"welcome": "Welcome back, {name}",
|
||||
"instanceStatus": "Instance Status",
|
||||
"usage": "Usage",
|
||||
"packages": "Packages",
|
||||
"noInstance": "No Instance Yet",
|
||||
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
|
||||
"noInstance": "No instance provisioned yet.",
|
||||
"comingSoon": "Detailed view coming in Session 6.2",
|
||||
"getStarted": "Get Started",
|
||||
"agentName": "Agent",
|
||||
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
|
||||
"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",
|
||||
"usageTitle": "Usage & Spend",
|
||||
"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"
|
||||
"manage": "Manage"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Packages",
|
||||
"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": {
|
||||
"save": "Save",
|
||||
"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": {
|
||||
"enable": "Enable",
|
||||
@@ -66,18 +79,18 @@
|
||||
"error": "Error"
|
||||
},
|
||||
"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",
|
||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"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": {
|
||||
"description": "Connect your AI assistant to a Discord server via a bot.",
|
||||
"botTokenLabel": "Discord Bot Token",
|
||||
"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",
|
||||
"disclaimer": "I confirm that I own this Discord bot and authorize PieCed IT to connect it to my AI assistant instance."
|
||||
"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 I own this Discord bot and authorize PieCed IT to connect it to my AI assistant."
|
||||
},
|
||||
"email": {
|
||||
"description": "Enable your AI assistant to send and receive email.",
|
||||
@@ -89,29 +102,14 @@
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "IMAP Host",
|
||||
"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.",
|
||||
"disclaimer": "I confirm that I am authorized to use these email credentials and that PieCed IT may access this mailbox on my behalf."
|
||||
"instructions": "Provide SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
|
||||
"disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
|
||||
},
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,34 +24,47 @@
|
||||
"instanceStatus": "État de l'instance",
|
||||
"usage": "Utilisation",
|
||||
"packages": "Paquets",
|
||||
"noInstance": "Aucune instance provisionnée",
|
||||
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
|
||||
"noInstance": "Aucune instance provisionnée.",
|
||||
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
|
||||
"getStarted": "Commencer",
|
||||
"agentName": "Agent",
|
||||
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
|
||||
"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éé",
|
||||
"usageTitle": "Utilisation & Dépenses",
|
||||
"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"
|
||||
"manage": "Gérer"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Paquets",
|
||||
"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": {
|
||||
"save": "Enregistrer",
|
||||
"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": {
|
||||
"enable": "Activer",
|
||||
@@ -66,21 +79,21 @@
|
||||
"error": "Erreur"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"disclaimer": "Je confirme être propriétaire de ce bot Telegram et j'autorise PieCed IT à le connecter à mon instance d'assistant IA."
|
||||
"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 autorise PieCed IT à le connecter à mon assistant IA."
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"disclaimer": "Je confirme être propriétaire de ce bot Discord et j'autorise PieCed IT à le connecter à mon instance d'assistant IA."
|
||||
"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 autorise PieCed IT à le connecter à mon assistant IA."
|
||||
},
|
||||
"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",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Utilisateur SMTP",
|
||||
@@ -89,29 +102,14 @@
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "Hôte IMAP",
|
||||
"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.",
|
||||
"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."
|
||||
"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 et que PieCed IT peut accéder à cette boîte."
|
||||
},
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,38 +20,51 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Benvenuto, {name}",
|
||||
"welcome": "Bentornato, {name}",
|
||||
"instanceStatus": "Stato dell'istanza",
|
||||
"usage": "Utilizzo",
|
||||
"packages": "Pacchetti",
|
||||
"noInstance": "Nessuna istanza ancora",
|
||||
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"noInstance": "Nessuna istanza ancora provisioned.",
|
||||
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||
"getStarted": "Inizia",
|
||||
"agentName": "Agente",
|
||||
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"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",
|
||||
"usageTitle": "Utilizzo & Spese",
|
||||
"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"
|
||||
"manage": "Gestisci"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
"packages": "Pacchetti",
|
||||
"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": {
|
||||
"save": "Salva",
|
||||
"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": {
|
||||
"enable": "Attiva",
|
||||
@@ -66,18 +79,18 @@
|
||||
"error": "Errore"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA."
|
||||
"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 al mio assistente IA."
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"disclaimer": "Confermo di essere il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA."
|
||||
"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 al mio assistente IA."
|
||||
},
|
||||
"email": {
|
||||
"description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.",
|
||||
@@ -89,29 +102,14 @@
|
||||
"smtpPasswordPlaceholder": "••••••••",
|
||||
"imapHostLabel": "Host IMAP",
|
||||
"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.",
|
||||
"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."
|
||||
"instructions": "Fornisci le credenziali SMTP e IMAP per l'invio e la ricezione dei messaggi.",
|
||||
"disclaimer": "Confermo di essere autorizzato/a a usare queste credenziali e che PieCed IT può accedere a questa casella."
|
||||
},
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@ export default async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|favicon.ico|api/auth).*)"],
|
||||
matcher: ["/((?!_next|favicon.ico|api).*)" ],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface PiecedTenantStatus {
|
||||
phase: "Pending" | "Provisioning" | "Running" | "Error" | "Deleting";
|
||||
message?: string;
|
||||
observedGeneration?: number;
|
||||
litellmTeamId?: string;
|
||||
conditions?: Array<{
|
||||
type: string;
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user