feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
This commit is contained in:
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,12 +22,23 @@ export default async function AdminPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<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">
|
||||
<AdminPanel initialTenants={tenants} />
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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