197 lines
5.4 KiB
TypeScript
197 lines
5.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|