Add initial Portal version
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.md
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# NextAuth
|
||||
NEXTAUTH_URL=https://app.pieced.ch
|
||||
NEXTAUTH_SECRET= # openssl rand -base64 32
|
||||
|
||||
# ZITADEL OIDC
|
||||
ZITADEL_ISSUER=https://auth.pieced.ch
|
||||
ZITADEL_CLIENT_ID=
|
||||
ZITADEL_CLIENT_SECRET=
|
||||
|
||||
# LiteLLM (in-cluster)
|
||||
LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
|
||||
LITELLM_MASTER_KEY=
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# PieCed Portal
|
||||
|
||||
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Choice |
|
||||
|-------|--------|
|
||||
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
|
||||
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
|
||||
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
|
||||
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
|
||||
| i18n | next-intl 4.x (en/de) |
|
||||
| Styling | Tailwind CSS 4 |
|
||||
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. ZITADEL Application
|
||||
|
||||
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
|
||||
|
||||
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
|
||||
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
|
||||
3. Post-logout URI: `https://app.pieced.ch/login`
|
||||
4. Note Client ID and Client Secret
|
||||
|
||||
### 2. OpenBao Secrets
|
||||
|
||||
```bash
|
||||
bao kv put pieced/portal/oidc \
|
||||
client_id="<from step 1>" \
|
||||
client_secret="<from step 1>" \
|
||||
nextauth_secret="$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
### 3. Build & Push
|
||||
|
||||
```bash
|
||||
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
|
||||
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
|
||||
```
|
||||
|
||||
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
|
||||
|
||||
### 4. DNS
|
||||
|
||||
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Fill in values — K8s client uses ~/.kube/config locally
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
|
||||
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
|
||||
│ │ └── usage/route.ts # Usage stub
|
||||
│ ├── [locale]/
|
||||
│ │ ├── layout.tsx # Locale layout + NavShell
|
||||
│ │ ├── page.tsx # Redirect → /dashboard
|
||||
│ │ ├── login/page.tsx # ZITADEL sign-in
|
||||
│ │ ├── dashboard/page.tsx # Customer dashboard
|
||||
│ │ └── admin/page.tsx # Platform admin tenant list
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── globals.css # Tailwind 4 theme
|
||||
├── components/
|
||||
│ ├── layout/nav-shell.tsx # Header + navigation
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── i18n/
|
||||
│ ├── routing.ts # next-intl 4.x routing config
|
||||
│ ├── navigation.ts # Localized Link, redirect, etc.
|
||||
│ └── request.ts # Server-side i18n config
|
||||
├── lib/
|
||||
│ ├── auth.ts # NextAuth v5 + ZITADEL config
|
||||
│ ├── k8s.ts # K8s client for PiecedTenant CRs
|
||||
│ ├── litellm.ts # LiteLLM API client
|
||||
│ └── session.ts # Session helpers
|
||||
├── messages/
|
||||
│ ├── en.json
|
||||
│ └── de.json
|
||||
└── types/index.ts # Shared TypeScript types
|
||||
```
|
||||
|
||||
## Session Roadmap
|
||||
|
||||
- **6.1** ← This session: scaffold, auth, basic pages
|
||||
- **6.2**: Instance management, package config, usage display
|
||||
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
|
||||
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
|
||||
- **6.5**: Admin panel (tenant lifecycle, billing overview)
|
||||
10
next.config.mjs
Normal file
10
next.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
7460
package-lock.json
generated
Normal file
7460
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "pieced-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"next": "^15.5.15",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-intl": "^4.9.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-next": "^15.5.15",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
5
src/app/[locale]/admin/page.tsx
Normal file
5
src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminTenantsClient from "@/components/admin/AdminTenantsClient";
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminTenantsClient />;
|
||||
}
|
||||
5
src/app/[locale]/dashboard/page.tsx
Normal file
5
src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DashboardClient from "@/components/dashboard/DashboardClient";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
43
src/app/[locale]/layout.tsx
Normal file
43
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as any)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
60
src/app/[locale]/login/page.tsx
Normal file
60
src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
{/* Background grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(var(--color-accent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--color-accent) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||
{/* Logo mark */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
||||
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||
<h1 className="font-display text-xl font-semibold text-center mb-1">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm text-center mb-8">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
hover:bg-accent-dim active:scale-[0.98]
|
||||
transition-all duration-150
|
||||
shadow-lg shadow-accent/20
|
||||
"
|
||||
>
|
||||
{t("button")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-text-muted text-[11px] mt-6 tracking-wide uppercase">
|
||||
{t("footer")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/[locale]/page.tsx
Normal file
5
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TenantDetailClient from "@/components/tenants/TenantDetailClient";
|
||||
|
||||
export default function TenantDetailPage() {
|
||||
return <TenantDetailClient />;
|
||||
}
|
||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
12
src/app/api/packages/route.ts
Normal file
12
src/app/api/packages/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(PACKAGE_CATALOG);
|
||||
}
|
||||
95
src/app/api/tenants/[name]/route.ts
Normal file
95
src/app/api/tenants/[name]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(tenant);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "K8s API error", detail: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.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")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Ownership check
|
||||
const existing = await getTenant(name);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
!isPlatformRole(userRoles) &&
|
||||
existing.metadata?.labels?.["zitadel-org-id"] !== 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)
|
||||
specPatch.workspaceFiles = body.workspaceFiles;
|
||||
if (body.displayName !== undefined)
|
||||
specPatch.displayName = body.displayName;
|
||||
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
|
||||
|
||||
const updated = await patchTenantSpec(name, specPatch);
|
||||
return NextResponse.json(updated);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Patch failed", detail: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.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")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { packageId, secrets } = body as {
|
||||
packageId: string;
|
||||
secrets: Record<string, string>;
|
||||
};
|
||||
|
||||
if (!packageId || !secrets || typeof secrets !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing packageId or secrets" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate package exists and requires secrets
|
||||
const pkgDef = getPackageDef(packageId);
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required secrets: ${missingKeys.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isPlatform &&
|
||||
tenant.metadata?.labels?.["zitadel-org-id"] !== 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(
|
||||
{ error: "Failed to store secrets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
56
src/app/api/tenants/route.ts
Normal file
56
src/app/api/tenants/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants, getTenant, createTenant } from "@/lib/k8s";
|
||||
import type { PiecedTenantSpec } from "@/types";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tenants = await listTenants();
|
||||
|
||||
if (user.isPlatform) {
|
||||
return NextResponse.json(tenants);
|
||||
}
|
||||
|
||||
// Customers see only their own tenant
|
||||
const own = tenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
return NextResponse.json(own);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
spec: PiecedTenantSpec;
|
||||
};
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await getTenant(body.name);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenant = await createTenant(body.name, body.spec, {
|
||||
"pieced.ch/zitadel-org-id": user.orgId,
|
||||
});
|
||||
return NextResponse.json(tenant, { status: 201 });
|
||||
}
|
||||
107
src/app/api/usage/route.ts
Normal file
107
src/app/api/usage/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.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 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId query param required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const spendLogs = await getTeamSpendLogs(
|
||||
teamId,
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[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,
|
||||
}));
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
teamId,
|
||||
currentPeriod: {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE,
|
||||
outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE,
|
||||
totalCostCHF,
|
||||
},
|
||||
budget: {
|
||||
maxBudget: teamInfo?.max_budget ?? null,
|
||||
spend: teamInfo?.spend ?? 0,
|
||||
remaining: teamInfo?.max_budget
|
||||
? teamInfo.max_budget - (teamInfo.spend ?? 0)
|
||||
: null,
|
||||
},
|
||||
rateLimits: {
|
||||
rpm: teamInfo?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
src/app/globals.css
Normal file
104
src/app/globals.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design direction: Swiss Industrial
|
||||
* Dark neutral base, sharp teal accent, geometric precision.
|
||||
* Outfit for headings (geometric, characterful), system for body.
|
||||
* No purple gradients, no Inter, no AI slop.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-surface-0: #0a0c10;
|
||||
--color-surface-1: #12151c;
|
||||
--color-surface-2: #1a1e28;
|
||||
--color-surface-3: #242a36;
|
||||
|
||||
--color-text-primary: #e8ecf4;
|
||||
--color-text-secondary: #8892a4;
|
||||
--color-text-muted: #565e6e;
|
||||
|
||||
--color-accent: #00d4aa;
|
||||
--color-accent-dim: #00b892;
|
||||
--color-accent-glow: #00d4aa26;
|
||||
|
||||
--color-border: #1e2330;
|
||||
--color-border-active: #2c3344;
|
||||
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
|
||||
--font-display: "Outfit", system-ui, sans-serif;
|
||||
--font-body: "Outfit", system-ui, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-surface-0);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Utility: subtle noise texture overlay */
|
||||
.bg-noise::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Accent underline for headings */
|
||||
.accent-rule {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.accent-rule::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Card hover lift */
|
||||
.card-interactive {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--color-border-active);
|
||||
}
|
||||
|
||||
/* Status dot pulse */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.status-pulse {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Staggered fade-in for page content */
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-in {
|
||||
animation: fade-up 0.4s ease-out both;
|
||||
}
|
||||
.animate-in-delay-1 { animation-delay: 0.05s; }
|
||||
.animate-in-delay-2 { animation-delay: 0.1s; }
|
||||
.animate-in-delay-3 { animation-delay: 0.15s; }
|
||||
9
src/app/layout.tsx
Normal file
9
src/app/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
193
src/components/admin/AdminTenantsClient.tsx
Normal file
193
src/components/admin/AdminTenantsClient.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
124
src/components/dashboard/DashboardClient.tsx
Normal file
124
src/components/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
111
src/components/dashboard/InstanceStatus.tsx
Normal file
111
src/components/dashboard/InstanceStatus.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
216
src/components/dashboard/UsageDisplay.tsx
Normal file
216
src/components/dashboard/UsageDisplay.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
104
src/components/layout/nav-shell.tsx
Normal file
104
src/components/layout/nav-shell.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
|
||||
function NavBar() {
|
||||
const t = useTranslations("common");
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const user = (session as any)?.platformUser;
|
||||
|
||||
const isLogin = pathname === "/login";
|
||||
if (isLogin) return null;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
{/* Logo / brand */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||
{/* Geometric mark */}
|
||||
<div className="relative h-7 w-7">
|
||||
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
|
||||
<div className="absolute inset-[3px] rounded-sm bg-accent" />
|
||||
</div>
|
||||
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
||||
{t("appName")}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-[11px] font-medium tracking-widest uppercase text-text-muted">
|
||||
{t("tagline")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.orgName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${
|
||||
active
|
||||
? "bg-surface-3 text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
224
src/components/packages/PackageCard.tsx
Normal file
224
src/components/packages/PackageCard.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/components/packages/WorkspaceEditor.tsx
Normal file
87
src/components/packages/WorkspaceEditor.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
196
src/components/tenants/TenantDetailClient.tsx
Normal file
196
src/components/tenants/TenantDetailClient.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/card.tsx
Normal file
37
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
interactive = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-xl border border-border bg-surface-1 p-6
|
||||
${interactive ? "card-interactive cursor-pointer" : ""}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={`text-xs font-semibold uppercase tracking-wider text-text-muted mb-3 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
91
src/components/ui/language-switcher.tsx
Normal file
91
src/components/ui/language-switcher.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
de: "DE",
|
||||
fr: "FR",
|
||||
it: "IT",
|
||||
en: "EN",
|
||||
};
|
||||
|
||||
const LOCALE_NAMES: Record<string, string> = {
|
||||
de: "Deutsch",
|
||||
fr: "Français",
|
||||
it: "Italiano",
|
||||
en: "English",
|
||||
};
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function switchLocale(next: string) {
|
||||
setOpen(false);
|
||||
router.replace(pathname, { locale: next as any });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="
|
||||
flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
|
||||
text-text-secondary hover:text-text-primary hover:bg-surface-2
|
||||
transition-colors cursor-pointer
|
||||
"
|
||||
>
|
||||
<span className="font-mono">{LOCALE_LABELS[locale]}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-32 rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/30 overflow-hidden z-50">
|
||||
{Object.entries(LOCALE_NAMES).map(([code, name]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => switchLocale(code)}
|
||||
className={`
|
||||
w-full px-3 py-2 text-left text-xs transition-colors cursor-pointer
|
||||
flex items-center justify-between
|
||||
${
|
||||
code === locale
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span className="font-mono text-text-muted">
|
||||
{LOCALE_LABELS[code]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/status-badge.tsx
Normal file
29
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const phaseStyles: Record<string, string> = {
|
||||
Running:
|
||||
"bg-success/10 text-success border-success/20",
|
||||
Provisioning:
|
||||
"bg-warning/10 text-warning border-warning/20",
|
||||
Pending:
|
||||
"bg-text-muted/10 text-text-secondary border-border",
|
||||
Error:
|
||||
"bg-error/10 text-error border-error/20",
|
||||
Deleting:
|
||||
"bg-text-muted/10 text-text-muted border-border",
|
||||
};
|
||||
|
||||
export function StatusBadge({ phase }: { phase: string }) {
|
||||
const style = phaseStyles[phase] ?? phaseStyles.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}`}
|
||||
>
|
||||
{phase === "Running" && (
|
||||
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-success" />
|
||||
)}
|
||||
{phase === "Provisioning" && (
|
||||
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
)}
|
||||
{phase}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
5
src/i18n/navigation.ts
Normal file
5
src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
14
src/i18n/request.ts
Normal file
14
src/i18n/request.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
if (!locale || !routing.locales.includes(locale as any)) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
7
src/i18n/routing.ts
Normal file
7
src/i18n/routing.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["de", "fr", "it", "en"],
|
||||
defaultLocale: "de",
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
75
src/lib/auth.ts
Normal file
75
src/lib/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import NextAuth from "next-auth";
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
|
||||
|
||||
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
|
||||
|
||||
function extractRoles(
|
||||
rolesObj?: Record<string, Record<string, string>>
|
||||
): PlatformRole[] {
|
||||
if (!rolesObj) return [];
|
||||
return Object.keys(rolesObj) as PlatformRole[];
|
||||
}
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
providers: [
|
||||
{
|
||||
id: "zitadel",
|
||||
name: "ZITADEL",
|
||||
type: "oidc",
|
||||
issuer: process.env.ZITADEL_ISSUER!,
|
||||
clientId: process.env.ZITADEL_CLIENT_ID!,
|
||||
clientSecret: process.env.ZITADEL_CLIENT_SECRET!,
|
||||
authorization: {
|
||||
params: {
|
||||
scope:
|
||||
"openid profile email urn:zitadel:iam:org:project:roles urn:zitadel:iam:user:resourceowner",
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && profile) {
|
||||
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"];
|
||||
token.roles = extractRoles(
|
||||
claims["urn:zitadel:iam:org:project:roles"]
|
||||
);
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as PlatformRole[]) ?? [];
|
||||
const sessionUser: SessionUser = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
email: session.user?.email ?? "",
|
||||
orgId: token.orgId as string,
|
||||
orgName: token.orgName as string,
|
||||
roles,
|
||||
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
|
||||
};
|
||||
(session as any).platformUser = sessionUser;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 8 * 60 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
||||
132
src/lib/k8s.ts
Normal file
132
src/lib/k8s.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import type { PiecedTenant, PiecedTenantSpec } from "@/types";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
if (process.env.KUBERNETES_SERVICE_HOST) {
|
||||
kc.loadFromCluster();
|
||||
} else {
|
||||
kc.loadFromDefault();
|
||||
}
|
||||
|
||||
const API_VERSION = "pieced.ch/v1alpha1";
|
||||
const PLURAL = "piecedtenants";
|
||||
|
||||
// Raw K8s API client — avoids @kubernetes/client-node API surface instability
|
||||
// across versions. The REST API itself is stable.
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const cluster = kc.getCurrentCluster();
|
||||
if (!cluster) throw new Error("No active K8s cluster in kubeconfig");
|
||||
return cluster.server;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
// In-cluster: read SA token
|
||||
if (process.env.KUBERNETES_SERVICE_HOST) {
|
||||
const token = readFileSync(
|
||||
"/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
"utf8"
|
||||
);
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
// Local dev: extract token from kubeconfig current user
|
||||
const user = kc.getCurrentUser();
|
||||
if (user?.token) {
|
||||
return { Authorization: `Bearer ${user.token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function k8sRequest<T>(
|
||||
path: string,
|
||||
method: string = "GET",
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(`K8s ${method} ${path}: ${res.status} ${text}`);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listTenants(): Promise<PiecedTenant[]> {
|
||||
const result = await k8sRequest<{ items: PiecedTenant[] }>("");
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
export async function getTenant(name: string): Promise<PiecedTenant | null> {
|
||||
try {
|
||||
return await k8sRequest<PiecedTenant>(`/${name}`);
|
||||
} catch (e: any) {
|
||||
if (e.statusCode === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTenant(
|
||||
name: string,
|
||||
spec: PiecedTenantSpec,
|
||||
labels?: Record<string, string>
|
||||
): Promise<PiecedTenant> {
|
||||
return k8sRequest<PiecedTenant>("", "POST", {
|
||||
apiVersion: API_VERSION,
|
||||
kind: "PiecedTenant",
|
||||
metadata: { name, labels },
|
||||
spec,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenantSpec(
|
||||
name: string,
|
||||
spec: Partial<PiecedTenantSpec>
|
||||
): Promise<PiecedTenant> {
|
||||
const existing = await getTenant(name);
|
||||
if (!existing) throw new Error(`Tenant ${name} not found`);
|
||||
|
||||
return k8sRequest<PiecedTenant>(`/${name}`, "PUT", {
|
||||
...existing,
|
||||
spec: { ...existing.spec, ...spec },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTenant(name: string): Promise<void> {
|
||||
await k8sRequest(`/${name}`, "DELETE");
|
||||
}
|
||||
|
||||
export async function patchTenantSpec(
|
||||
name: string,
|
||||
spec: Partial<PiecedTenantSpec>
|
||||
): Promise<PiecedTenant> {
|
||||
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`;
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/merge-patch+json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({ spec }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(`K8s PATCH /${name}: ${res.status} ${text}`);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
33
src/lib/litellm.ts
Normal file
33
src/lib/litellm.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const LITELLM_URL =
|
||||
process.env.LITELLM_INTERNAL_URL ?? "http://litellm.inference.svc:4000";
|
||||
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY!;
|
||||
|
||||
async function litellmFetch(path: string, init?: RequestInit) {
|
||||
const res = await fetch(`${LITELLM_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`LiteLLM ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTeamInfo(teamId: string) {
|
||||
return litellmFetch(`/team/info?team_id=${encodeURIComponent(teamId)}`);
|
||||
}
|
||||
|
||||
export async function getTeamSpendLogs(
|
||||
teamId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
) {
|
||||
const params = new URLSearchParams({ team_id: teamId });
|
||||
if (startDate) params.set("start_date", startDate);
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
return litellmFetch(`/global/spend/logs?${params}`);
|
||||
}
|
||||
92
src/lib/openbao.ts
Normal file
92
src/lib/openbao.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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 K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal";
|
||||
const K8S_AUTH_MOUNT = process.env.OPENBAO_K8S_MOUNT || "kubernetes";
|
||||
|
||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
async function authenticate(): Promise<string> {
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
const jwt = readFileSync(SA_TOKEN_PATH, "utf-8").trim();
|
||||
|
||||
const res = await fetch(
|
||||
`${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: K8S_AUTH_ROLE, jwt }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`OpenBao K8s auth failed: ${res.status} ${body}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const token = data.auth.client_token as string;
|
||||
const leaseDuration = (data.auth.lease_duration as number) || 3600;
|
||||
|
||||
cachedToken = {
|
||||
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,
|
||||
secrets: Record<string, string>
|
||||
): Promise<void> {
|
||||
const token = await authenticate();
|
||||
const path = `secret/data/tenants/${tenantId}/${packageId}`;
|
||||
|
||||
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vault-Token": token,
|
||||
},
|
||||
body: JSON.stringify({ data: secrets }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`OpenBao write failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text();
|
||||
throw new Error(`OpenBao delete failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
104
src/lib/packages.ts
Normal file
104
src/lib/packages.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface PackageDef {
|
||||
id: string;
|
||||
name: string;
|
||||
descriptionKey: string; // i18n key
|
||||
icon: string; // emoji or lucide icon name
|
||||
requiresSecrets: boolean;
|
||||
secrets?: {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
placeholderKey: string;
|
||||
}[];
|
||||
customerInstructionsKey?: string; // i18n key for how-to
|
||||
disclaimerKey?: string; // i18n key
|
||||
category: "channel" | "skill";
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
descriptionKey: "packages.telegram.description",
|
||||
icon: "MessageCircle",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "bot-token",
|
||||
labelKey: "packages.telegram.botTokenLabel",
|
||||
placeholderKey: "packages.telegram.botTokenPlaceholder",
|
||||
},
|
||||
],
|
||||
customerInstructionsKey: "packages.telegram.instructions",
|
||||
disclaimerKey: "packages.telegram.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
descriptionKey: "packages.discord.description",
|
||||
icon: "Hash",
|
||||
requiresSecrets: true,
|
||||
secrets: [
|
||||
{
|
||||
key: "bot-token",
|
||||
labelKey: "packages.discord.botTokenLabel",
|
||||
placeholderKey: "packages.discord.botTokenPlaceholder",
|
||||
},
|
||||
],
|
||||
customerInstructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
customerInstructionsKey: "packages.email.instructions",
|
||||
disclaimerKey: "packages.email.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
{
|
||||
id: "web-search",
|
||||
name: "Web Search",
|
||||
descriptionKey: "packages.webSearch.description",
|
||||
icon: "Search",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
{
|
||||
id: "document-processing",
|
||||
name: "Document Processing",
|
||||
descriptionKey: "packages.documentProcessing.description",
|
||||
icon: "FileText",
|
||||
requiresSecrets: false,
|
||||
category: "skill",
|
||||
},
|
||||
];
|
||||
|
||||
export function getPackageDef(id: string): PackageDef | undefined {
|
||||
return PACKAGE_CATALOG.find((p) => p.id === id);
|
||||
}
|
||||
19
src/lib/session.ts
Normal file
19
src/lib/session.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const session = await auth();
|
||||
return (session as any)?.platformUser ?? null;
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requirePlatformRole(): Promise<SessionUser> {
|
||||
const user = await requireSession();
|
||||
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
|
||||
return user;
|
||||
}
|
||||
117
src/messages/de.json
Normal file
117
src/messages/de.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "KI-Plattform",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Laden…",
|
||||
"language": "Sprache",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
"subtitle": "Melden Sie sich an, um Ihren KI-Assistenten zu verwalten",
|
||||
"button": "Weiter mit ZITADEL",
|
||||
"footer": "On-Premises gehostet in der Schweiz"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Willkommen, {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.",
|
||||
"comingSoon": "Detailansicht folgt in Session 6.2",
|
||||
"getStarted": "Loslegen",
|
||||
"agentName": "Agent",
|
||||
"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"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Pakete",
|
||||
"workspaceFiles": "Workspace-Dateien",
|
||||
"notFound": "Mandant nicht gefunden."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"packages": {
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"enableAndSave": "Aktivieren & Speichern",
|
||||
"configure": "Konfigurieren",
|
||||
"requiresApiKey": "API-Schlüssel erforderlich",
|
||||
"missingFields": "Bitte füllen Sie alle Pflichtfelder aus.",
|
||||
"status": {
|
||||
"pending": "Ausstehend",
|
||||
"active": "Aktiv",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"telegram": {
|
||||
"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."
|
||||
},
|
||||
"discord": {
|
||||
"description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit einem Discord-Server.",
|
||||
"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."
|
||||
},
|
||||
"email": {
|
||||
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
|
||||
"smtpHostLabel": "SMTP-Host",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP-Benutzername",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "SMTP-Passwort",
|
||||
"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."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web nach aktuellen Informationen 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"
|
||||
}
|
||||
}
|
||||
117
src/messages/en.json
Normal file
117
src/messages/en.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "AI Platform",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Loading…",
|
||||
"language": "Language",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"error": "An error occurred"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
"subtitle": "Sign in to manage your AI assistant",
|
||||
"button": "Continue with ZITADEL",
|
||||
"footer": "Hosted on-premises in Switzerland"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {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.",
|
||||
"comingSoon": "Detailed view coming in Session 6.2",
|
||||
"getStarted": "Get Started",
|
||||
"agentName": "Agent",
|
||||
"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"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Packages",
|
||||
"workspaceFiles": "Workspace Files",
|
||||
"notFound": "Tenant not found."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"packages": {
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enableAndSave": "Enable & Save",
|
||||
"configure": "Configure",
|
||||
"requiresApiKey": "Requires API key",
|
||||
"missingFields": "Please fill in all required fields.",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"error": "Error"
|
||||
},
|
||||
"telegram": {
|
||||
"description": "Connect your AI assistant to a Telegram bot for messaging.",
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"email": {
|
||||
"description": "Enable your AI assistant to send and receive email.",
|
||||
"smtpHostLabel": "SMTP Host",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "SMTP Username",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "SMTP Password",
|
||||
"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."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Give your AI assistant the ability to search the web for current information."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Enable document parsing, summarization, and extraction capabilities."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
117
src/messages/fr.json
Normal file
117
src/messages/fr.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "Plateforme IA",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"dashboard": "Tableau de bord",
|
||||
"admin": "Admin",
|
||||
"loading": "Chargement…",
|
||||
"language": "Langue",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"error": "Une erreur est survenue"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
"subtitle": "Connectez-vous pour gérer votre assistant IA",
|
||||
"button": "Continuer avec ZITADEL",
|
||||
"footer": "Hébergé sur site en Suisse"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"welcome": "Bienvenue, {name}",
|
||||
"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.",
|
||||
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
|
||||
"getStarted": "Commencer",
|
||||
"agentName": "Agent",
|
||||
"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"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
"packages": "Paquets",
|
||||
"workspaceFiles": "Fichiers Workspace",
|
||||
"notFound": "Tenant introuvable."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"packages": {
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"enableAndSave": "Activer & Enregistrer",
|
||||
"configure": "Configurer",
|
||||
"requiresApiKey": "Clé API requise",
|
||||
"missingFields": "Veuillez remplir tous les champs obligatoires.",
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"active": "Actif",
|
||||
"error": "Erreur"
|
||||
},
|
||||
"telegram": {
|
||||
"description": "Connectez votre assistant IA à un bot Telegram pour la messagerie.",
|
||||
"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."
|
||||
},
|
||||
"discord": {
|
||||
"description": "Connectez votre assistant IA à un serveur 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."
|
||||
},
|
||||
"email": {
|
||||
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
|
||||
"smtpHostLabel": "Hôte SMTP",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Utilisateur SMTP",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "Mot de passe SMTP",
|
||||
"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."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Donnez à votre assistant IA la possibilité de rechercher des informations actuelles sur le web."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Activez les capacités d'analyse, de résumé et d'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"
|
||||
}
|
||||
}
|
||||
117
src/messages/it.json
Normal file
117
src/messages/it.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "Piattaforma IA",
|
||||
"login": "Accedi",
|
||||
"logout": "Esci",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Caricamento…",
|
||||
"language": "Lingua",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"error": "Si è verificato un errore"
|
||||
},
|
||||
"login": {
|
||||
"title": "PieCed Portal",
|
||||
"subtitle": "Accedi per gestire il tuo assistente IA",
|
||||
"button": "Continua con ZITADEL",
|
||||
"footer": "Ospitato on-premises in Svizzera"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Benvenuto, {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.",
|
||||
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||
"getStarted": "Inizia",
|
||||
"agentName": "Agente",
|
||||
"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"
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
"packages": "Pacchetti",
|
||||
"workspaceFiles": "File Workspace",
|
||||
"notFound": "Tenant non trovato."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"packages": {
|
||||
"enable": "Attiva",
|
||||
"disable": "Disattiva",
|
||||
"enableAndSave": "Attiva & Salva",
|
||||
"configure": "Configura",
|
||||
"requiresApiKey": "Chiave API richiesta",
|
||||
"missingFields": "Compilare tutti i campi obbligatori.",
|
||||
"status": {
|
||||
"pending": "In attesa",
|
||||
"active": "Attivo",
|
||||
"error": "Errore"
|
||||
},
|
||||
"telegram": {
|
||||
"description": "Collega il tuo assistente IA a un bot Telegram per la messaggistica.",
|
||||
"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."
|
||||
},
|
||||
"discord": {
|
||||
"description": "Collega il tuo assistente IA a un server 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."
|
||||
},
|
||||
"email": {
|
||||
"description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.",
|
||||
"smtpHostLabel": "Host SMTP",
|
||||
"smtpHostPlaceholder": "smtp.example.com",
|
||||
"smtpUserLabel": "Utente SMTP",
|
||||
"smtpUserPlaceholder": "user@example.com",
|
||||
"smtpPasswordLabel": "Password SMTP",
|
||||
"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."
|
||||
},
|
||||
"webSearch": {
|
||||
"description": "Dai al tuo assistente IA la possibilità di cercare informazioni attuali sul web."
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Attiva le funzionalità di 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"
|
||||
}
|
||||
}
|
||||
43
src/middleware.ts
Normal file
43
src/middleware.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import createIntlMiddleware from "next-intl/middleware";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
const publicPaths = ["/login", "/api/auth"];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
// Strip locale prefix for comparison
|
||||
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
|
||||
return (
|
||||
publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) ||
|
||||
pathname.startsWith("/api/auth")
|
||||
);
|
||||
}
|
||||
|
||||
export default async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// NextAuth API routes pass through directly
|
||||
if (pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Auth guard for protected paths
|
||||
if (!isPublicPath(pathname)) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|favicon.ico|api/auth).*)"],
|
||||
};
|
||||
66
src/types/index.ts
Normal file
66
src/types/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ZITADEL JWT custom claims
|
||||
export interface ZitadelClaims {
|
||||
"urn:zitadel:iam:user:resourceowner:id": string;
|
||||
"urn:zitadel:iam:user:resourceowner:name": string;
|
||||
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export type PlatformRole =
|
||||
| "platform_admin"
|
||||
| "platform_operator"
|
||||
| "owner"
|
||||
| "user"
|
||||
| "viewer";
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
roles: PlatformRole[];
|
||||
isPlatform: boolean;
|
||||
}
|
||||
|
||||
// PiecedTenant CR (pieced.ch/v1alpha1)
|
||||
export interface PiecedTenantSpec {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
plan?: string;
|
||||
packages?: string[];
|
||||
workspaceFiles?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PiecedTenantStatus {
|
||||
phase: "Pending" | "Provisioning" | "Running" | "Error" | "Deleting";
|
||||
message?: string;
|
||||
observedGeneration?: number;
|
||||
conditions?: Array<{
|
||||
type: string;
|
||||
status: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
lastTransitionTime?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PiecedTenant {
|
||||
apiVersion: "pieced.ch/v1alpha1";
|
||||
kind: "PiecedTenant";
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
spec: PiecedTenantSpec;
|
||||
status?: PiecedTenantStatus;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalSpendChf: number;
|
||||
period: string;
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user