feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
All checks were successful
Build and Push / build (push) Successful in 1m52s
All checks were successful
Build and Push / build (push) Successful in 1m52s
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,11 +22,22 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8 animate-in">
|
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
|
||||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
<div>
|
||||||
{t("title")}
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||||
</h1>
|
{t("title")}
|
||||||
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
|
</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>
|
||||||
|
|
||||||
<div className="animate-in animate-in-delay-1">
|
<div className="animate-in animate-in-delay-1">
|
||||||
|
|||||||
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>;
|
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",
|
"statusDown": "Ausgefallen",
|
||||||
"spendChf": "Kosten (CHF)",
|
"spendChf": "Kosten (CHF)",
|
||||||
"resumeRequestBadge": "Wieder",
|
"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": {
|
"channelUsers": {
|
||||||
"title": "Autorisierte Benutzer",
|
"title": "Autorisierte Benutzer",
|
||||||
@@ -473,5 +474,24 @@
|
|||||||
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
|
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
|
||||||
"adminControlsTitle": "Admin-Steuerung",
|
"adminControlsTitle": "Admin-Steuerung",
|
||||||
"updateFailed": "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen."
|
"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",
|
"statusDown": "Down",
|
||||||
"spendChf": "Spend (CHF)",
|
"spendChf": "Spend (CHF)",
|
||||||
"resumeRequestBadge": "Resume",
|
"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": {
|
"channelUsers": {
|
||||||
"title": "Authorized Users",
|
"title": "Authorized Users",
|
||||||
@@ -473,5 +474,24 @@
|
|||||||
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
|
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
|
||||||
"adminControlsTitle": "Admin controls",
|
"adminControlsTitle": "Admin controls",
|
||||||
"updateFailed": "Could not save changes. Please try again."
|
"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",
|
"statusDown": "Hors service",
|
||||||
"spendChf": "Coûts (CHF)",
|
"spendChf": "Coûts (CHF)",
|
||||||
"resumeRequestBadge": "Reprise",
|
"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": {
|
"channelUsers": {
|
||||||
"title": "Utilisateurs autorisés",
|
"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.",
|
"resolvedBanner": "Ce ticket est résolu. Répondez ci-dessous si vous avez besoin d'un suivi — cela le rouvrira.",
|
||||||
"adminControlsTitle": "Contrôles admin",
|
"adminControlsTitle": "Contrôles admin",
|
||||||
"updateFailed": "Impossible d'enregistrer les modifications. Veuillez réessayer."
|
"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",
|
"statusDown": "Non disponibile",
|
||||||
"spendChf": "Costi (CHF)",
|
"spendChf": "Costi (CHF)",
|
||||||
"resumeRequestBadge": "Ripresa",
|
"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": {
|
"channelUsers": {
|
||||||
"title": "Utenti autorizzati",
|
"title": "Utenti autorizzati",
|
||||||
@@ -473,5 +474,24 @@
|
|||||||
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
||||||
"adminControlsTitle": "Controlli admin",
|
"adminControlsTitle": "Controlli admin",
|
||||||
"updateFailed": "Impossibile salvare le modifiche. Riprova."
|
"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>;
|
workspaceFiles?: Record<string, string>;
|
||||||
channelUsers?: Record<string, string[]>;
|
channelUsers?: Record<string, string[]>;
|
||||||
suspend?: boolean;
|
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 {
|
export interface PiecedTenantStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user