278 lines
9.4 KiB
TypeScript
278 lines
9.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|