Add Health and Spend for Admins
This commit is contained in:
@@ -1,58 +1,31 @@
|
||||
# Session 6.6 — Items 3 & 4: AGENTS.md / TOOLS.md in Wizard + Default Templates
|
||||
# Session 6.6 — Items 5 & 6: System Health + Spend Column
|
||||
|
||||
## Manual Steps (in order)
|
||||
## Files
|
||||
|
||||
### 1. Deploy the updated portal code
|
||||
Copy the files from this ZIP into your `pieced-portal` repo, overwriting existing files.
|
||||
All paths match the project structure — drop-in replacements.
|
||||
| File | Action | What |
|
||||
|------|--------|------|
|
||||
| `src/lib/litellm.ts` | REPLACE | Added `listTeams()`, `getLitellmHealth()`, `getGlobalSpend()`, `getPerTeamSpend()` |
|
||||
| `src/app/api/admin/health/route.ts` | **NEW** | Returns tenant phase counts, aggregate + per-tenant spend, vLLM & LiteLLM health |
|
||||
| `src/components/admin/admin-panel.tsx` | REPLACE | Added "Health" tab (service indicators, tenant overview, spend cards) + "Spend (CHF)" column in tenants table |
|
||||
| `patch-i18n-admin-health.mjs` | **RUN ONCE** | Patches all 4 i18n files with new admin keys |
|
||||
|
||||
### 2. The DB migration is automatic
|
||||
The updated `db.ts` adds these idempotently on first query:
|
||||
- Column `agents_md TEXT` on `tenant_requests`
|
||||
- Table `workspace_templates` (file_key TEXT PK, content TEXT, updated_at TIMESTAMPTZ)
|
||||
## Steps
|
||||
|
||||
No manual `ALTER TABLE` needed.
|
||||
1. Drop in the 3 source files (overwrite existing)
|
||||
2. Run the i18n patcher from the portal root:
|
||||
```bash
|
||||
node patch-i18n-admin-health.mjs
|
||||
```
|
||||
3. Build and deploy
|
||||
|
||||
### 3. Seed the workspace templates
|
||||
After the portal has started (so the table exists):
|
||||
## Environment Variables (optional)
|
||||
|
||||
```bash
|
||||
kubectl exec -i portal-db-1 -n portal -- psql -U portal -d portal < seed-workspace-templates.sql
|
||||
```
|
||||
- `VLLM_HEALTH_URL` — defaults to `http://vllm.inference.svc:8000`. Set if your vLLM is elsewhere.
|
||||
- `LITELLM_INTERNAL_URL` / `LITELLM_MASTER_KEY` — already configured.
|
||||
|
||||
Or connect interactively and paste the SQL.
|
||||
## Notes
|
||||
|
||||
### 4. Edit templates as needed
|
||||
To update a template later, just UPDATE the row:
|
||||
|
||||
```sql
|
||||
UPDATE workspace_templates
|
||||
SET content = '# Your new SOUL.md content here...', updated_at = now()
|
||||
WHERE file_key = 'SOUL.md';
|
||||
```
|
||||
|
||||
The portal reads templates on every wizard load / approval — no restart needed.
|
||||
|
||||
---
|
||||
|
||||
## File Manifest
|
||||
|
||||
| File | Action | What changed |
|
||||
|------|--------|-------------|
|
||||
| `src/lib/workspace-defaults.ts` | **NEW** | Default content fetching from DB + TOOLS.md generation |
|
||||
| `src/lib/db.ts` | REPLACE | Added `agents_md` column, `workspace_templates` table + CRUD |
|
||||
| `src/types/index.ts` | REPLACE | Added `agentsMd` to `TenantRequest` and `OnboardingInput` |
|
||||
| `src/app/api/onboarding/route.ts` | REPLACE | Accepts `agentsMd` field |
|
||||
| `src/app/api/admin/requests/[id]/approve/route.ts` | REPLACE | Builds all 3 workspace files (SOUL/AGENTS/TOOLS) |
|
||||
| `src/app/api/workspace-defaults/route.ts` | **NEW** | API to fetch defaults for wizard pre-fill |
|
||||
| `src/components/onboarding/wizard.tsx` | REPLACE | "Advanced Configuration" accordion with AGENTS.md textarea + readonly TOOLS.md preview |
|
||||
| `src/messages/{de,en,fr,it}.json` | REPLACE | Added `agentsMd`, `agentsMdHint`, `toolsMd`, `toolsMdHint`, `advancedConfig`, `readonlyNote` |
|
||||
| `seed-workspace-templates.sql` | **NEW** | SQL to seed default templates |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **TOOLS.md is readonly** in both the wizard and the tenant detail page. It's auto-generated from the base template + per-package sections. Users see it but can't edit it.
|
||||
- **AGENTS.md is editable** in the wizard (under "Advanced Configuration" accordion) and on the tenant detail workspace editor.
|
||||
- **Templates live in the DB** (`workspace_templates` table) so you can edit them without redeploying. Hardcoded fallbacks exist in `workspace-defaults.ts` in case the DB rows are missing.
|
||||
- **TOOLS.md is regenerated on approval** based on the packages selected, so it's always consistent with what's actually enabled.
|
||||
- The workspace editor on the tenant detail page already supports arbitrary `workspaceFiles` keys from the CR spec, so AGENTS.md and TOOLS.md will appear there automatically. TOOLS.md should be made readonly there too — that's a separate small change to the workspace editor component (mark `TOOLS.md` as readonly based on the filename).
|
||||
- The health API uses `Promise.allSettled` so a single service being down won't break the whole page.
|
||||
- Per-tenant spend is fetched from LiteLLM's `/team/list` which returns the cumulative `spend` per team. This is mapped to tenant names via `status.litellmTeamId` on the PiecedTenant CR.
|
||||
- The spend column in the tenants table piggybacks on the same health data — fetched once when switching to the tenants tab.
|
||||
- If LiteLLM's `/team/list` or `/global/spend` response format differs from what I assumed, you may need to adjust the parsing in `litellm.ts`. The functions have fallbacks for common response shapes.
|
||||
|
||||
58
deploy/README_sql.md
Normal file
58
deploy/README_sql.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Session 6.6 — Items 3 & 4: AGENTS.md / TOOLS.md in Wizard + Default Templates
|
||||
|
||||
## Manual Steps (in order)
|
||||
|
||||
### 1. Deploy the updated portal code
|
||||
Copy the files from this ZIP into your `pieced-portal` repo, overwriting existing files.
|
||||
All paths match the project structure — drop-in replacements.
|
||||
|
||||
### 2. The DB migration is automatic
|
||||
The updated `db.ts` adds these idempotently on first query:
|
||||
- Column `agents_md TEXT` on `tenant_requests`
|
||||
- Table `workspace_templates` (file_key TEXT PK, content TEXT, updated_at TIMESTAMPTZ)
|
||||
|
||||
No manual `ALTER TABLE` needed.
|
||||
|
||||
### 3. Seed the workspace templates
|
||||
After the portal has started (so the table exists):
|
||||
|
||||
```bash
|
||||
kubectl exec -i portal-db-1 -n portal -- psql -U portal -d portal < seed-workspace-templates.sql
|
||||
```
|
||||
|
||||
Or connect interactively and paste the SQL.
|
||||
|
||||
### 4. Edit templates as needed
|
||||
To update a template later, just UPDATE the row:
|
||||
|
||||
```sql
|
||||
UPDATE workspace_templates
|
||||
SET content = '# Your new SOUL.md content here...', updated_at = now()
|
||||
WHERE file_key = 'SOUL.md';
|
||||
```
|
||||
|
||||
The portal reads templates on every wizard load / approval — no restart needed.
|
||||
|
||||
---
|
||||
|
||||
## File Manifest
|
||||
|
||||
| File | Action | What changed |
|
||||
|------|--------|-------------|
|
||||
| `src/lib/workspace-defaults.ts` | **NEW** | Default content fetching from DB + TOOLS.md generation |
|
||||
| `src/lib/db.ts` | REPLACE | Added `agents_md` column, `workspace_templates` table + CRUD |
|
||||
| `src/types/index.ts` | REPLACE | Added `agentsMd` to `TenantRequest` and `OnboardingInput` |
|
||||
| `src/app/api/onboarding/route.ts` | REPLACE | Accepts `agentsMd` field |
|
||||
| `src/app/api/admin/requests/[id]/approve/route.ts` | REPLACE | Builds all 3 workspace files (SOUL/AGENTS/TOOLS) |
|
||||
| `src/app/api/workspace-defaults/route.ts` | **NEW** | API to fetch defaults for wizard pre-fill |
|
||||
| `src/components/onboarding/wizard.tsx` | REPLACE | "Advanced Configuration" accordion with AGENTS.md textarea + readonly TOOLS.md preview |
|
||||
| `src/messages/{de,en,fr,it}.json` | REPLACE | Added `agentsMd`, `agentsMdHint`, `toolsMd`, `toolsMdHint`, `advancedConfig`, `readonlyNote` |
|
||||
| `seed-workspace-templates.sql` | **NEW** | SQL to seed default templates |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **TOOLS.md is readonly** in both the wizard and the tenant detail page. It's auto-generated from the base template + per-package sections. Users see it but can't edit it.
|
||||
- **AGENTS.md is editable** in the wizard (under "Advanced Configuration" accordion) and on the tenant detail workspace editor.
|
||||
- **Templates live in the DB** (`workspace_templates` table) so you can edit them without redeploying. Hardcoded fallbacks exist in `workspace-defaults.ts` in case the DB rows are missing.
|
||||
- **TOOLS.md is regenerated on approval** based on the packages selected, so it's always consistent with what's actually enabled.
|
||||
- The workspace editor on the tenant detail page already supports arbitrary `workspaceFiles` keys from the CR spec, so AGENTS.md and TOOLS.md will appear there automatically. TOOLS.md should be made readonly there too — that's a separate small change to the workspace editor component (mark `TOOLS.md` as readonly based on the filename).
|
||||
86
deploy/patch-i18n-admin-health.mjs
Normal file
86
deploy/patch-i18n-admin-health.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node patch-i18n-admin-health.mjs
|
||||
* Adds health/spend keys to all 4 message files.
|
||||
* Run from the pieced-portal root.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const newKeys = {
|
||||
en: {
|
||||
health: "Health",
|
||||
serviceHealth: "Service Health",
|
||||
vllmDescription: "GPU inference engine",
|
||||
litellmDescription: "LLM proxy & spend tracking",
|
||||
tenantOverview: "Tenant Overview",
|
||||
spendOverview: "Spend Overview",
|
||||
globalSpend: "Global Spend (CHF)",
|
||||
activeTenants: "Active Tenants",
|
||||
tenantsWithSpend: "tenants with recorded spend",
|
||||
refresh: "Refresh",
|
||||
healthUnavailable: "Health data unavailable.",
|
||||
loadingHealth: "Loading health data…",
|
||||
statusHealthy: "Healthy",
|
||||
statusDown: "Down",
|
||||
spendChf: "Spend (CHF)",
|
||||
},
|
||||
de: {
|
||||
health: "Status",
|
||||
serviceHealth: "Dienststatus",
|
||||
vllmDescription: "GPU-Inferenz-Engine",
|
||||
litellmDescription: "LLM-Proxy & Kostenerfassung",
|
||||
tenantOverview: "Mandanten-Übersicht",
|
||||
spendOverview: "Kostenübersicht",
|
||||
globalSpend: "Gesamtkosten (CHF)",
|
||||
activeTenants: "Aktive Mandanten",
|
||||
tenantsWithSpend: "Mandanten mit erfassten Kosten",
|
||||
refresh: "Aktualisieren",
|
||||
healthUnavailable: "Statusdaten nicht verfügbar.",
|
||||
loadingHealth: "Statusdaten werden geladen…",
|
||||
statusHealthy: "OK",
|
||||
statusDown: "Ausgefallen",
|
||||
spendChf: "Kosten (CHF)",
|
||||
},
|
||||
fr: {
|
||||
health: "Santé",
|
||||
serviceHealth: "Santé des services",
|
||||
vllmDescription: "Moteur d'inférence GPU",
|
||||
litellmDescription: "Proxy LLM & suivi des coûts",
|
||||
tenantOverview: "Aperçu des locataires",
|
||||
spendOverview: "Aperçu des coûts",
|
||||
globalSpend: "Coûts globaux (CHF)",
|
||||
activeTenants: "Locataires actifs",
|
||||
tenantsWithSpend: "locataires avec dépenses enregistrées",
|
||||
refresh: "Actualiser",
|
||||
healthUnavailable: "Données de santé indisponibles.",
|
||||
loadingHealth: "Chargement des données de santé…",
|
||||
statusHealthy: "OK",
|
||||
statusDown: "Hors service",
|
||||
spendChf: "Coûts (CHF)",
|
||||
},
|
||||
it: {
|
||||
health: "Stato",
|
||||
serviceHealth: "Stato dei servizi",
|
||||
vllmDescription: "Motore di inferenza GPU",
|
||||
litellmDescription: "Proxy LLM & monitoraggio costi",
|
||||
tenantOverview: "Panoramica tenant",
|
||||
spendOverview: "Panoramica costi",
|
||||
globalSpend: "Costi globali (CHF)",
|
||||
activeTenants: "Tenant attivi",
|
||||
tenantsWithSpend: "tenant con spese registrate",
|
||||
refresh: "Aggiorna",
|
||||
healthUnavailable: "Dati di stato non disponibili.",
|
||||
loadingHealth: "Caricamento dati di stato…",
|
||||
statusHealthy: "OK",
|
||||
statusDown: "Non disponibile",
|
||||
spendChf: "Costi (CHF)",
|
||||
},
|
||||
};
|
||||
|
||||
for (const [lang, keys] of Object.entries(newKeys)) {
|
||||
const path = `src/messages/${lang}.json`;
|
||||
const json = JSON.parse(readFileSync(path, "utf8"));
|
||||
Object.assign(json.admin, keys);
|
||||
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
||||
console.log(`Patched ${path} — added ${Object.keys(keys).length} keys`);
|
||||
}
|
||||
92
src/app/api/admin/health/route.ts
Normal file
92
src/app/api/admin/health/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
getLitellmHealth,
|
||||
getGlobalSpend,
|
||||
getPerTeamSpend,
|
||||
} from "@/lib/litellm";
|
||||
|
||||
const VLLM_URL =
|
||||
process.env.VLLM_HEALTH_URL ?? "http://vllm-inference.inference.svc:8000";
|
||||
|
||||
async function checkVllmHealth(): Promise<{
|
||||
healthy: boolean;
|
||||
details?: any;
|
||||
}> {
|
||||
try {
|
||||
const res = await fetch(`${VLLM_URL}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) return { healthy: true };
|
||||
return { healthy: false, details: `HTTP ${res.status}` };
|
||||
} catch (e: any) {
|
||||
return { healthy: false, details: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/health
|
||||
* Returns system health overview for the admin panel.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [tenants, litellm, vllm, globalSpend, perTeamSpend] =
|
||||
await Promise.allSettled([
|
||||
listTenants(),
|
||||
getLitellmHealth(),
|
||||
checkVllmHealth(),
|
||||
getGlobalSpend(),
|
||||
getPerTeamSpend(),
|
||||
]);
|
||||
|
||||
const allTenants =
|
||||
tenants.status === "fulfilled" ? tenants.value : [];
|
||||
|
||||
// Count tenants by phase
|
||||
const phaseCounts: Record<string, number> = {};
|
||||
for (const t of allTenants) {
|
||||
const phase = t.spec.suspend
|
||||
? "Suspended"
|
||||
: t.status?.phase ?? "Pending";
|
||||
phaseCounts[phase] = (phaseCounts[phase] || 0) + 1;
|
||||
}
|
||||
|
||||
// Build per-tenant spend map (tenantName → spend)
|
||||
const spendMap: Record<string, number> = {};
|
||||
const teamSpend =
|
||||
perTeamSpend.status === "fulfilled" ? perTeamSpend.value : new Map();
|
||||
for (const t of allTenants) {
|
||||
const teamId = t.status?.litellmTeamId;
|
||||
if (teamId && teamSpend.has(teamId)) {
|
||||
spendMap[t.metadata.name] = teamSpend.get(teamId)!;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tenants: {
|
||||
total: allTenants.length,
|
||||
phases: phaseCounts,
|
||||
},
|
||||
spend: {
|
||||
global:
|
||||
globalSpend.status === "fulfilled" ? globalSpend.value : 0,
|
||||
perTenant: spendMap,
|
||||
},
|
||||
services: {
|
||||
litellm:
|
||||
litellm.status === "fulfilled"
|
||||
? litellm.value
|
||||
: { healthy: false, details: "fetch failed" },
|
||||
vllm:
|
||||
vllm.status === "fulfilled"
|
||||
? vllm.value
|
||||
: { healthy: false, details: "fetch failed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,18 @@ import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import Link from "next/link";
|
||||
|
||||
type Tab = "requests" | "tenants";
|
||||
type Tab = "requests" | "tenants" | "health";
|
||||
type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected";
|
||||
|
||||
interface HealthData {
|
||||
tenants: { total: number; phases: Record<string, number> };
|
||||
spend: { global: number; perTenant: Record<string, number> };
|
||||
services: {
|
||||
litellm: { healthy: boolean; details?: any };
|
||||
vllm: { healthy: boolean; details?: any };
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminPanelProps {
|
||||
initialTenants: PiecedTenant[];
|
||||
}
|
||||
@@ -30,6 +39,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
const [loadingTenants, setLoadingTenants] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState<string | null>(null);
|
||||
|
||||
// Health state
|
||||
const [health, setHealth] = useState<HealthData | null>(null);
|
||||
const [loadingHealth, setLoadingHealth] = useState(false);
|
||||
|
||||
// Shared
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -79,6 +92,34 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
}
|
||||
}, [tab, fetchTenants]);
|
||||
|
||||
// ─── Health fetching ───
|
||||
const fetchHealth = useCallback(async () => {
|
||||
setLoadingHealth(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/health");
|
||||
if (!res.ok) throw new Error("Failed to fetch health");
|
||||
const data = await res.json();
|
||||
setHealth(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingHealth(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "health") {
|
||||
fetchHealth();
|
||||
}
|
||||
}, [tab, fetchHealth]);
|
||||
|
||||
// Also fetch health for spend data when on tenants tab
|
||||
useEffect(() => {
|
||||
if (tab === "tenants" && !health) {
|
||||
fetchHealth();
|
||||
}
|
||||
}, [tab, health, fetchHealth]);
|
||||
|
||||
// ─── Request actions ───
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
@@ -212,6 +253,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("health")}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === "health"
|
||||
? "text-accent"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{t("health")}
|
||||
{tab === "health" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
@@ -435,6 +489,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("packages")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("spendChf")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("created")}
|
||||
</th>
|
||||
@@ -444,76 +501,85 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map((tenant) => (
|
||||
<tr
|
||||
key={tenant.metadata.name}
|
||||
className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${
|
||||
tenant.spec.suspend ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-accent">
|
||||
{tenant.metadata.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
<span>{tenant.spec.displayName}</span>
|
||||
{tenant.spec.suspend && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded">
|
||||
{t("suspendedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
|
||||
{tenant.spec.packages?.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{tenant.metadata.creationTimestamp
|
||||
? new Date(
|
||||
tenant.metadata.creationTimestamp
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Link
|
||||
href={`/tenants/${tenant.metadata.name}`}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
{t("manage")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSuspend(
|
||||
tenant.metadata.name,
|
||||
!tenant.spec.suspend
|
||||
)
|
||||
}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === tenant.metadata.name
|
||||
? "…"
|
||||
: tenant.spec.suspend
|
||||
? t("resume")
|
||||
: t("suspend")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal(tenant.metadata.name)
|
||||
}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("deleteTenant")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.map((tenant) => {
|
||||
const tenantSpend =
|
||||
health?.spend?.perTenant?.[tenant.metadata.name];
|
||||
return (
|
||||
<tr
|
||||
key={tenant.metadata.name}
|
||||
className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${
|
||||
tenant.spec.suspend ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-accent">
|
||||
{tenant.metadata.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
<span>{tenant.spec.displayName}</span>
|
||||
{tenant.spec.suspend && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded">
|
||||
{t("suspendedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
|
||||
{tenant.spec.packages?.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs font-mono tabular-nums text-text-secondary hidden md:table-cell">
|
||||
{tenantSpend !== undefined
|
||||
? `CHF ${tenantSpend.toFixed(2)}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{tenant.metadata.creationTimestamp
|
||||
? new Date(
|
||||
tenant.metadata.creationTimestamp
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Link
|
||||
href={`/tenants/${tenant.metadata.name}`}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
{t("manage")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSuspend(
|
||||
tenant.metadata.name,
|
||||
!tenant.spec.suspend
|
||||
)
|
||||
}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === tenant.metadata.name
|
||||
? "…"
|
||||
: tenant.spec.suspend
|
||||
? t("resume")
|
||||
: t("suspend")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal(tenant.metadata.name)
|
||||
}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("deleteTenant")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -522,6 +588,115 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── HEALTH TAB ───── */}
|
||||
{tab === "health" && (
|
||||
<>
|
||||
{loadingHealth ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-text-muted text-xs">{t("loadingHealth")}</p>
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="space-y-6">
|
||||
{/* Service health indicators */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("serviceHealth")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<ServiceCard
|
||||
name="vLLM"
|
||||
subtitle={t("vllmDescription")}
|
||||
healthy={health.services.vllm.healthy}
|
||||
t={t}
|
||||
/>
|
||||
<ServiceCard
|
||||
name="LiteLLM"
|
||||
subtitle={t("litellmDescription")}
|
||||
healthy={health.services.litellm.healthy}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenant overview */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("tenantOverview")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard
|
||||
label={t("totalTenants")}
|
||||
value={health.tenants.total}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("running")}
|
||||
value={
|
||||
(health.tenants.phases["Running"] ?? 0) +
|
||||
(health.tenants.phases["Ready"] ?? 0)
|
||||
}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("suspended")}
|
||||
value={health.tenants.phases["Suspended"] ?? 0}
|
||||
color="text-amber-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("errors")}
|
||||
value={health.tenants.phases["Error"] ?? 0}
|
||||
color="text-red-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spend overview */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("spendOverview")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("globalSpend")}
|
||||
</p>
|
||||
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
|
||||
CHF {health.spend.global.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
||||
{t("activeTenants")}
|
||||
</p>
|
||||
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
|
||||
{Object.keys(health.spend.perTenant).length}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("tenantsWithSpend")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={fetchHealth}
|
||||
disabled={loadingHealth}
|
||||
className="px-4 py-2 text-xs font-medium bg-surface-2 border border-border rounded-lg text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("healthUnavailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── REJECT MODAL ───── */}
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
@@ -596,6 +771,49 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({
|
||||
name,
|
||||
subtitle,
|
||||
healthy,
|
||||
t,
|
||||
}: {
|
||||
name: string;
|
||||
subtitle: string;
|
||||
healthy: boolean;
|
||||
t: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-4 flex items-center gap-4">
|
||||
<div
|
||||
className={`shrink-0 h-10 w-10 rounded-lg flex items-center justify-center ${
|
||||
healthy ? "bg-emerald-400/15" : "bg-red-400/15"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
healthy ? "bg-emerald-400" : "bg-red-400 animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{name}</p>
|
||||
<p className="text-xs text-text-muted truncate">{subtitle}</p>
|
||||
</div>
|
||||
<div className="ml-auto shrink-0">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
healthy
|
||||
? "bg-emerald-400/15 text-emerald-400"
|
||||
: "bg-red-400/15 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{healthy ? t("statusHealthy") : t("statusDown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
pending: "bg-blue-400/15 text-blue-400",
|
||||
|
||||
@@ -48,3 +48,57 @@ export async function getTeamSpendLogsV2(
|
||||
});
|
||||
return litellmFetch(`/spend/logs/v2?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams registered in LiteLLM.
|
||||
* Returns team_id, spend, max_budget, etc.
|
||||
*/
|
||||
export async function listTeams(): Promise<any[]> {
|
||||
const data = await litellmFetch("/team/list");
|
||||
// LiteLLM returns either an array or { data: [...] }
|
||||
return Array.isArray(data) ? data : data?.data ?? data?.teams ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiteLLM health status.
|
||||
*/
|
||||
export async function getLitellmHealth(): Promise<{
|
||||
healthy: boolean;
|
||||
details?: any;
|
||||
}> {
|
||||
try {
|
||||
const data = await litellmFetch("/health");
|
||||
return { healthy: true, details: data };
|
||||
} catch (e: any) {
|
||||
return { healthy: false, details: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global spend across all teams for the current month.
|
||||
*/
|
||||
export async function getGlobalSpend(): Promise<number> {
|
||||
try {
|
||||
const data = await litellmFetch("/global/spend");
|
||||
// LiteLLM returns { spend: number } or similar
|
||||
if (typeof data === "number") return data;
|
||||
return data?.spend ?? data?.total_spend ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch per-team spend as a map: teamId → spend (CHF).
|
||||
* Uses /team/list which includes current spend per team.
|
||||
*/
|
||||
export async function getPerTeamSpend(): Promise<Map<string, number>> {
|
||||
const teams = await listTeams();
|
||||
const map = new Map<string, number>();
|
||||
for (const team of teams) {
|
||||
const id = team.team_id ?? team.id;
|
||||
const spend = team.spend ?? 0;
|
||||
if (id) map.set(id, spend);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,21 @@
|
||||
"confirmDelete": "Endgültig löschen",
|
||||
"loadingTenants": "Mandanten werden geladen…",
|
||||
"filter_deleted": "Gelöscht",
|
||||
"filter_active": "Aktiv"
|
||||
"filter_active": "Aktiv",
|
||||
"health": "Status",
|
||||
"serviceHealth": "Dienststatus",
|
||||
"vllmDescription": "GPU-Inferenz-Engine",
|
||||
"litellmDescription": "LLM-Proxy & Kostenerfassung",
|
||||
"tenantOverview": "Mandanten-Übersicht",
|
||||
"spendOverview": "Kostenübersicht",
|
||||
"globalSpend": "Gesamtkosten (CHF)",
|
||||
"activeTenants": "Aktive Mandanten",
|
||||
"tenantsWithSpend": "Mandanten mit erfassten Kosten",
|
||||
"refresh": "Aktualisieren",
|
||||
"healthUnavailable": "Statusdaten nicht verfügbar.",
|
||||
"loadingHealth": "Statusdaten werden geladen…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Ausgefallen",
|
||||
"spendChf": "Kosten (CHF)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,21 @@
|
||||
"confirmDelete": "Delete permanently",
|
||||
"loadingTenants": "Loading tenants…",
|
||||
"filter_deleted": "Deleted",
|
||||
"filter_active": "Active"
|
||||
"filter_active": "Active",
|
||||
"health": "Health",
|
||||
"serviceHealth": "Service Health",
|
||||
"vllmDescription": "GPU inference engine",
|
||||
"litellmDescription": "LLM proxy & spend tracking",
|
||||
"tenantOverview": "Tenant Overview",
|
||||
"spendOverview": "Spend Overview",
|
||||
"globalSpend": "Global Spend (CHF)",
|
||||
"activeTenants": "Active Tenants",
|
||||
"tenantsWithSpend": "tenants with recorded spend",
|
||||
"refresh": "Refresh",
|
||||
"healthUnavailable": "Health data unavailable.",
|
||||
"loadingHealth": "Loading health data…",
|
||||
"statusHealthy": "Healthy",
|
||||
"statusDown": "Down",
|
||||
"spendChf": "Spend (CHF)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,21 @@
|
||||
"confirmDelete": "Supprimer définitivement",
|
||||
"loadingTenants": "Chargement des locataires…",
|
||||
"filter_deleted": "Supprimé",
|
||||
"filter_active": "Actif"
|
||||
"filter_active": "Actif",
|
||||
"health": "Santé",
|
||||
"serviceHealth": "Santé des services",
|
||||
"vllmDescription": "Moteur d'inférence GPU",
|
||||
"litellmDescription": "Proxy LLM & suivi des coûts",
|
||||
"tenantOverview": "Aperçu des locataires",
|
||||
"spendOverview": "Aperçu des coûts",
|
||||
"globalSpend": "Coûts globaux (CHF)",
|
||||
"activeTenants": "Locataires actifs",
|
||||
"tenantsWithSpend": "locataires avec dépenses enregistrées",
|
||||
"refresh": "Actualiser",
|
||||
"healthUnavailable": "Données de santé indisponibles.",
|
||||
"loadingHealth": "Chargement des données de santé…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Hors service",
|
||||
"spendChf": "Coûts (CHF)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,21 @@
|
||||
"confirmDelete": "Elimina definitivamente",
|
||||
"loadingTenants": "Caricamento tenant…",
|
||||
"filter_deleted": "Eliminato",
|
||||
"filter_active": "Attivo"
|
||||
"filter_active": "Attivo",
|
||||
"health": "Stato",
|
||||
"serviceHealth": "Stato dei servizi",
|
||||
"vllmDescription": "Motore di inferenza GPU",
|
||||
"litellmDescription": "Proxy LLM & monitoraggio costi",
|
||||
"tenantOverview": "Panoramica tenant",
|
||||
"spendOverview": "Panoramica costi",
|
||||
"globalSpend": "Costi globali (CHF)",
|
||||
"activeTenants": "Tenant attivi",
|
||||
"tenantsWithSpend": "tenant con spese registrate",
|
||||
"refresh": "Aggiorna",
|
||||
"healthUnavailable": "Dati di stato non disponibili.",
|
||||
"loadingHealth": "Caricamento dati di stato…",
|
||||
"statusHealthy": "OK",
|
||||
"statusDown": "Non disponibile",
|
||||
"spendChf": "Costi (CHF)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user