feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
All checks were successful
Build and Push / build (push) Successful in 1m52s

This commit is contained in:
2026-05-10 21:15:53 +02:00
parent d375a099f0
commit b58bdadad4
11 changed files with 725 additions and 9 deletions

View 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>
);
}

View File

@@ -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">

View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -333,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",
@@ -473,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"
}
}

View File

@@ -333,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",
@@ -473,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"
}
}

View File

@@ -333,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",
@@ -473,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"
}
}

View File

@@ -333,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",
@@ -473,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"
}
}

View File

@@ -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 {