From b58bdadad4250b4c969a2a0e4cd6908a99bb6123 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 10 May 2026 21:15:53 +0200 Subject: [PATCH] feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only) --- src/app/[locale]/admin/openclaw/page.tsx | 71 +++++ src/app/[locale]/admin/page.tsx | 21 +- src/app/api/admin/openclaw/route.ts | 75 +++++ .../tenants/[name]/openclaw-image/route.ts | 78 +++++ src/components/admin/openclaw-admin-panel.tsx | 277 ++++++++++++++++++ src/lib/k8s.ts | 112 +++++++ src/messages/de.json | 22 +- src/messages/en.json | 22 +- src/messages/fr.json | 22 +- src/messages/it.json | 22 +- src/types/index.ts | 12 + 11 files changed, 725 insertions(+), 9 deletions(-) create mode 100644 src/app/[locale]/admin/openclaw/page.tsx create mode 100644 src/app/api/admin/openclaw/route.ts create mode 100644 src/app/api/admin/tenants/[name]/openclaw-image/route.ts create mode 100644 src/components/admin/openclaw-admin-panel.tsx diff --git a/src/app/[locale]/admin/openclaw/page.tsx b/src/app/[locale]/admin/openclaw/page.tsx new file mode 100644 index 0000000..55c1335 --- /dev/null +++ b/src/app/[locale]/admin/openclaw/page.tsx @@ -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 ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + ({ + 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, + }))} + /> +
+ ); +} diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx index f6ff294..5705b7f 100644 --- a/src/app/[locale]/admin/page.tsx +++ b/src/app/[locale]/admin/page.tsx @@ -22,11 +22,22 @@ export default async function AdminPage() { return (
-
-

- {t("title")} -

-

{t("subtitle")}

+
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ {/* Sub-tools: links to other admin pages. Plain links rather + than nav-shell entries — these are platform-team utilities, + not main navigation. */} + + {t("openclawTool")} +
diff --git a/src/app/api/admin/openclaw/route.ts b/src/app/api/admin/openclaw/route.ts new file mode 100644 index 0000000..40fdbc4 --- /dev/null +++ b/src/app/api/admin/openclaw/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/tenants/[name]/openclaw-image/route.ts b/src/app/api/admin/tenants/[name]/openclaw-image/route.ts new file mode 100644 index 0000000..e7c973a --- /dev/null +++ b/src/app/api/admin/tenants/[name]/openclaw-image/route.ts @@ -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 } + ); + } +} diff --git a/src/components/admin/openclaw-admin-panel.tsx b/src/components/admin/openclaw-admin-panel.tsx new file mode 100644 index 0000000..4cbffe0 --- /dev/null +++ b/src/components/admin/openclaw-admin-panel.tsx @@ -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 ( +
+ {/* Default editor */} +
+

+ {t("defaultSection")} +

+ +

+ {t("defaultDescription")} +

+
+
+ + 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" + /> +

{t("emptyHint")}

+
+ + {defaultError && ( +
+ {defaultError} +
+ )} + {defaultSaved && !defaultError && ( +
+ {t("defaultSaved")} +
+ )} + +
+ +
+
+
+
+ + {/* Tenant overrides */} +
+

+ {t("overridesSection")} +

+ + {tenants.length === 0 ? ( +

+ {t("noTenants")} +

+ ) : ( +
+ {tenants.map((tn) => ( + router.refresh()} + /> + ))} +
+ )} +
+
+
+ ); +} + +/** + * 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 ( +
+ + + {expanded && ( +
+
+ + 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" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ {tenant.override && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/src/lib/k8s.ts b/src/lib/k8s.ts index 113789f..fbfc2ee 100644 --- a/src/lib/k8s.ts +++ b/src/lib/k8s.ts @@ -173,3 +173,115 @@ export async function setTenantAnnotation( } return res.json() as Promise; } + +// --------------------------------------------------------------------------- +// 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 { + 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 }; + 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 { + 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; +} diff --git a/src/messages/de.json b/src/messages/de.json index 3b326f2..353d42f 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -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" } } diff --git a/src/messages/en.json b/src/messages/en.json index 60fceaf..1f45c78 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 1ed411e..f089f34 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -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" } } diff --git a/src/messages/it.json b/src/messages/it.json index 31004c6..aa960fb 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -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" } } diff --git a/src/types/index.ts b/src/types/index.ts index c6e7b26..8ef78d5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -75,6 +75,18 @@ export interface PiecedTenantSpec { workspaceFiles?: Record; channelUsers?: Record; 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 {