import { useState, useEffect, cx, Link } from "zeb";
import { StudioTabNav, StudioTabLink } from "@/components/ui/studio-tab-nav";
import ProjectStudioShell from "@/pages/project-studio/components/shell";
import Checkbox from "@/components/ui/checkbox";
import Input from "@/components/ui/input";
import Textarea from "@/components/ui/textarea";
import Badge from "@/components/ui/badge";
import Separator from "@/components/ui/separator";
import Button from "@/components/ui/button";
import Card from "@/components/ui/card";
import CardContent from "@/components/ui/card-content";
import CardDescription from "@/components/ui/card-description";
import CommitDialog from "@/components/ui/commit-dialog";
import ConfirmDialog from "@/components/ui/confirm-dialog";
import Field from "@/components/ui/field";
import { Select, SelectOption } from "@/components/ui/select";
export const page = {
html: {
lang: "en",
},
body: {
className: "font-sans",
},
navigation: "history",
};
export function getPage(input) {
return {
head: {
title: input?.seo?.title ?? "",
description: input?.seo?.description ?? "",
},
};
}
// ─── Shared helpers ────────────────────────────────────────────────────────
async function requestJson(url, options = {}) {
return fetch(url, {
headers: {
Accept: "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
...options,
}).then(async (response) => {
if (response.status === 401) { window.location.href = "/login"; return null; }
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
payload?.error?.message ||
payload?.message ||
payload?.error ||
`${response.status} ${response.statusText}`;
throw new Error(message);
}
return payload;
});
}
function renderCardGrid(items) {
const rows = Array.isArray(items) ? items : [];
return rows.map((item, index) => (
<a key={`${item?.href ?? "item"}-${index}`} href={item?.href ?? "#"} className="project-card block">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="project-card-title">{item?.title}</h3>
<p className="project-card-copy">{item?.description}</p>
</div>
{item?.tag ? <span className="project-inline-chip">{item.tag}</span> : null}
</div>
</a>
));
}
function formatOperationTimestamp(ts) {
if (!ts) return "unknown";
try {
return new Date(Number(ts) * 1000).toLocaleString();
} catch (_err) {
return String(ts);
}
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`;
}
function settingsStatusToneClass(tone) {
if (tone === "ok") {
return "text-dark-accent2";
}
if (tone === "error") {
return "text-dark-accent4";
}
return "text-body-soft";
}
const SETTINGS_SECTION_TAG_CLASS =
"inline-flex items-center border border-dark-border bg-dark-border px-2 py-[0.38rem] text-[0.66rem] font-mono uppercase tracking-[0.12em] text-body-soft";
function settingsOperationBadgeClass(status) {
if (status === "completed") {
return "!rounded-none !border !border-dark-accent2 !bg-transparent !text-dark-accent2";
}
if (status === "failed") {
return "!rounded-none !border !border-dark-accent4 !bg-transparent !text-dark-accent4";
}
return "!rounded-none !border !border-dark-border !bg-transparent !text-body-soft";
}
function SettingsSection({
id,
title,
description,
tag,
tone = "default",
children,
}: any) {
const titleClass =
tone === "danger"
? "text-dark-accent5"
: "text-body";
return (
<article
id={id}
className={cx(
"border-b border-dark-border",
tone === "danger" && "border-dark-accent4",
)}
>
<header className="flex items-start justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<h3 className={cx("text-[0.83rem] font-semibold tracking-[0.01em]", titleClass)}>
{title}
</h3>
{description ? (
<p className="mt-1 text-[0.78rem] leading-[1.45] text-body-soft">
{description}
</p>
) : null}
</div>
{tag ? <span className={SETTINGS_SECTION_TAG_CLASS}>{tag}</span> : null}
</header>
<div className="px-4 py-4">{children}</div>
</article>
);
}
function ProjectTransferPanel({ owner, project, api, initialOperations }) {
const [operations, setOperations] = useState(Array.isArray(initialOperations) ? initialOperations : []);
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [busyKey, setBusyKey] = useState("");
const [bundleFile, setBundleFile] = useState(null);
const [filesFile, setFilesFile] = useState(null);
const primaryButtonClass = "!rounded-none";
const outlineButtonClass = "!rounded-none !border !border-dark-border !bg-transparent !text-body hover:!bg-dark-border";
const ghostButtonClass = "!rounded-none !border !border-dark-border !bg-transparent !text-body-soft hover:!bg-dark-border hover:!text-body";
async function refreshOperations() {
if (!api?.operations) return;
try {
const payload = await requestJson(api.operations);
setOperations(Array.isArray(payload?.items) ? payload.items : []);
} catch (_err) {}
}
async function handleExport(kind) {
const url = kind === "bundle" ? api?.export_bundle : api?.export_files;
if (!url) return;
setBusyKey(`export:${kind}`);
setStatusMsg(`Preparing ${kind} export…`);
setStatusTone("info");
try {
const payload = await requestJson(url, { method: "POST" });
await refreshOperations();
if (payload?.download_url) {
window.location.href = payload.download_url;
}
setStatusMsg(`${kind === "bundle" ? "Bundle" : "Files"} export ready.`);
setStatusTone("ok");
} catch (err) {
setStatusMsg(`Export failed: ${err?.message || String(err)}`);
setStatusTone("error");
await refreshOperations();
} finally {
setBusyKey("");
}
}
async function handleImport(kind) {
const file = kind === "bundle" ? bundleFile : filesFile;
const url = kind === "bundle" ? api?.import_bundle : api?.import_files;
if (!url || !file) return;
setBusyKey(`import:${kind}`);
setStatusMsg(`Importing ${kind} archive…`);
setStatusTone("info");
try {
const fd = new FormData();
fd.append("archive", file);
const response = await fetch(url, { method: "POST", body: fd });
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
payload?.error?.message ||
payload?.message ||
payload?.error ||
`${response.status} ${response.statusText}`,
);
}
await refreshOperations();
setStatusMsg(`${kind === "bundle" ? "Bundle" : "Files"} import applied.`);
setStatusTone("ok");
if (kind === "bundle") setBundleFile(null);
if (kind === "files") setFilesFile(null);
} catch (err) {
setStatusMsg(`Import failed: ${err?.message || String(err)}`);
setStatusTone("error");
await refreshOperations();
} finally {
setBusyKey("");
}
}
return (
<SettingsSection
id="settings-transfer"
title="Project Export"
description={
<>
Download or apply first-class project archives. Bundle includes <code>repo/</code> and <code>data/</code>. Files archive includes Zebflow FS objects.
</>
}
tag="Portability"
>
<div className="grid gap-3 md:grid-cols-2">
<section className="bg-dark-border px-4 py-4">
<p className="text-[0.8rem] font-medium text-body">Export</p>
<p className="mt-2 text-[0.78rem] leading-[1.45] text-body-soft">
Credentials and DB connections stay platform-managed. Export only project workspace and files.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="sm"
className={primaryButtonClass}
disabled={busyKey !== ""}
onClick={() => handleExport("bundle")}
>
{busyKey === "export:bundle" ? "Preparing…" : "Export Bundle"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className={outlineButtonClass}
disabled={busyKey !== ""}
onClick={() => handleExport("files")}
>
{busyKey === "export:files" ? "Preparing…" : "Export Files"}
</Button>
</div>
</section>
<section className="bg-dark-border px-4 py-4">
<p className="text-[0.8rem] font-medium text-body">Import</p>
<p className="mt-2 text-[0.78rem] leading-[1.45] text-body-soft">
Apply one archive at a time to the current project. This replaces that scope on the target project.
</p>
<div className="mt-4 grid gap-3">
<Field label="Bundle archive">
<input
type="file"
accept=".tar"
onChange={(e) => {
const files = e?.target?.files;
setBundleFile(files && files[0] ? files[0] : null);
}}
/>
</Field>
<Button
type="button"
variant="primary"
size="sm"
className={primaryButtonClass}
disabled={!bundleFile || busyKey !== ""}
onClick={() => handleImport("bundle")}
>
{busyKey === "import:bundle" ? "Importing…" : "Import Bundle"}
</Button>
<Field label="Files archive">
<input
type="file"
accept=".tar"
onChange={(e) => {
const files = e?.target?.files;
setFilesFile(files && files[0] ? files[0] : null);
}}
/>
</Field>
<Button
type="button"
variant="outline"
size="sm"
className={outlineButtonClass}
disabled={!filesFile || busyKey !== ""}
onClick={() => handleImport("files")}
>
{busyKey === "import:files" ? "Importing…" : "Import Files"}
</Button>
</div>
</section>
</div>
<div className="mt-4 flex flex-wrap items-center gap-[0.7rem]">
<span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>
{statusMsg}
</span>
<Button type="button" variant="ghost" size="sm" className={ghostButtonClass} onClick={refreshOperations}>
Refresh Operations
</Button>
</div>
<div className="mt-4">
<p className="mb-2 text-[0.8rem] font-medium text-body">Recent Operations</p>
<div className="grid gap-2">
{operations.length === 0 ? (
<div className="text-[0.78rem] text-body-soft">No transfer operations yet.</div>
) : (
operations.map((item, index) => (
<div
key={`${item?.operation_id ?? "op"}-${index}`}
className="border border-dark-border px-[0.7rem] py-[0.7rem]"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[0.78rem] font-medium text-body">{item?.kind ?? "operation"}</div>
<div className="text-[0.72rem] text-body-soft">{item?.current_step || item?.status}</div>
</div>
<Badge
variant={
item?.status === "completed"
? "secondary"
: item?.status === "failed"
? "destructive"
: "outline"
}
className={settingsOperationBadgeClass(item?.status)}
label={item?.status ?? "unknown"}
/>
</div>
<div className="mt-2 text-[0.72rem] text-body-soft">
Updated {formatOperationTimestamp(item?.updated_at)}
</div>
{item?.error_message ? (
<div className="mt-2 text-[0.72rem] text-red-300">{item.error_message}</div>
) : null}
{item?.artifact_rel_path ? (
<div className="mt-2">
<Link
href={`/api/projects/${owner}/${project}/transfer/download/${item.operation_id}`}
className="text-[0.72rem] text-accent"
>
Download artifact
</Link>
</div>
) : null}
</div>
))
)}
</div>
</div>
</SettingsSection>
);
}
// ─── RWE Settings Panel ─────────────────────────────────────────────────────
function RwePanel({ api, initialConfig, owner, project }) {
const [allowList, setAllowList] = useState(
(initialConfig?.allow_list ?? []).join("\n")
);
const [minify, setMinify] = useState(Boolean(initialConfig?.minify_html));
const [strict, setStrict] = useState(initialConfig?.strict_mode !== false);
const [deploymentAssetBase, setDeploymentAssetBase] = useState(
String(initialConfig?.deployment_asset_base ?? "")
);
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [commitOpen, setCommitOpen] = useState(false);
const [pendingData, setPendingData] = useState(null);
function handleSubmit(e) {
e.preventDefault();
setPendingData({
allow_list: allowList.split(/[\n,]/).map((s) => s.trim()).filter(Boolean),
minify_html: minify,
strict_mode: strict,
deployment_asset_base: deploymentAssetBase.trim() || null,
});
setCommitOpen(true);
}
async function handleCommit(commitMessage) {
setCommitOpen(false);
setSaving(true);
setStatusMsg("Saving...");
setStatusTone("info");
try {
const resp = await requestJson(api, {
method: "PUT",
body: JSON.stringify({ commit_message: commitMessage, data: pendingData }),
});
if (resp?.committed) {
setStatusMsg("Saved & committed.");
setStatusTone("ok");
} else if (resp?.git_error) {
setStatusMsg(`Saved (git: ${resp.git_error})`);
setStatusTone("info");
} else {
setStatusMsg("Saved.");
setStatusTone("ok");
}
} catch (err) {
setStatusMsg(`Failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setSaving(false);
setPendingData(null);
}
}
async function handleClearCache() {
setClearing(true);
try {
const res = await fetch(`/api/projects/${owner}/${project}/rwe/cache/clear`, { method: "POST" });
if (res.ok) {
setStatusMsg("Template cache cleared.");
setStatusTone("ok");
} else {
setStatusMsg("Failed to clear cache.");
setStatusTone("error");
}
} finally {
setClearing(false);
}
}
return (
<SettingsSection
title="Reactive Web Engine"
description={
<>
Project-level compile and render controls for all <code>n.web.render</code> nodes.
</>
}
tag="RWE"
>
<CommitDialog
open={commitOpen}
section="rwe"
defaultMessage="settings(rwe): update RWE config"
onConfirm={handleCommit}
onCancel={() => { setCommitOpen(false); setPendingData(null); }}
/>
<form className="grid grid-cols-2 gap-[0.65rem]" onSubmit={handleSubmit}>
<label className="pipeline-editor-field">
<span>Script Allow List</span>
<Textarea
name="allow_list"
rows={4}
placeholder={"https://cdnjs.cloudflare.com/*\nhttps://cdn.jsdelivr.net/*"}
value={allowList}
onInput={(e) => setAllowList(e.currentTarget.value)}
/>
<small className="pipeline-editor-field-help">
One URL pattern per line (or comma-separated). Controls which external scripts and
stylesheets <code>--load-scripts</code> may reference. Blessed <code>zeb/*</code>{" "}
libraries are always allowed and do not appear here.
</small>
</label>
<div className="flex flex-col gap-2 pt-1">
<Checkbox
name="minify_html"
label="Minify HTML output"
checked={minify}
onChange={(e) => setMinify(e.target.checked)}
/>
<Checkbox
name="strict_mode"
label="Strict compile-time checks"
checked={strict}
onChange={(e) => setStrict(e.target.checked)}
/>
</div>
<label className="pipeline-editor-field col-span-full">
<span>Asset Base Path</span>
<Input
name="deployment_asset_base"
placeholder={`/assets/${owner ?? "owner"}/${project ?? "project"}`}
value={deploymentAssetBase}
onInput={(e) => setDeploymentAssetBase(e.currentTarget.value)}
/>
<small className="pipeline-editor-field-help">
Replaces the default <code>/assets/{"{owner}/{project}"}</code> prefix in all rendered HTML — scripts, images, uploads, library chunks. E.g. set to <code>/my/custom/path</code> and <code>/assets/{"{owner}/{project}"}/rwe/…</code> becomes <code>/my/custom/path/rwe/…</code>. Leave empty to keep the default.
</small>
</label>
<div className="col-span-full flex items-center gap-[0.7rem]">
<Button
type="submit"
variant="primary"
size="sm"
disabled={saving}
label={saving ? "Saving..." : "Save RWE Config"}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={clearing}
onClick={handleClearCache}
label={clearing ? "Clearing…" : "Clear Template Cache"}
/>
<span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</span>
</div>
</form>
</SettingsSection>
);
}
// ─── Assistant Settings Panel ───────────────────────────────────────────────
function AssistantPanel({ api, credentials, initialConfig }) {
const creds = Array.isArray(credentials) ? credentials : [];
const [highModel, setHighModel] = useState(
String(initialConfig?.llm_high_credential_id || "")
);
const [generalModel, setGeneralModel] = useState(
String(initialConfig?.llm_general_credential_id || "")
);
const [maxSteps, setMaxSteps] = useState(Number(initialConfig?.max_steps ?? 50));
const [maxReplans, setMaxReplans] = useState(Number(initialConfig?.max_replans ?? 2));
const [historyPairs, setHistoryPairs] = useState(
Number(initialConfig?.chat_history_pairs ?? 10)
);
const [enabled, setEnabled] = useState(Boolean(initialConfig?.enabled));
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [saving, setSaving] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setSaving(true);
setStatusMsg("Saving...");
setStatusTone("info");
try {
await requestJson(api, {
method: "PUT",
body: JSON.stringify({
llm_high_credential_id: highModel.trim() || null,
llm_general_credential_id: generalModel.trim() || null,
max_steps: maxSteps,
max_replans: maxReplans,
chat_history_pairs: historyPairs,
enabled,
}),
});
setStatusMsg("Saved.");
setStatusTone("ok");
} catch (err) {
setStatusMsg(`Failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setSaving(false);
}
}
return (
<article className="border border-border rounded-xl bg-surface p-[0.85rem] mb-[0.9rem]">
<header className="flex items-start justify-between gap-3 mb-[0.65rem]">
<div>
<h3 className="project-card-title">Project Assistant</h3>
<p className="project-card-copy">Bind credential profiles for assistant reasoning tiers.</p>
</div>
<span className="project-inline-chip">Automaton</span>
</header>
<form className="grid grid-cols-2 gap-[0.65rem]" onSubmit={handleSubmit}>
<label className="pipeline-editor-field">
<span>High Model</span>
<Select name="llm_high_credential_id" value={highModel} onChange={(e) => setHighModel(e.target.value)}>
<SelectOption value="" label="None" />
{creds.map((item, index) => (
<SelectOption
key={`${item?.credential_id ?? "credential"}-${index}`}
value={item?.credential_id ?? ""}
label={`${item?.title} · ${item?.credential_id}`}
/>
))}
</Select>
<small className="pipeline-editor-field-help">Planning and decomposition model.</small>
</label>
<label className="pipeline-editor-field">
<span>General Model</span>
<Select name="llm_general_credential_id" value={generalModel} onChange={(e) => setGeneralModel(e.target.value)}>
<SelectOption value="" label="None" />
{creds.map((item, index) => (
<SelectOption
key={`${item?.credential_id ?? "credential-general"}-${index}`}
value={item?.credential_id ?? ""}
label={`${item?.title} · ${item?.credential_id}`}
/>
))}
</Select>
<small className="pipeline-editor-field-help">Default model for regular project chat requests.</small>
</label>
<label className="pipeline-editor-field">
<span>Max Steps</span>
<Input
type="number"
name="max_steps"
min={1}
max={1000}
value={maxSteps}
onChange={(e) => setMaxSteps(Number(e.target.value))}
/>
<small className="pipeline-editor-field-help">Upper bound for future multi-step agent execution.</small>
</label>
<label className="pipeline-editor-field">
<span>Max Replans</span>
<Input
type="number"
name="max_replans"
min={0}
max={64}
value={maxReplans}
onChange={(e) => setMaxReplans(Number(e.target.value))}
/>
<small className="pipeline-editor-field-help">Maximum replanning attempts before stopping.</small>
</label>
<label className="pipeline-editor-field">
<span>Chat History Pairs</span>
<Input
type="number"
name="chat_history_pairs"
min={0}
max={50}
value={historyPairs}
onChange={(e) => setHistoryPairs(Number(e.target.value))}
/>
<small className="pipeline-editor-field-help">
Number of previous user/assistant exchanges kept as context (0 = no history).
</small>
</label>
<div className="flex flex-col gap-2 pt-1">
<Checkbox
name="enabled"
label="Enable assistant for this project"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</div>
<div className="col-span-full flex items-center gap-[0.7rem]">
<Button
type="submit"
variant="primary"
size="sm"
disabled={saving}
label={saving ? "Saving..." : "Save Assistant Config"}
/>
<span className={cx("text-[0.72rem]", statusTone === "ok" ? "text-dark-accent2" : statusTone === "error" ? "text-red-300" : "text-body-soft")}>{statusMsg}</span>
</div>
</form>
</article>
);
}
// ─── Libraries Panel ─────────────────────────────────────────────────────────
function LibrariesPanel({ items, api }) {
const [libs, setLibs] = useState(Array.isArray(items) ? items : []);
const [loading, setLoading] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
async function toggle(lib) {
setLoading(lib.name);
setErrorMsg(null);
try {
if (lib.enabled) {
await requestJson(`${api}/disable?name=${encodeURIComponent(lib.name)}`, { method: "DELETE" });
} else {
await requestJson(`${api}/enable`, {
method: "POST",
body: JSON.stringify({
name: lib.name,
version: lib.packed_version,
source: "offline",
}),
});
}
const updated = await requestJson(api);
setLibs(Array.isArray(updated) ? updated : libs);
} catch (err) {
setErrorMsg(String(err?.message || err));
} finally {
setLoading(null);
}
}
return (
<div className="flex flex-col gap-4">
{errorMsg ? (
<p className="text-[0.72rem] text-red-300">{errorMsg}</p>
) : null}
{libs.map((lib) => (
<article key={lib.name} className="border border-border rounded-xl bg-surface p-[0.85rem] mb-[0.9rem]">
<header className="flex items-start justify-between gap-3 mb-[0.65rem]">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h3 className="project-card-title">{lib.name}</h3>
<Badge label={lib.packed_version} variant="secondary" />
<Badge label={lib.packed_kind} variant={lib.packed_kind === "full" ? "default" : "secondary"} />
</div>
<p className="project-card-copy">{lib.description}</p>
{lib.enabled ? (
<p className="project-card-copy" data-tone="ok">
locked: {lib.installed_version} · {lib.source}
</p>
) : null}
</div>
<Button
variant={lib.enabled ? "outline" : "primary"}
size="sm"
disabled={loading === lib.name}
label={loading === lib.name ? "..." : lib.enabled ? "Disable" : "Enable"}
onClick={() => toggle(lib)}
/>
</header>
</article>
))}
</div>
);
}
// ─── Re-index Panel ─────────────────────────────────────────────────────────
function ReIndexPanel({ api }) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
async function handleReindex() {
setBusy(true);
setResult(null);
setError(null);
try {
const data = await requestJson(api, { method: "POST" });
setResult(data);
} catch (err) {
setError((err as any)?.message || String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsSection
title="Re-index from Files"
description={
<>
Scan <code>repo/pipelines/</code> on disk and register all found files into the catalog.
Use this after a DB wipe, crash recovery, or restoring from a git backup.
</>
}
tag="Recovery"
>
<div className="flex flex-col gap-[0.55rem]">
<div className="flex items-center gap-[0.7rem]">
<Button
variant="outline"
size="sm"
disabled={busy}
label={busy ? "Indexing..." : "Run Re-index"}
onClick={handleReindex}
/>
{error ? (
<span className="text-[0.72rem] text-red-300">{error}</span>
) : null}
</div>
{result ? (
<div className="flex items-center gap-[0.55rem] flex-wrap">
<span className="project-inline-chip">{result.pipelines} pipeline{result.pipelines !== 1 ? "s" : ""}</span>
<span className="project-inline-chip">{result.templates} template{result.templates !== 1 ? "s" : ""}</span>
<span className="project-inline-chip">{result.assets} asset{result.assets !== 1 ? "s" : ""}</span>
{Array.isArray(result.errors) && result.errors.length > 0 ? (
<span className="text-[0.72rem] text-red-300">{result.errors.length} error{result.errors.length !== 1 ? "s" : ""}</span>
) : (
<span className="text-[0.72rem] text-dark-accent2">Done.</span>
)}
</div>
) : null}
</div>
</SettingsSection>
);
}
// ─── Git Identity Panel ──────────────────────────────────────────────────────
function GitPanel({ remoteApi, initialConfig, healthApi, repairApi }) {
const [credentialId, setCredentialId] = useState(String(initialConfig?.credential_id ?? ""));
const [repoUrl, setRepoUrl] = useState(String(initialConfig?.repo_url ?? ""));
const [branch, setBranch] = useState(String(initialConfig?.branch ?? "main"));
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [saving, setSaving] = useState(false);
function handleSubmit(e) {
e.preventDefault();
setSaving(true);
setStatusMsg("Saving...");
setStatusTone("info");
requestJson(remoteApi, {
method: "PUT",
body: JSON.stringify({
credential_id: credentialId.trim(),
repo_url: repoUrl.trim(),
branch: branch.trim() || "main",
}),
}).then((resp) => {
if (resp?.ok) {
setStatusMsg("Remote saved.");
setStatusTone("ok");
} else {
setStatusMsg("Saved.");
setStatusTone("ok");
}
}).catch((err) => {
setStatusMsg(`Failed: ${err?.message || String(err)}`);
setStatusTone("error");
}).finally(() => {
setSaving(false);
});
}
useEffect(() => {
setCredentialId(String(initialConfig?.credential_id ?? ""));
setRepoUrl(String(initialConfig?.repo_url ?? ""));
setBranch(String(initialConfig?.branch ?? "main"));
}, [initialConfig?.credential_id, initialConfig?.repo_url, initialConfig?.branch]);
return (
<>
<GitHealthPanel healthApi={healthApi} repairApi={repairApi} />
<SettingsSection
title="Git Remote"
description="Remote repository settings for project sync and push operations. Git author identity comes from the acting platform user."
tag="Git"
>
<form className="grid grid-cols-1 gap-[0.65rem]" onSubmit={handleSubmit}>
<Field label="Credential ID">
<Input
name="credential_id"
placeholder="git-origin"
value={credentialId}
onInput={(e) => setCredentialId(e.currentTarget.value)}
/>
</Field>
<Field label="Repository URL">
<Input
name="repo_url"
placeholder="https://gitlab.com/org/repo.git"
value={repoUrl}
onInput={(e) => setRepoUrl(e.currentTarget.value)}
/>
</Field>
<Field label="Branch">
<Input
name="branch"
placeholder="main"
value={branch}
onInput={(e) => setBranch(e.currentTarget.value)}
/>
</Field>
<div className="flex items-center gap-[0.7rem]">
<Button
type="submit"
variant="primary"
size="sm"
disabled={saving}
label={saving ? "Saving..." : "Save Git Remote"}
/>
<span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</span>
</div>
</form>
</SettingsSection>
</>
);
}
function GitHealthPanel({ healthApi, repairApi }) {
const [health, setHealth] = useState(null);
const [statusMsg, setStatusMsg] = useState("");
const [statusTone, setStatusTone] = useState("info");
const [busyAction, setBusyAction] = useState("");
const [confirmMode, setConfirmMode] = useState("");
async function loadHealth(showStatus = false) {
if (!healthApi) return;
if (showStatus) {
setStatusMsg("Checking repository health...");
setStatusTone("info");
}
try {
const payload = await requestJson(healthApi);
setHealth(payload?.health ?? null);
if (showStatus) {
setStatusMsg("Repository health updated.");
setStatusTone("ok");
}
} catch (err) {
setStatusMsg(`Check failed: ${err?.message || String(err)}`);
setStatusTone("error");
}
}
async function handleRepair(mode) {
if (!repairApi) return;
const label = mode === "repair" ? "Repairing" : "Reinitializing";
setBusyAction(mode);
setStatusMsg(`${label} repository...`);
setStatusTone("info");
try {
const payload = await requestJson(repairApi, {
method: "POST",
body: JSON.stringify({ mode }),
});
setHealth(payload?.health ?? null);
setStatusMsg(mode === "repair" ? "Repository repair completed." : "Repository reinitialized.");
setStatusTone("ok");
} catch (err) {
setStatusMsg(`Action failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setBusyAction("");
}
}
useEffect(() => {
loadHealth(false);
}, [healthApi]);
const healthState = String(health?.state ?? "unknown");
const recommended = String(health?.recommended_action ?? "none");
const branch = String(health?.branch ?? "");
const confirmTitle = confirmMode === "repair" ? "Repair Git metadata?" : "Reinitialize Git repository?";
const confirmMessage = confirmMode === "repair"
? "This will try to repair the current Git metadata while keeping the existing project files."
: "This will rebuild Git metadata for the current project worktree. Existing broken Git metadata will be replaced.";
const detailItems = [
{ label: ".git", value: health?.git_dir_exists ? "present" : "missing" },
{ label: "work tree", value: health?.is_work_tree ? "valid" : "invalid" },
{ label: "HEAD", value: health?.head_exists ? "present" : "missing" },
{ label: "config", value: health?.config_exists ? "present" : "missing" },
{ label: "objects", value: health?.objects_exists ? "present" : "missing" },
{ label: "refs", value: health?.refs_exists ? "present" : "missing" },
];
return (
<SettingsSection
title="Repository Health"
description="Inspect and repair the local Git metadata for this project when status is broken or missing."
tag="Git"
>
<ConfirmDialog
open={confirmMode.length > 0}
title={confirmTitle}
message={confirmMessage}
confirmLabel="Yes"
cancelLabel="No"
variant={confirmMode === "reinitialize" ? "destructive" : "default"}
onClose={() => setConfirmMode("")}
onConfirm={() => {
const mode = confirmMode;
setConfirmMode("");
handleRepair(mode);
}}
/>
<div className="flex flex-col gap-[0.8rem]">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex flex-col gap-[0.45rem] min-w-0">
<div className="flex items-center gap-[0.5rem] flex-wrap">
<span className={cx(
"inline-flex items-center rounded-full border px-2 py-[0.22rem] text-[0.65rem] font-semibold uppercase tracking-[0.08em]",
healthState === "healthy"
? "border-emerald-400/40 bg-emerald-400/10 text-emerald-300"
: healthState === "missing"
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
: healthState === "broken"
? "border-red-400/40 bg-red-400/10 text-red-300"
: "border-border bg-surface-2 text-body-soft"
)}>
{healthState}
</span>
{branch ? <span className="project-inline-chip">branch: {branch}</span> : null}
{recommended !== "none" ? (
<span className="project-inline-chip">recommended: {recommended}</span>
) : null}
</div>
<div className="text-[0.72rem] text-body-soft break-all">
{String(health?.repo_path ?? "Loading repository path...")}
</div>
{health?.last_error ? (
<div className="text-[0.72rem] text-red-300 break-words">{health.last_error}</div>
) : null}
</div>
<div className="flex items-center gap-[0.45rem] flex-wrap">
<Button
variant="outline"
size="sm"
disabled={busyAction.length > 0}
label="Check"
onClick={() => loadHealth(true)}
/>
<Button
variant="outline"
size="sm"
disabled={busyAction.length > 0}
label={busyAction === "repair" ? "Repairing..." : "Repair"}
onClick={() => setConfirmMode("repair")}
/>
<Button
variant="ghost"
size="sm"
disabled={busyAction.length > 0}
className="border border-red-400/25 text-red-200 hover:bg-red-500/10"
label={busyAction === "reinitialize" ? "Reinitializing..." : "Reinitialize"}
onClick={() => setConfirmMode("reinitialize")}
/>
</div>
</div>
<div className="flex flex-wrap gap-[0.45rem]">
{detailItems.map((item) => (
<span
key={item.label}
className="inline-flex items-center gap-[0.35rem] rounded-full border border-border-soft bg-surface-2 px-[0.72rem] py-[0.32rem] text-[0.72rem] text-body"
>
<span className="uppercase tracking-[0.08em] text-body-soft">{item.label}</span>
<span>{item.value}</span>
</span>
))}
</div>
{statusMsg ? (
<div className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</div>
) : null}
</div>
</SettingsSection>
);
}
// ─── Node Registry Panel ─────────────────────────────────────────────────────
function NodeRegistryPanel({ groups, count }) {
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("installed");
const nodeGroups = Array.isArray(groups) ? groups : [];
const query = searchQuery.toLowerCase().trim();
const filteredGroups = query
? nodeGroups
.map((group) => ({
...group,
nodes: (Array.isArray(group?.nodes) ? group.nodes : []).filter((node) =>
`${node?.title ?? ""} ${node?.kind ?? ""} ${node?.description ?? ""}`
.toLowerCase()
.includes(query)
),
}))
.filter((group) => group.nodes.length > 0)
: nodeGroups;
const visibleCount = filteredGroups.reduce(
(sum, g) => sum + (g.nodes?.length ?? 0),
0
);
return (
<div className="border border-border rounded-xl bg-surface overflow-hidden">
{/* Toolbar */}
<div className="flex flex-row items-center gap-[0.55rem] px-3 pt-[0.7rem] pb-[0.6rem]">
<Input
placeholder="Search nodes by name or kind..."
value={searchQuery}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
/>
<Button variant="outline" size="sm" label="+ Install" disabled />
</div>
{/* Tabs */}
<div className="flex gap-1 px-3 pb-2">
{(["installed", "discover", "updates"] as const).map((tab) => (
<Button
key={tab}
variant="ghost"
size="sm"
className={cx(tab === activeTab && "bg-accent/10 text-accent border-accent/40")}
label={tab === "installed" ? `Installed · ${count}` : tab === "discover" ? "Discover" : "Updates"}
onClick={() => setActiveTab(tab)}
/>
))}
</div>
<Separator />
{/* Installed panel */}
{activeTab === "installed" ? (
<div>
<p className="px-3 py-[0.4rem] text-[0.72rem] text-body-soft border-b border-border-soft">
{visibleCount === count
? `${count} nodes · ${count} built-in`
: `${visibleCount} of ${count} nodes · ${count} built-in`}
</p>
<div className="flex flex-col gap-[0.35rem] px-3 py-[0.6rem]">
{filteredGroups.length === 0 ? (
<p className="p-8 text-center text-[0.8rem] text-body-soft">No nodes found.</p>
) : (
filteredGroups.map((group, gi) => (
<div key={`grp-${gi}`}>
<div className="flex items-center gap-2 mb-[0.35rem] mt-2 first:mt-0">
{group?.prefix ? (
<span className="text-[0.65rem] font-mono text-body-soft tracking-[0.05em] whitespace-nowrap shrink-0">{group.prefix}</span>
) : null}
<div className="flex-1 h-px bg-border-soft" />
</div>
{(Array.isArray(group?.nodes) ? group.nodes : []).map((node, ni) => (
<div
key={`${node?.kind ?? "node"}-${ni}`}
className="flex items-stretch border border-border-soft rounded-lg bg-surface-2 overflow-hidden transition-colors duration-[120ms]"
>
<div className="w-[3px] shrink-0 bg-border" />
<div className="flex-1 min-w-0 px-3 py-[0.6rem] flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-[0.83rem] font-bold text-body leading-tight">{node?.title}</div>
<div className="text-[0.66rem] font-mono text-body-soft mt-[0.15rem] tracking-[0.03em]">{node?.kind}</div>
<div className="text-[0.75rem] leading-[1.4] text-body-soft mt-[0.3rem]">{node?.description}</div>
</div>
<div className="flex items-center flex-wrap gap-[0.3rem] shrink-0 pt-[0.1rem]">
{node?.script_available ? (
<Badge label="n.script access" variant="outline" className="text-[0.65rem] text-gray-200 border-white/25 bg-transparent" />
) : null}
{node?.ai_registered ? (
<Badge label="agent tool" variant="outline" className="text-[0.65rem] text-gray-200 border-white/25 bg-transparent" />
) : null}
<span className="inline-flex items-center px-2 py-[0.25rem] rounded-full border border-[rgba(74,222,128,0.3)] text-[#4ade80] text-[0.65rem] font-mono uppercase tracking-widest">● installed</span>
</div>
</div>
</div>
))}
</div>
))
)}
</div>
</div>
) : null}
{/* Discover panel */}
{activeTab === "discover" ? (
<div className="p-4 px-3">
<Card>
<CardContent>
<CardDescription label="Community registry — coming soon." />
<CardDescription label="Install custom nodes from a Git URL using + Install above." />
</CardContent>
</Card>
</div>
) : null}
{/* Updates panel */}
{activeTab === "updates" ? (
<div className="p-4 px-3">
<Card>
<CardContent>
<CardDescription label={`All ${count} built-in nodes are current.`} />
</CardContent>
</Card>
</div>
) : null}
</div>
);
}
// ─── Logging Settings Panel ─────────────────────────────────────────────────
function LoggingPanel({ api, invocationsApi, initialConfig }) {
const [maxInv, setMaxInv] = useState(String(initialConfig?.max_invocations ?? 20));
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [saving, setSaving] = useState(false);
const [commitOpen, setCommitOpen] = useState(false);
const [pendingData, setPendingData] = useState(null);
const [stats, setStats] = useState(null);
const [loadingStats, setLoadingStats] = useState(false);
const [clearTarget, setClearTarget] = useState(null);
const [clearing, setClearing] = useState(false);
async function loadStats() {
if (!invocationsApi) return;
setLoadingStats(true);
try {
const resp = await requestJson(invocationsApi);
setStats(resp?.stats ?? null);
} catch (err) {
setStatusMsg(`Failed to load logs: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setLoadingStats(false);
}
}
useEffect(() => {
loadStats();
}, [invocationsApi]);
function handleSubmit(e) {
e.preventDefault();
setPendingData({ max_invocations: parseInt(maxInv, 10) || 20 });
setCommitOpen(true);
}
async function handleCommit(commitMessage) {
setCommitOpen(false);
setSaving(true);
setStatusMsg("Saving...");
setStatusTone("info");
try {
const resp = await requestJson(api, {
method: "PUT",
body: JSON.stringify({ commit_message: commitMessage, data: pendingData }),
});
if (resp?.committed) {
setStatusMsg("Saved & committed.");
setStatusTone("ok");
} else if (resp?.git_error) {
setStatusMsg(`Saved (git: ${resp.git_error})`);
setStatusTone("info");
} else {
setStatusMsg("Saved.");
setStatusTone("ok");
}
} catch (err) {
setStatusMsg(`Failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setSaving(false);
setPendingData(null);
}
}
async function handleClearLogs() {
if (!invocationsApi || !clearTarget) return;
setClearing(true);
setStatusMsg("Clearing invocation logs...");
setStatusTone("info");
try {
const url = clearTarget.file_rel_path
? `${invocationsApi}?pipeline=${encodeURIComponent(clearTarget.file_rel_path)}`
: invocationsApi;
const resp = await requestJson(url, { method: "DELETE" });
setStatusMsg(`Cleared ${resp?.deleted ?? 0} invocation log row(s).`);
setStatusTone("ok");
await loadStats();
} catch (err) {
setStatusMsg(`Clear failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setClearing(false);
setClearTarget(null);
}
}
const pipelines = Array.isArray(stats?.pipelines) ? stats.pipelines : [];
const dbTotal = Number(stats?.db_bytes || 0) + Number(stats?.wal_bytes || 0) + Number(stats?.shm_bytes || 0);
return (
<article className="border border-border rounded-xl bg-surface p-[0.85rem] mb-[0.9rem]">
<CommitDialog
open={commitOpen}
section="logging"
defaultMessage="settings(logging): update retention config"
onConfirm={handleCommit}
onCancel={() => { setCommitOpen(false); setPendingData(null); }}
/>
<ConfirmDialog
open={!!clearTarget}
title={clearTarget?.file_rel_path ? "Clear Pipeline Invocation Logs" : "Clear All Invocation Logs"}
message={
clearTarget?.file_rel_path
? `Delete invocation history for ${clearTarget.file_rel_path}? This cannot be undone.`
: "Delete all invocation history for this project? This cannot be undone."
}
confirmLabel={clearing ? "Clearing..." : "Clear Logs"}
cancelLabel="Cancel"
variant="destructive"
onConfirm={handleClearLogs}
onClose={() => !clearing && setClearTarget(null)}
/>
<header className="flex items-start justify-between gap-3 mb-[0.65rem]">
<div>
<h3 className="project-card-title">Pipeline Logging</h3>
<p className="project-card-copy">Retention, storage, and cleanup for project invocation logs.</p>
</div>
<span className="project-inline-chip">Logging</span>
</header>
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="border border-dark-border bg-dark-panel p-3">
<p className="text-[0.66rem] uppercase tracking-[0.08em] text-body-soft">Rows</p>
<p className="mt-1 text-[1rem] font-semibold text-body">{stats?.count ?? 0}</p>
</div>
<div className="border border-dark-border bg-dark-panel p-3">
<p className="text-[0.66rem] uppercase tracking-[0.08em] text-body-soft">Trace JSON</p>
<p className="mt-1 text-[1rem] font-semibold text-body">{formatBytes(stats?.trace_bytes)}</p>
</div>
<div className="border border-dark-border bg-dark-panel p-3">
<p className="text-[0.66rem] uppercase tracking-[0.08em] text-body-soft">Log Store</p>
<p className="mt-1 text-[1rem] font-semibold text-body">{formatBytes(dbTotal)}</p>
</div>
<div className="border border-dark-border bg-dark-panel p-3">
<p className="text-[0.66rem] uppercase tracking-[0.08em] text-body-soft">Pipelines</p>
<p className="mt-1 text-[1rem] font-semibold text-body">{pipelines.length}</p>
</div>
</div>
<form className="grid grid-cols-2 gap-[0.65rem] mb-4" onSubmit={handleSubmit}>
<Field label="Max Invocations Per Pipeline">
<Input
name="max_invocations"
type="number"
min="1"
max="1000"
value={maxInv}
onInput={(e) => setMaxInv(e.currentTarget.value)}
/>
<small className="pipeline-editor-field-help">
How many invocation log entries to retain per pipeline. Oldest are dropped. Default: 20.
</small>
</Field>
<div className="col-span-full flex flex-wrap items-center gap-[0.7rem]">
<Button
type="submit"
variant="primary"
size="sm"
disabled={saving}
label={saving ? "Saving..." : "Save Logging Config"}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={loadingStats}
onClick={loadStats}
>
{loadingStats ? "Refreshing..." : "Refresh Stats"}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={clearing || Number(stats?.count || 0) === 0}
onClick={() => setClearTarget({ file_rel_path: null })}
>
Clear All Logs
</Button>
<span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</span>
</div>
</form>
<div className="border border-dark-border">
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_7rem_7rem_10rem_5rem] gap-2 border-b border-dark-border bg-dark-panel px-3 py-2 text-[0.68rem] font-semibold uppercase tracking-[0.08em] text-body-soft">
<span>Pipeline</span>
<span className="text-right">Rows</span>
<span className="text-right">Trace</span>
<span className="text-right">Largest</span>
<span>Latest</span>
<span className="text-right">Action</span>
</div>
<div className="max-h-[420px] overflow-auto">
{pipelines.length ? pipelines.map((item) => (
<div key={item.file_rel_path} className="grid grid-cols-[minmax(0,1fr)_5.5rem_7rem_7rem_10rem_5rem] gap-2 border-b border-dark-border px-3 py-2 text-[0.74rem] last:border-b-0">
<span className="truncate font-mono text-body" title={item.file_rel_path}>{item.file_rel_path}</span>
<span className="text-right text-body-soft">{item.count}</span>
<span className="text-right text-body-soft">{formatBytes(item.trace_bytes)}</span>
<span className="text-right text-body-soft">{formatBytes(item.largest_trace_bytes)}</span>
<span className="truncate text-body-soft">{formatOperationTimestamp(item.latest_at)}</span>
<span className="text-right">
<Button
type="button"
variant="outline"
size="xs"
disabled={clearing}
onClick={() => setClearTarget({ file_rel_path: item.file_rel_path })}
>
Clear
</Button>
</span>
</div>
)) : (
<div className="px-3 py-4 text-[0.78rem] text-body-soft">
{loadingStats ? "Loading invocation logs..." : "No invocation logs stored for this project."}
</div>
)}
</div>
</div>
</article>
);
}
// ─── Runtime Defaults Panel ──────────────────────────────────────────────────
function RuntimeDefaultsPanel({ api, initialConfig }) {
const [maxMb, setMaxMb] = useState(Number(initialConfig?.max_asset_size_mb ?? 10));
const [fileMaxMb, setFileMaxMb] = useState(Number(initialConfig?.max_file_size_mb ?? 1024));
const [webhookMaxMb, setWebhookMaxMb] = useState(Number(initialConfig?.webhook_body_max_mb ?? 100));
const [nodeTimeoutSecs, setNodeTimeoutSecs] = useState(Number(initialConfig?.pipeline_node_timeout_secs ?? 30));
const [statusMsg, setStatusMsg] = useState("Ready.");
const [statusTone, setStatusTone] = useState("info");
const [saving, setSaving] = useState(false);
const [commitOpen, setCommitOpen] = useState(false);
const [pendingData, setPendingData] = useState(null);
function handleSubmit(e) {
e.preventDefault();
setPendingData({
max_asset_size_mb: maxMb,
max_file_size_mb: fileMaxMb,
webhook_body_max_mb: webhookMaxMb,
pipeline_node_timeout_secs: nodeTimeoutSecs,
});
setCommitOpen(true);
}
async function handleCommit(commitMessage) {
setCommitOpen(false);
setSaving(true);
setStatusMsg("Saving...");
setStatusTone("info");
try {
const resp = await requestJson(api, {
method: "PUT",
body: JSON.stringify({ commit_message: commitMessage, data: pendingData }),
});
if (resp?.committed) {
setStatusMsg("Saved & committed.");
setStatusTone("ok");
} else if (resp?.git_error) {
setStatusMsg(`Saved (git: ${resp.git_error})`);
setStatusTone("info");
} else {
setStatusMsg("Saved.");
setStatusTone("ok");
}
} catch (err) {
setStatusMsg(`Failed: ${err?.message || String(err)}`);
setStatusTone("error");
} finally {
setSaving(false);
setPendingData(null);
}
}
return (
<SettingsSection
title="Runtime Defaults"
description="Project-level upload limits and node execution timeout defaults."
tag="Assets"
>
<CommitDialog
open={commitOpen}
section="assets"
defaultMessage="settings(assets): update runtime defaults"
onConfirm={handleCommit}
onCancel={() => { setCommitOpen(false); setPendingData(null); }}
/>
<form className="flex flex-col gap-[0.65rem]" onSubmit={handleSubmit}>
<label className="pipeline-editor-field">
<span>Max asset upload size: <strong>{maxMb} MB</strong></span>
<input
type="range"
name="max_asset_size_mb"
min={5}
max={1024}
step={1}
value={maxMb}
onInput={(e) => setMaxMb(Number((e.target as HTMLInputElement).value))}
className="w-full cursor-pointer accent-dark-accent1"
/>
<small className="pipeline-editor-field-help">
Maximum file size per uploaded project asset (5–1024 MB).
</small>
</label>
<label className="pipeline-editor-field">
<span>Max FS upload size: <strong>{fileMaxMb} MB</strong></span>
<input
type="range"
name="max_file_size_mb"
min={5}
max={1024}
step={1}
value={fileMaxMb}
onInput={(e) => setFileMaxMb(Number((e.target as HTMLInputElement).value))}
className="w-full cursor-pointer accent-dark-accent1"
/>
<small className="pipeline-editor-field-help">
Per-file limit for Project Files uploads at <code>/api/projects/.../files/upload</code> (5–1024 MB).
</small>
</label>
<label className="pipeline-editor-field">
<span>Webhook body max: <strong>{webhookMaxMb} MB</strong></span>
<input
type="range"
name="webhook_body_max_mb"
min={100}
max={1024}
step={1}
value={webhookMaxMb}
onInput={(e) => setWebhookMaxMb(Number((e.target as HTMLInputElement).value))}
className="w-full cursor-pointer accent-dark-accent1"
/>
<small className="pipeline-editor-field-help">
Per-project logical request limit for <code>/wh/...</code> uploads (100–1024 MB). This should be at least as large as the documents your webhook pipelines need to accept.
</small>
</label>
<label className="pipeline-editor-field">
<span>Pipeline node timeout: <strong>{nodeTimeoutSecs} sec</strong></span>
<input
type="range"
name="pipeline_node_timeout_secs"
min={5}
max={600}
step={5}
value={nodeTimeoutSecs}
onInput={(e) => setNodeTimeoutSecs(Number((e.target as HTMLInputElement).value))}
className="w-full cursor-pointer accent-dark-accent1"
/>
<small className="pipeline-editor-field-help">
Maximum runtime per node before timeout (5–3600 sec effective). Raise this for heavy transforms like PDF conversion.
</small>
</label>
<div className="flex items-center gap-[0.7rem]">
<Button
type="submit"
variant="primary"
size="sm"
disabled={saving}
label={saving ? "Saving..." : "Save Defaults"}
/>
<span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</span>
</div>
</form>
</SettingsSection>
);
}
// ─── Files ────────────────────────────────────────────────────────────────────
function FilesPanel() {
return (
<div className="project-settings-panel">
<div className="project-settings-panel-head">
<p className="project-card-label">Object Storage</p>
<Badge variant="outline">Coming soon</Badge>
</div>
<div className="project-settings-panel-body flex flex-col gap-6 pt-2">
{/* S3 */}
<Card className="opacity-60">
<CardContent className="flex items-start gap-4 pt-5">
<div className="mt-0.5 rounded bg-accent/10 p-2 text-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 8V16a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<path d="M3 8l9-5 9 5"/>
<path d="M12 3v18"/>
</svg>
</div>
<div className="flex-1">
<p className="text-[0.88rem] font-semibold text-body">Amazon S3 / S3-Compatible</p>
<p className="mt-0.5 text-[0.78rem] text-body-soft">
Connect an S3 bucket (or any compatible store — Cloudflare R2, MinIO, Backblaze B2) as the
primary file backend for this project. Uploaded assets will be stored in the bucket and served
via pre-signed or public URLs.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{["AWS S3", "Cloudflare R2", "MinIO", "Backblaze B2", "Tigris"].map((label) => (
<Badge key={label} variant="secondary" className="text-[0.72rem]">{label}</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<p className="text-[0.78rem] text-body-soft">
File storage integration is on the roadmap. When available, you'll be able to bind a credential
profile here and choose a bucket. Existing assets in <code className="font-mono text-[0.75rem]">repo/pipelines/assets/</code> will be
migrated automatically.
</p>
</div>
</div>
);
}
// ─── Git Branch Panel ─────────────────────────────────────────────────────────
function GitBranchPanel({ owner, project }) {
const branchesUrl = `/api/projects/${owner}/${project}/git/branches`;
const [current, setCurrent] = useState("");
const [branches, setBranches] = useState([]);
const [selected, setSelected] = useState("");
const [newBranch, setNewBranch] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
const [msgTone, setMsgTone] = useState("info");
async function fetchBranches() {
try {
const data = await requestJson(branchesUrl);
if (!data) return;
setCurrent(data.current ?? "");
setBranches(Array.isArray(data.branches) ? data.branches : []);
setSelected(data.current ?? "");
} catch (_) {}
}
useEffect(() => { fetchBranches(); }, []);
async function handleSwitch() {
if (!selected || selected === current) return;
setBusy(true); setMsg("");
try {
await requestJson(branchesUrl, { method: "POST", body: JSON.stringify({ branch: selected, create: false }) });
setMsg(`Switched to ${selected}.`); setMsgTone("ok");
await fetchBranches();
} catch (e) { setMsg((e as any)?.message || "Failed"); setMsgTone("error"); }
setBusy(false);
}
async function handleCreate() {
const name = newBranch.trim();
if (!name) return;
setBusy(true); setMsg("");
try {
await requestJson(branchesUrl, { method: "POST", body: JSON.stringify({ branch: name, create: true }) });
setMsg(`Branch "${name}" created and checked out.`); setMsgTone("ok");
setNewBranch("");
await fetchBranches();
} catch (e) { setMsg((e as any)?.message || "Failed"); setMsgTone("error"); }
setBusy(false);
}
return (
<SettingsSection
id="git"
title="Git Branches"
description="Switch or create local branches for this project."
tag={current || null}
>
{branches.length > 1 && (
<div className="flex items-end gap-[0.65rem] mb-[0.65rem]">
<Field label="Switch to branch" className="flex-1">
<Select value={selected} onChange={(e) => setSelected(e.target.value)}>
{branches.map((b) => <SelectOption key={b} value={b}>{b}</SelectOption>)}
</Select>
</Field>
<Button size="sm" variant="outline" onClick={handleSwitch} disabled={busy || selected === current}>
Switch
</Button>
</div>
)}
<div className="flex items-end gap-[0.65rem]">
<Field label="Create new branch" className="flex-1">
<Input
placeholder="branch-name"
value={newBranch}
onInput={(e) => setNewBranch(e.currentTarget.value)}
/>
</Field>
<Button size="sm" variant="primary" onClick={handleCreate} disabled={busy || !newBranch.trim()}>
Create
</Button>
</div>
{msg && (
<p className={cx("text-[0.72rem] mt-[0.5rem]", msgTone === "ok" ? "text-accent" : msgTone === "error" ? "text-red-400" : "text-body-soft")}>
{msg}
</p>
)}
</SettingsSection>
);
}
// ─── Danger Zone ─────────────────────────────────────────────────────────────
function DangerZone({ owner, project }) {
const [confirmName, setConfirmName] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const canDelete = confirmName.trim() === project && password.length > 0;
async function handleDelete(e) {
e.preventDefault();
if (!canDelete || busy) return;
setBusy(true);
setError("");
try {
const res = await requestJson(`/api/users/${owner}/projects/${project}`, {
method: "DELETE",
body: JSON.stringify({ project_name: confirmName.trim(), password }),
});
if (res?.ok) {
window.location.href = "/home";
}
} catch (e) {
setError((e as any)?.message || "Deletion failed");
setBusy(false);
}
}
return (
<SettingsSection
title="Danger Zone"
description="Permanently delete this project and all its data. This cannot be undone."
tone="danger"
>
<form onSubmit={handleDelete} className="flex flex-col gap-3 max-w-sm">
<Field label={`Type "${project}" to confirm`} id="danger-confirm-name">
<Input
id="danger-confirm-name"
type="text"
value={confirmName}
onInput={(e) => setConfirmName((e.target as HTMLInputElement).value)}
placeholder={project}
autoComplete="off"
/>
</Field>
<Field label="Your password" id="danger-password">
<Input
id="danger-password"
type="password"
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Enter your password"
autoComplete="current-password"
/>
</Field>
{error && <p className="text-[0.72rem] text-red-400">{error}</p>}
<Button
type="submit"
variant="destructive"
disabled={!canDelete || busy}
className="self-start"
>
{busy ? "Deleting…" : "Delete project"}
</Button>
</form>
</SettingsSection>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function Page(input) {
const tabFlags = input?.tab_flags ?? {};
const settingsTabs = Array.isArray(input?.settings_tabs) ? input.settings_tabs : [];
const assistant = input?.assistant ?? {};
const mcpCapabilities = Array.isArray(input?.mcp?.capabilities) ? input.mcp.capabilities : [];
return (
<>
<ProjectStudioShell
projectHref={input.project_href}
projectLabel={input.title}
currentMenu="Settings"
owner={input.owner}
project={input.project}
nav={input.nav}
>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<StudioTabNav>
{settingsTabs.map((item, index) => (
<StudioTabLink
key={`${item?.href ?? "tab"}-${index}`}
href={item?.href ?? "#"}
active={item?.classes === "is-active"}
>
{item?.label}
</StudioTabLink>
))}
</StudioTabNav>
<section className="flex-1 min-h-0 overflow-auto flex flex-col">
<div className="flex min-h-full flex-col">
<section
className="border-b border-dark-border px-4 py-3"
>
<div>
<p className="text-[0.68rem] font-medium uppercase tracking-[0.08em] text-body-soft">
{input.page_title}
</p>
<p className="mt-1 text-[0.78rem] text-body-soft">
{input.page_subtitle}
</p>
</div>
</section>
{tabFlags?.general ? (
<section className="flex flex-col">
<div className="flex flex-col">
<ProjectTransferPanel
owner={input?.owner}
project={input?.project}
api={input?.transfer?.api ?? {}}
initialOperations={input?.transfer?.operations ?? []}
/>
<RuntimeDefaultsPanel
api={input?.assets?.settings_api ?? ""}
initialConfig={input?.assets?.config ?? {}}
/>
<ReIndexPanel api={input?.reindex_api ?? ""} />
<GitPanel
remoteApi={input?.git?.remote_api ?? ""}
initialConfig={input?.git?.config ?? {}}
healthApi={input?.git?.health_api ?? ""}
repairApi={input?.git?.repair_api ?? ""}
/>
<GitBranchPanel owner={input.owner} project={input.project} />
<DangerZone owner={input.owner} project={input.project} />
</div>
</section>
) : null}
{tabFlags?.policy ? (
<section className="project-content-section">
<div className="project-content-body">
<RwePanel
api={input?.rwe?.api ?? ""}
initialConfig={input?.rwe?.config ?? {}}
owner={input?.owner}
project={input?.project}
/>
<div className="project-card-grid cols-2">
{renderCardGrid(input?.cards_policy)}
</div>
</div>
</section>
) : null}
{tabFlags?.logs ? (
<section className="project-content-section">
<div className="project-content-body">
<LoggingPanel
api={input?.logging?.api ?? ""}
invocationsApi={input?.logging?.invocations_api ?? ""}
initialConfig={input?.logging?.config ?? {}}
/>
</div>
</section>
) : null}
{tabFlags?.automatons ? (
<section className="project-content-section">
<div className="project-content-body">
<AssistantPanel
api={assistant?.api?.config ?? ""}
credentials={Array.isArray(assistant?.credentials) ? assistant.credentials : []}
initialConfig={assistant?.config ?? {}}
/>
<article className="border border-border rounded-xl bg-surface p-[0.85rem] mb-[0.9rem]">
<header className="flex items-start justify-between gap-3 mb-[0.65rem]">
<div>
<h3 className="project-card-title">MCP Session</h3>
<p className="project-card-copy">Remote control channel for external agents.</p>
</div>
<span className="project-inline-chip">{input?.mcp?.status_label}</span>
</header>
<div className="flex flex-wrap items-center gap-[0.45rem]">
<p className="project-card-copy">Allowed capabilities:</p>
{mcpCapabilities.map((item, index) => (
<span key={`${item}-${index}`} className="project-inline-chip">{item}</span>
))}
</div>
</article>
</div>
</section>
) : null}
{tabFlags?.libraries ? (
<section className="project-content-section">
<div className="project-content-body">
<LibrariesPanel
items={Array.isArray(input?.libraries_available) ? input.libraries_available : []}
api={input?.libraries_api ?? ""}
/>
</div>
</section>
) : null}
{tabFlags?.nodes ? (
<section className="project-content-section">
<div className="project-content-body">
<NodeRegistryPanel
groups={input?.node_groups ?? []}
count={input?.node_count ?? 0}
/>
</div>
</section>
) : null}
{tabFlags?.files ? (
<section className="project-content-section">
<div className="project-content-body">
<FilesPanel />
</div>
</section>
) : null}
</div>
</section>
</div>
</ProjectStudioShell>
</>
);
}