Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b58bdadad4 | |||
| d375a099f0 | |||
| 666dd64580 | |||
| 188bef2ece | |||
| 57258bca92 |
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
71
src/app/[locale]/admin/openclaw/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
|
||||
import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
|
||||
|
||||
/**
|
||||
* /admin/openclaw — platform-default OpenClaw image + per-tenant
|
||||
* overrides table.
|
||||
*
|
||||
* Two sections:
|
||||
* 1. Default — readable from `pieced-openclaw-config` ConfigMap.
|
||||
* Editable via the same form. Empty fields show as "(unset)"
|
||||
* and the operator falls back to its built-in default in that
|
||||
* case (intentionally invisible to the portal — the binary's
|
||||
* baked version moves with releases and we don't want the UI
|
||||
* to claim a misleading "current default").
|
||||
* 2. Tenant table — every tenant in the cluster with its current
|
||||
* override (or "follows default"). Clicking a row opens a small
|
||||
* inline editor.
|
||||
*
|
||||
* Authorization is gated server-side: `user.isPlatform` only. Any
|
||||
* other user gets redirected to /dashboard.
|
||||
*/
|
||||
export default async function OpenClawAdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("openclawAdmin");
|
||||
|
||||
// Parallel fetch — defaults and tenants are independent.
|
||||
const [defaults, tenants] = await Promise.all([
|
||||
getOpenClawDefaults(),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
// Sort tenants: overridden first (more interesting to review),
|
||||
// then alphabetically by display name. Helps the admin spot which
|
||||
// tenants are off the platform default at a glance.
|
||||
const sorted = [...tenants].sort((a, b) => {
|
||||
const aOverride = a.spec.openClawImage ? 1 : 0;
|
||||
const bOverride = b.spec.openClawImage ? 1 : 0;
|
||||
if (aOverride !== bOverride) return bOverride - aOverride;
|
||||
return (a.spec.displayName || a.metadata.name).localeCompare(
|
||||
b.spec.displayName || b.metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<OpenClawAdminPanel
|
||||
initialDefaults={defaults}
|
||||
tenants={sorted.map((tn) => ({
|
||||
name: tn.metadata.name,
|
||||
displayName: tn.spec.displayName || tn.metadata.name,
|
||||
phase: tn.status?.phase ?? "Unknown",
|
||||
override: tn.spec.openClawImage?.tag
|
||||
? { tag: tn.spec.openClawImage.tag }
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,22 @@ export default async function AdminPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
||||
</div>
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
@@ -199,7 +199,7 @@ export default async function TenantDetailPage({
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("usage")}
|
||||
</h2>
|
||||
<UsageDisplay tenant={name} />
|
||||
<UsageDisplay tenant={name} canEditBudget={canEdit} />
|
||||
</section>
|
||||
|
||||
{/* Packages */}
|
||||
|
||||
75
src/app/api/admin/openclaw/route.ts
Normal file
75
src/app/api/admin/openclaw/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Platform-wide default OpenClaw image tag (admin-only).
|
||||
*
|
||||
* GET — read the current default tag from the
|
||||
* `pieced-openclaw-config` ConfigMap. Can be empty string if no
|
||||
* default is configured; the operator uses its built-in fallback
|
||||
* in that case.
|
||||
*
|
||||
* PATCH — update the tag. Send "" to clear. The operator watches
|
||||
* this ConfigMap and re-enqueues all tenants without a per-tenant
|
||||
* override on change, so existing tenants roll forward to the new
|
||||
* default automatically. Tenants WITH an override are unaffected.
|
||||
*
|
||||
* Tag-only by design — see operator notes.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
defaultTag: z.string().trim().max(256),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await getOpenClawDefaults());
|
||||
} catch (e: any) {
|
||||
console.error("Failed to read openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to read defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await setOpenClawDefaults({
|
||||
defaultTag: parsed.data.defaultTag,
|
||||
});
|
||||
return NextResponse.json(next);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update openclaw defaults:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update defaults") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
78
src/app/api/admin/tenants/[name]/openclaw-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Per-tenant OpenClaw image override (admin-only).
|
||||
*
|
||||
* Why admin-only: customers cannot pick OpenClaw versions. This
|
||||
* exists so the platform team can A/B-test new releases on specific
|
||||
* tenants without rolling them out fleet-wide. The endpoint enforces
|
||||
* `user.isPlatform`; even owners of the tenant's org cannot use it.
|
||||
*
|
||||
* PATCH body shapes:
|
||||
* - { tag: "2026.4.22" } → use this tag
|
||||
* - { tag: "" } or empty body → clear override (revert to platform
|
||||
* default)
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
tag: z.string().trim().max(256).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isPlatform) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tag = parsed.data.tag ?? "";
|
||||
const isClearing = tag === "";
|
||||
|
||||
// Merge-patch semantics: openClawImage: null removes the field
|
||||
// from the spec; openClawImage: { tag } sets it.
|
||||
const spec: any = isClearing
|
||||
? { openClawImage: null }
|
||||
: { openClawImage: { tag } };
|
||||
|
||||
try {
|
||||
const updated = await patchTenantSpec(name, spec);
|
||||
return NextResponse.json({
|
||||
message: isClearing
|
||||
? "Override cleared; tenant follows platform default."
|
||||
: "Override set.",
|
||||
openClawImage: updated.spec.openClawImage ?? null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Failed to set tenant openclaw image:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update tenant image") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/app/api/tenants/[name]/budget/route.ts
Normal file
126
src/app/api/tenants/[name]/budget/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { findKeyByAlias, updateKeyBudget } from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Update the per-tenant budget — operates on the LiteLLM virtual
|
||||
* key, NOT on the team.
|
||||
*
|
||||
* Why per-key
|
||||
* -----------
|
||||
* Each tenant in an org has its own virtual key
|
||||
* (`key_alias = tenant.metadata.name`); the team that owns those
|
||||
* keys is org-scoped and shared across all the org's tenants. A
|
||||
* budget on the team would cap the whole org; a budget on the key
|
||||
* caps just this one tenant. Customers landing on the tenant detail
|
||||
* page reasonably expect "edit budget" to mean "the budget of THIS
|
||||
* tenant" — so we put it on the key.
|
||||
*
|
||||
* The team-level (org-wide) budget is a separate control that lives
|
||||
* in /settings (not yet implemented) — the two coexist: LiteLLM
|
||||
* applies whichever cap is hit first.
|
||||
*
|
||||
* Schema:
|
||||
* - maxBudget: number > 0 (set a cap), or null (remove the cap).
|
||||
* - budgetDuration: one of "30d", "1mo", "1y", or null (lifetime).
|
||||
*
|
||||
* Authorization: owners and platform admins.
|
||||
*/
|
||||
|
||||
const patchSchema = z.object({
|
||||
// > 0 because LiteLLM rejects 0 and a zero cap would lock the key
|
||||
// out instantly. Upper bound 1M as a typo guard.
|
||||
maxBudget: z.number().positive().max(1_000_000).nullable(),
|
||||
budgetDuration: z.enum(["30d", "1mo", "1y"]).nullable(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
// Don't leak existence — same 404 a non-visible tenant gets.
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const teamId = tenant.status?.litellmTeamId;
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Tenant has no LiteLLM team yet. Please wait until provisioning completes.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive: removing the cap should null out the duration too —
|
||||
// a reset cadence on an unlimited budget is meaningless and would
|
||||
// confuse LiteLLM's bookkeeping.
|
||||
const maxBudget = parsed.data.maxBudget;
|
||||
const budgetDuration =
|
||||
maxBudget === null ? null : parsed.data.budgetDuration;
|
||||
|
||||
// Look up the key by alias (= tenant name). The token returned is
|
||||
// what /key/update wants in the `key` field.
|
||||
let keyInfo;
|
||||
try {
|
||||
keyInfo = await findKeyByAlias(teamId, name);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to look up tenant key:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to look up tenant key") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
if (!keyInfo) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Tenant has no virtual key yet. Please wait until provisioning completes.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateKeyBudget(keyInfo.token, { maxBudget, budgetDuration });
|
||||
return NextResponse.json({
|
||||
message: maxBudget === null ? "Budget removed." : "Budget updated.",
|
||||
maxBudget,
|
||||
budgetDuration,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Failed to update key budget:", e);
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update budget") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||
import {
|
||||
getTeamInfo,
|
||||
getTeamSpendLogsV2,
|
||||
findKeyByAlias,
|
||||
} from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
@@ -126,6 +130,16 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const teamInfo = await getTeamInfo(teamId);
|
||||
|
||||
// Per-tenant budget lives on the virtual key, not the team
|
||||
// (Feature 7 fix). When the request is scoped to a specific
|
||||
// tenant (keyAlias provided), look up the key so we can return
|
||||
// the per-tenant cap. Tolerate failure — older LiteLLM builds
|
||||
// or short-lived race conditions during provisioning shouldn't
|
||||
// 500 the whole usage page; we degrade to "no key info".
|
||||
const keyInfo = keyAlias
|
||||
? await findKeyByAlias(teamId, keyAlias).catch(() => null)
|
||||
: null;
|
||||
|
||||
// Page through results — server-side filtered by key_alias when
|
||||
// provided. Pagination still needed because LiteLLM caps
|
||||
// page_size at 100, and a busy tenant can easily exceed that in
|
||||
@@ -191,17 +205,38 @@ export async function GET(req: NextRequest) {
|
||||
totalSpend,
|
||||
requestCount: allRequests.length,
|
||||
},
|
||||
// Budget is always team-level (= company budget). Spend reported
|
||||
// here is the team total, not the per-key total — the customer
|
||||
// wants to see "how much of our company budget is left", not
|
||||
// just "how much has this one tenant cost".
|
||||
budget: {
|
||||
maxBudget: teamInfo?.team_info?.max_budget ?? null,
|
||||
spend: teamInfo?.team_info?.spend ?? 0,
|
||||
remaining: teamInfo?.team_info?.max_budget
|
||||
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
|
||||
: null,
|
||||
},
|
||||
// Budget reporting (Feature 7).
|
||||
//
|
||||
// When the caller scopes to a specific tenant (keyAlias set),
|
||||
// we report THAT tenant's per-key budget — that's what the
|
||||
// tenant detail page renders, and what the customer expects
|
||||
// when they see "Budget" on a tenant's page.
|
||||
//
|
||||
// When unscoped (admin / org-wide view), we fall back to the
|
||||
// team budget — that's the org-wide cap, conceptually different
|
||||
// but the only thing meaningful at that scope.
|
||||
//
|
||||
// The two cases display the same way; the editor button gates
|
||||
// on whether we know which tenant we're on (= keyAlias set).
|
||||
budget: keyAlias && keyInfo
|
||||
? {
|
||||
maxBudget: keyInfo.maxBudget,
|
||||
spend: keyInfo.spend,
|
||||
remaining:
|
||||
keyInfo.maxBudget !== null
|
||||
? keyInfo.maxBudget - keyInfo.spend
|
||||
: null,
|
||||
budgetDuration: keyInfo.budgetDuration,
|
||||
}
|
||||
: {
|
||||
maxBudget: teamInfo?.team_info?.max_budget ?? null,
|
||||
spend: teamInfo?.team_info?.spend ?? 0,
|
||||
remaining: teamInfo?.team_info?.max_budget
|
||||
? teamInfo.team_info.max_budget -
|
||||
(teamInfo.team_info.spend ?? 0)
|
||||
: null,
|
||||
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
|
||||
},
|
||||
rateLimits: {
|
||||
rpm: teamInfo?.team_info?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.team_info?.tpm_limit ?? null,
|
||||
|
||||
277
src/components/admin/openclaw-admin-panel.tsx
Normal file
277
src/components/admin/openclaw-admin-panel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { OpenClawDefaults } from "@/lib/k8s";
|
||||
|
||||
interface TenantRow {
|
||||
name: string;
|
||||
displayName: string;
|
||||
phase: string;
|
||||
override: { tag: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialDefaults: OpenClawDefaults;
|
||||
tenants: TenantRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-section admin UI:
|
||||
* - Default editor card at the top — single input for the tag.
|
||||
* - Tenant table below — each row has an inline edit/clear control.
|
||||
*
|
||||
* No optimistic updates: every save round-trips to the API and we
|
||||
* router.refresh() to re-render the server-side state. Keeps the UI
|
||||
* honest about what's actually applied (controller-runtime watch
|
||||
* latency can be a couple of seconds).
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale.
|
||||
*/
|
||||
export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
||||
const t = useTranslations("openclawAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
const [defaults, setDefaults] = useState(initialDefaults);
|
||||
const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
|
||||
const [savingDefault, setSavingDefault] = useState(false);
|
||||
const [defaultError, setDefaultError] = useState("");
|
||||
const [defaultSaved, setDefaultSaved] = useState(false);
|
||||
|
||||
const onSaveDefault = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSavingDefault(true);
|
||||
setDefaultError("");
|
||||
setDefaultSaved(false);
|
||||
try {
|
||||
const res = await fetch("/api/admin/openclaw", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultTag: defaultTag.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("saveFailed"));
|
||||
}
|
||||
const next = await res.json();
|
||||
setDefaults(next);
|
||||
setDefaultSaved(true);
|
||||
} catch (e: any) {
|
||||
setDefaultError(e.message);
|
||||
} finally {
|
||||
setSavingDefault(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Default editor */}
|
||||
<section className="animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("defaultSection")}
|
||||
</h2>
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("defaultDescription")}
|
||||
</p>
|
||||
<form onSubmit={onSaveDefault} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldTag")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultTag}
|
||||
onChange={(e) => setDefaultTag(e.target.value)}
|
||||
placeholder="2026.4.22"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("emptyHint")}</p>
|
||||
</div>
|
||||
|
||||
{defaultError && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
||||
{defaultError}
|
||||
</div>
|
||||
)}
|
||||
{defaultSaved && !defaultError && (
|
||||
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
|
||||
{t("defaultSaved")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingDefault}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Tenant overrides */}
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("overridesSection")}
|
||||
</h2>
|
||||
<Card>
|
||||
{tenants.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{t("noTenants")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tenants.map((tn) => (
|
||||
<TenantOverrideRow
|
||||
key={tn.name}
|
||||
tenant={tn}
|
||||
platformDefault={defaults}
|
||||
onChanged={() => router.refresh()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single row in the tenants table. Collapsed by default; click to
|
||||
* expand the inline editor.
|
||||
*/
|
||||
function TenantOverrideRow({
|
||||
tenant,
|
||||
platformDefault,
|
||||
onChanged,
|
||||
}: {
|
||||
tenant: TenantRow;
|
||||
platformDefault: OpenClawDefaults;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations("openclawAdmin");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tag, setTag] = useState(tenant.override?.tag ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (clear = false) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/tenants/${encodeURIComponent(tenant.name)}/openclaw-image`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(clear ? {} : { tag: tag.trim() }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("saveFailed"));
|
||||
}
|
||||
setExpanded(false);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const effective = tenant.override?.tag
|
||||
? tenant.override.tag
|
||||
: platformDefault.defaultTag || t("builtinFallback");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-surface-1 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-text-primary truncate">
|
||||
{tenant.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted font-mono truncate mt-0.5">
|
||||
{tenant.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4 min-w-0">
|
||||
{tenant.override ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-400/15 text-amber-400 border border-amber-400/20">
|
||||
{t("statusOverridden")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-400/15 text-blue-400 border border-blue-400/20">
|
||||
{t("statusFollowsDefault")}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-text-muted font-mono truncate max-w-[260px] mt-1">
|
||||
{effective}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-border bg-surface-1">
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("fieldTag")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder={
|
||||
platformDefault.defaultTag
|
||||
? `${t("defaultPrefix")} ${platformDefault.defaultTag}`
|
||||
: ""
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{tenant.override && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(true)}
|
||||
disabled={saving}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("clearOverride")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={saving || !tag.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("saveOverride")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,11 +127,29 @@ export function BudgetEditableCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-surface-1 border border-border rounded-xl p-4 text-left hover:border-text-secondary transition-colors group"
|
||||
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
|
||||
>
|
||||
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
|
||||
<span>{t("budget")}</span>
|
||||
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-accent inline-flex items-center gap-1">
|
||||
{/* Pencil icon — unambiguous "this is editable" affordance.
|
||||
Visible at all times (was hover-only before, which on
|
||||
touch devices and at-a-glance scanning gave no
|
||||
indication the card was clickable). */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
{t("budgetEdit")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,17 +158,13 @@ export function BudgetEditableCard({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("budgetEditTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{t("budgetEditDescription")}
|
||||
</p>
|
||||
<div className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 rounded-lg px-3 py-2 mb-5">
|
||||
{t("budgetOrgScopeWarning")}
|
||||
</div>
|
||||
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("budgetEditTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("budgetEditDescription")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Mode toggle: unlimited vs capped. Two radios are
|
||||
@@ -256,7 +270,6 @@ export function BudgetEditableCard({
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/lib/k8s.ts
112
src/lib/k8s.ts
@@ -173,3 +173,115 @@ export async function setTenantAnnotation(
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenClaw config ConfigMap helpers (admin-only feature: per-tenant version
|
||||
// override + platform default).
|
||||
//
|
||||
// The ConfigMap lives in the operator's namespace (`pieced-system`). The
|
||||
// portal's ServiceAccount needs `get/patch` on configmaps in that namespace
|
||||
// — rules added in the gitops repo.
|
||||
//
|
||||
// Tag-only by design — see operator notes for rationale.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OPENCLAW_CONFIGMAP_NAME = "pieced-openclaw-config";
|
||||
|
||||
/**
|
||||
* Operator namespace. Reads the env var so the portal can be deployed in
|
||||
* non-default namespaces without code changes; defaults to "pieced-system"
|
||||
* matching the operator's chart default.
|
||||
*/
|
||||
function getOperatorNamespace(): string {
|
||||
return process.env.OPERATOR_NAMESPACE ?? "pieced-system";
|
||||
}
|
||||
|
||||
export interface OpenClawDefaults {
|
||||
/** Image tag (e.g. "2026.4.22"). Empty string means unset. */
|
||||
defaultTag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the platform-default OpenClaw image tag. Returns empty string
|
||||
* if unset, and `{ defaultTag: "" }` if the ConfigMap doesn't exist yet
|
||||
* — the operator's built-in fallback is invisible to the portal by
|
||||
* design (we don't want the UI to claim "current default: 2026.x" when
|
||||
* it's actually the operator binary's baked-in version; that would be
|
||||
* misleading once the binary updates).
|
||||
*/
|
||||
export async function getOpenClawDefaults(): Promise<OpenClawDefaults> {
|
||||
const ns = getOperatorNamespace();
|
||||
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json", ...getAuthHeaders() },
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return { defaultTag: "" };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(
|
||||
`K8s GET configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
|
||||
);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
const cm = (await res.json()) as { data?: Record<string, string> };
|
||||
return { defaultTag: cm.data?.defaultTag ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the platform-default OpenClaw image tag. Empty string clears
|
||||
* the field (operator falls back to its built-in default).
|
||||
*
|
||||
* Creates the ConfigMap if it doesn't exist (PATCH on missing resource
|
||||
* 404s; we retry as POST). Keeps the admin UI usable on a fresh install
|
||||
* where the helm-shipped CM was deleted or never created.
|
||||
*/
|
||||
export async function setOpenClawDefaults(
|
||||
defaults: OpenClawDefaults
|
||||
): Promise<OpenClawDefaults> {
|
||||
const ns = getOperatorNamespace();
|
||||
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
|
||||
const patch = { data: { defaultTag: defaults.defaultTag } };
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/merge-patch+json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
const createUrl = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps`;
|
||||
const createRes = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: { name: OPENCLAW_CONFIGMAP_NAME, namespace: ns },
|
||||
data: patch.data,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const text = await createRes.text();
|
||||
throw new Error(
|
||||
`K8s POST configmap ${OPENCLAW_CONFIGMAP_NAME}: ${createRes.status} ${text}`
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`K8s PATCH configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,94 @@ export async function listTeams(): Promise<any[]> {
|
||||
return Array.isArray(data) ? data : data?.data ?? data?.teams ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a virtual key on a team by its alias and return its current
|
||||
* state (token, spend, budget cap, reset cadence). Returns null if
|
||||
* the alias doesn't match any key on the team.
|
||||
*
|
||||
* Why we need this
|
||||
* ----------------
|
||||
* Per-tenant budgets live on the virtual key, not the team. The
|
||||
* portal needs to:
|
||||
* 1. Display the current key's `max_budget` / `budget_duration` /
|
||||
* `spend` on the tenant detail page.
|
||||
* 2. Pass the key's `token` to `/key/update` when the customer
|
||||
* changes the budget.
|
||||
*
|
||||
* The token is opaque to the customer; the operator's
|
||||
* `FindKeyByAlias` does the same lookup for stale-key cleanup. We
|
||||
* mirror its API call here.
|
||||
*/
|
||||
export async function findKeyByAlias(
|
||||
teamId: string,
|
||||
keyAlias: string
|
||||
): Promise<{
|
||||
token: string;
|
||||
spend: number;
|
||||
maxBudget: number | null;
|
||||
budgetDuration: string | null;
|
||||
} | null> {
|
||||
const data = await litellmFetch(
|
||||
`/key/list?team_id=${encodeURIComponent(teamId)}&return_full_object=true&include_team_keys=true`
|
||||
);
|
||||
const keys: any[] = Array.isArray(data?.keys)
|
||||
? data.keys
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
for (const k of keys) {
|
||||
if (typeof k !== "object" || k === null) continue;
|
||||
const alias = k.key_alias ?? k.keyAlias;
|
||||
if (alias !== keyAlias) continue;
|
||||
if (typeof k.token !== "string" || !k.token) continue;
|
||||
return {
|
||||
token: k.token,
|
||||
spend: typeof k.spend === "number" ? k.spend : Number(k.spend) || 0,
|
||||
maxBudget:
|
||||
typeof k.max_budget === "number"
|
||||
? k.max_budget
|
||||
: k.max_budget == null
|
||||
? null
|
||||
: Number(k.max_budget) || null,
|
||||
budgetDuration:
|
||||
typeof k.budget_duration === "string" ? k.budget_duration : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a virtual key's budget cap and reset duration.
|
||||
*
|
||||
* Pass `maxBudget: null` to remove the cap. Pass `budgetDuration:
|
||||
* null` to make the budget never reset (lifetime cap).
|
||||
*
|
||||
* Identified by `key` parameter — accepts either the raw `sk-...`
|
||||
* token or its hash (LiteLLM accepts both shapes on /key/update).
|
||||
* The portal flow uses the hash returned by `findKeyByAlias`.
|
||||
*/
|
||||
export async function updateKeyBudget(
|
||||
key: string,
|
||||
changes: {
|
||||
maxBudget?: number | null;
|
||||
budgetDuration?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const body: Record<string, any> = { key };
|
||||
if (changes.maxBudget !== undefined) {
|
||||
body.max_budget = changes.maxBudget;
|
||||
}
|
||||
if (changes.budgetDuration !== undefined) {
|
||||
body.budget_duration = changes.budgetDuration;
|
||||
}
|
||||
await litellmFetch("/key/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiteLLM health status.
|
||||
*/
|
||||
|
||||
@@ -189,7 +189,21 @@
|
||||
"last30Days": "Letzte 30 Tage",
|
||||
"noData": "Keine Nutzungsdaten verfügbar.",
|
||||
"dailyBreakdown": "Tagesübersicht",
|
||||
"requests": "Anfragen"
|
||||
"requests": "Anfragen",
|
||||
"budgetEdit": "Bearbeiten",
|
||||
"budgetEditTitle": "Budget festlegen",
|
||||
"budgetEditDescription": "Begrenzen Sie, wie viel die Assistenten dieses Tenants ausgeben können, bevor Anfragen abgelehnt werden.",
|
||||
"budgetModeUnlimited": "Kein Limit",
|
||||
"budgetModeUnlimitedDescription": "Beliebige Ausgaben, kein Limit.",
|
||||
"budgetModeCapped": "Limit festlegen",
|
||||
"budgetModeCappedDescription": "Anfragen ablehnen, sobald die Ausgaben diesen Betrag erreichen.",
|
||||
"budgetAmount": "Betrag",
|
||||
"budgetResetCadence": "Zurücksetzen",
|
||||
"budgetCadence_30d": "Alle 30 Tage",
|
||||
"budgetCadence_1mo": "Monatlich",
|
||||
"budgetCadence_1y": "Jährlich",
|
||||
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Speichern",
|
||||
@@ -319,7 +333,8 @@
|
||||
"statusDown": "Ausgefallen",
|
||||
"spendChf": "Kosten (CHF)",
|
||||
"resumeRequestBadge": "Wieder",
|
||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft."
|
||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||
"openclawTool": "OpenClaw-Versionen"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -459,5 +474,24 @@
|
||||
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
|
||||
"adminControlsTitle": "Admin-Steuerung",
|
||||
"updateFailed": "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "OpenClaw-Versionen",
|
||||
"subtitle": "Plattform-Standard-Tag und Tenant-spezifische Overrides für das Testen neuer Releases konfigurieren.",
|
||||
"defaultSection": "Plattform-Standard",
|
||||
"defaultDescription": "Wird von jedem Tenant ohne eigenen Override verwendet.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Leer lassen, um den eingebauten Operator-Standard zu verwenden.",
|
||||
"saveDefault": "Standard speichern",
|
||||
"defaultSaved": "Standard gespeichert. Tenants ohne Override übernehmen den Wert beim nächsten Reconcile.",
|
||||
"saveFailed": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"overridesSection": "Tenant-Overrides",
|
||||
"noTenants": "Keine Tenants im Cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Folgt Standard",
|
||||
"builtinFallback": "(eingebauter Fallback)",
|
||||
"defaultPrefix": "Standard:",
|
||||
"saveOverride": "Override speichern",
|
||||
"clearOverride": "Override entfernen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,21 @@
|
||||
"last30Days": "Last 30 Days",
|
||||
"noData": "No usage data available.",
|
||||
"dailyBreakdown": "Daily Breakdown",
|
||||
"requests": "requests"
|
||||
"requests": "requests",
|
||||
"budgetEdit": "Edit",
|
||||
"budgetEditTitle": "Set spending budget",
|
||||
"budgetEditDescription": "Cap how much this tenant's assistants can spend before requests start being declined.",
|
||||
"budgetModeUnlimited": "No limit",
|
||||
"budgetModeUnlimitedDescription": "Spend as much as needed; no cap.",
|
||||
"budgetModeCapped": "Set a cap",
|
||||
"budgetModeCappedDescription": "Stop accepting requests once spend reaches this amount.",
|
||||
"budgetAmount": "Amount",
|
||||
"budgetResetCadence": "Reset",
|
||||
"budgetCadence_30d": "Every 30 days",
|
||||
"budgetCadence_1mo": "Monthly",
|
||||
"budgetCadence_1y": "Yearly",
|
||||
"budgetInvalid": "Please enter a positive amount.",
|
||||
"budgetSaveFailed": "Could not save budget. Please try again."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Save",
|
||||
@@ -319,7 +333,8 @@
|
||||
"statusDown": "Down",
|
||||
"spendChf": "Spend (CHF)",
|
||||
"resumeRequestBadge": "Resume",
|
||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs."
|
||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||
"openclawTool": "OpenClaw versions"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -459,5 +474,24 @@
|
||||
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
|
||||
"adminControlsTitle": "Admin controls",
|
||||
"updateFailed": "Could not save changes. Please try again."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "OpenClaw versions",
|
||||
"subtitle": "Configure the platform-default OpenClaw image tag and per-tenant overrides for testing new releases.",
|
||||
"defaultSection": "Platform default",
|
||||
"defaultDescription": "Used by every tenant that doesn't have its own override.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Leave empty to fall back to the operator's built-in default.",
|
||||
"saveDefault": "Save default",
|
||||
"defaultSaved": "Default saved. Tenants without overrides will pick this up on the next reconcile.",
|
||||
"saveFailed": "Could not save. Please try again.",
|
||||
"overridesSection": "Tenant overrides",
|
||||
"noTenants": "No tenants in the cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Follows default",
|
||||
"builtinFallback": "(operator built-in fallback)",
|
||||
"defaultPrefix": "Default:",
|
||||
"saveOverride": "Save override",
|
||||
"clearOverride": "Clear override"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,21 @@
|
||||
"last30Days": "30 derniers jours",
|
||||
"noData": "Aucune donnée d'utilisation disponible.",
|
||||
"dailyBreakdown": "Détail journalier",
|
||||
"requests": "requêtes"
|
||||
"requests": "requêtes",
|
||||
"budgetEdit": "Modifier",
|
||||
"budgetEditTitle": "Définir un budget",
|
||||
"budgetEditDescription": "Limitez la dépense des assistants de ce locataire avant que les requêtes ne soient refusées.",
|
||||
"budgetModeUnlimited": "Aucune limite",
|
||||
"budgetModeUnlimitedDescription": "Dépense libre, sans plafond.",
|
||||
"budgetModeCapped": "Définir un plafond",
|
||||
"budgetModeCappedDescription": "Refuser les requêtes une fois ce montant atteint.",
|
||||
"budgetAmount": "Montant",
|
||||
"budgetResetCadence": "Réinitialisation",
|
||||
"budgetCadence_30d": "Tous les 30 jours",
|
||||
"budgetCadence_1mo": "Mensuelle",
|
||||
"budgetCadence_1y": "Annuelle",
|
||||
"budgetInvalid": "Veuillez saisir un montant positif.",
|
||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Enregistrer",
|
||||
@@ -319,7 +333,8 @@
|
||||
"statusDown": "Hors service",
|
||||
"spendChf": "Coûts (CHF)",
|
||||
"resumeRequestBadge": "Reprise",
|
||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute."
|
||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||
"openclawTool": "Versions OpenClaw"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -459,5 +474,24 @@
|
||||
"resolvedBanner": "Ce ticket est résolu. Répondez ci-dessous si vous avez besoin d'un suivi — cela le rouvrira.",
|
||||
"adminControlsTitle": "Contrôles admin",
|
||||
"updateFailed": "Impossible d'enregistrer les modifications. Veuillez réessayer."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "Versions OpenClaw",
|
||||
"subtitle": "Configurer le tag par défaut de la plateforme et les surcharges par locataire pour tester les nouvelles versions.",
|
||||
"defaultSection": "Défaut de la plateforme",
|
||||
"defaultDescription": "Utilisé par tous les locataires sans surcharge propre.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Laisser vide pour utiliser le défaut intégré de l'opérateur.",
|
||||
"saveDefault": "Enregistrer le défaut",
|
||||
"defaultSaved": "Défaut enregistré. Les locataires sans surcharge l'appliqueront au prochain réconcile.",
|
||||
"saveFailed": "Échec de l'enregistrement. Veuillez réessayer.",
|
||||
"overridesSection": "Surcharges par locataire",
|
||||
"noTenants": "Aucun locataire dans le cluster.",
|
||||
"statusOverridden": "Surcharge",
|
||||
"statusFollowsDefault": "Suit le défaut",
|
||||
"builtinFallback": "(repli intégré)",
|
||||
"defaultPrefix": "Défaut :",
|
||||
"saveOverride": "Enregistrer la surcharge",
|
||||
"clearOverride": "Supprimer la surcharge"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,21 @@
|
||||
"last30Days": "Ultimi 30 giorni",
|
||||
"noData": "Nessun dato di utilizzo disponibile.",
|
||||
"dailyBreakdown": "Dettaglio giornaliero",
|
||||
"requests": "richieste"
|
||||
"requests": "richieste",
|
||||
"budgetEdit": "Modifica",
|
||||
"budgetEditTitle": "Imposta budget",
|
||||
"budgetEditDescription": "Limita quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetModeUnlimited": "Nessun limite",
|
||||
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
|
||||
"budgetModeCapped": "Imposta un tetto",
|
||||
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
|
||||
"budgetAmount": "Importo",
|
||||
"budgetResetCadence": "Ripristino",
|
||||
"budgetCadence_30d": "Ogni 30 giorni",
|
||||
"budgetCadence_1mo": "Mensile",
|
||||
"budgetCadence_1y": "Annuale",
|
||||
"budgetInvalid": "Inserisci un importo positivo.",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Salva",
|
||||
@@ -319,7 +333,8 @@
|
||||
"statusDown": "Non disponibile",
|
||||
"spendChf": "Costi (CHF)",
|
||||
"resumeRequestBadge": "Ripresa",
|
||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning."
|
||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||
"openclawTool": "Versioni OpenClaw"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
@@ -459,5 +474,24 @@
|
||||
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
||||
"adminControlsTitle": "Controlli admin",
|
||||
"updateFailed": "Impossibile salvare le modifiche. Riprova."
|
||||
},
|
||||
"openclawAdmin": {
|
||||
"title": "Versioni OpenClaw",
|
||||
"subtitle": "Configura il tag predefinito della piattaforma e gli override per tenant per testare nuove release.",
|
||||
"defaultSection": "Predefinito piattaforma",
|
||||
"defaultDescription": "Usato da ogni tenant senza override proprio.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Lascia vuoto per usare il predefinito integrato dell'operatore.",
|
||||
"saveDefault": "Salva predefinito",
|
||||
"defaultSaved": "Predefinito salvato. I tenant senza override lo applicheranno al prossimo reconcile.",
|
||||
"saveFailed": "Salvataggio fallito. Riprova.",
|
||||
"overridesSection": "Override per tenant",
|
||||
"noTenants": "Nessun tenant nel cluster.",
|
||||
"statusOverridden": "Override",
|
||||
"statusFollowsDefault": "Segue predefinito",
|
||||
"builtinFallback": "(fallback integrato)",
|
||||
"defaultPrefix": "Predefinito:",
|
||||
"saveOverride": "Salva override",
|
||||
"clearOverride": "Rimuovi override"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,18 @@ export interface PiecedTenantSpec {
|
||||
workspaceFiles?: Record<string, string>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
suspend?: boolean;
|
||||
/**
|
||||
* Per-tenant OpenClaw image override (tag). Set only by platform
|
||||
* admins via the portal admin UI. Customers never see this field.
|
||||
* When unset or with empty Tag, the operator uses the platform
|
||||
* default from the pieced-openclaw-config ConfigMap.
|
||||
*
|
||||
* Tag-only by design — see operator notes for rationale (single
|
||||
* image-selector field avoids SSA field-ownership ambiguity).
|
||||
*/
|
||||
openClawImage?: {
|
||||
tag?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiecedTenantStatus {
|
||||
|
||||
Reference in New Issue
Block a user