feat(profiles): add typed live step config profiles

Profiles store script + default config per owner. Workflow steps reference
them with profile:, overlay keys win, and the UI marks overrides. Profile
name is immutable after create so YAML refs stay stable.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-21 06:12:49 +00:00
co-authored by nsrb
parent 210fdb2244
commit e84432a492
25 changed files with 1512 additions and 123 deletions
+4
View File
@@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx";
import { VariablesPage } from "./pages/VariablesPage.jsx";
import { ProfilesPage } from "./pages/ProfilesPage.jsx";
import { OpsPage } from "./pages/OpsPage.jsx";
import { BackupPage } from "./pages/BackupPage.jsx";
@@ -71,6 +72,9 @@ export function App() {
<Route path="/variables" element={<VariablesPage />} />
<Route path="/variables/new" element={<VariablesPage />} />
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
<Route path="/profiles" element={<ProfilesPage />} />
<Route path="/profiles/new" element={<ProfilesPage />} />
<Route path="/profiles/:owner/:name/edit" element={<ProfilesPage />} />
<Route path="/auth" element={<AuthProfilesPage />} />
<Route path="/auth/new" element={<AuthProfilesPage />} />
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
+41
View File
@@ -429,6 +429,47 @@ export function useDeleteVariable() {
});
}
export function useProfiles(owner, options = {}) {
return useQuery({
queryKey: ["profiles", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/profiles", { params })).data.profiles;
},
...options,
});
}
export function useProfileUsage(id, enabled = true) {
return useQuery({
queryKey: ["profiles", "usage", id],
queryFn: async () =>
(await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages,
enabled: Boolean(id) && enabled,
});
}
export function useUpsertProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/profiles", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
export function useDeleteProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, force }) =>
(
await api.delete(`/profiles/${encodeURIComponent(id)}`, {
params: force ? { force: "1" } : {},
})
).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
export function useKvNamespaces() {
return useQuery({
queryKey: ["kv", "namespaces"],
+2
View File
@@ -9,6 +9,7 @@ import {
LuGitBranch,
LuHouse,
LuKey,
LuLayers,
LuLogOut,
LuMenu,
LuMoon,
@@ -31,6 +32,7 @@ const navSections = [
items: [
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
{ to: "/scripts", label: "Scripts", icon: LuCode },
{ to: "/profiles", label: "Profiles", icon: LuLayers },
{ to: "/events", label: "Events", icon: LuActivity },
],
},
@@ -0,0 +1,201 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js";
import { ConfigFields } from "./workflow/ConfigFields.jsx";
/**
* @param {"add" | "edit"} mode
* @param {{
* owner: string,
* name?: string,
* script?: string,
* config?: Record<string, unknown>,
* description?: string,
* }} initial
* @param {number} [usageCount]
*/
export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) {
const { data: owners = [] } = useOwners();
const { data: scripts = [] } = useScripts();
const upsert = useUpsertProfile();
const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default",
name: initial.name || "",
script: initial.script || "",
config:
initial.config && typeof initial.config === "object" && !Array.isArray(initial.config)
? { ...initial.config }
: {},
description: initial.description || "",
}));
const [formError, setFormError] = useState(null);
const [confirmScript, setConfirmScript] = useState(false);
const scriptChanged = mode === "edit" && form.script !== (initial.script || "");
const listed = scripts.find((s) => (typeof s === "string" ? s : s.name) === form.script);
const meta = listed && typeof listed === "object" ? listed.meta : null;
function submit() {
setFormError(null);
if (!form.name.trim()) {
setFormError("name is required");
return;
}
if (!form.script.trim()) {
setFormError("script is required");
return;
}
upsert.mutate(
{
owner: form.owner,
name: form.name,
script: form.script,
config: form.config,
description: form.description,
},
{
onSuccess: (data) => {
onSaved?.(data?.profile ?? data);
onClose();
},
},
);
}
function onSubmit(e) {
e.preventDefault();
if (scriptChanged && usageCount > 0 && !confirmScript) {
setConfirmScript(true);
return;
}
submit();
}
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? (
<>
<label className="form-control w-full">
<span className="label py-0 text-sm">Owner</span>
{owners.length > 0 ? (
<select
className="select w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : (
<input
className="input w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<input
className="input w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
/>
<span className="label-text-alt opacity-60">
YAML id. Locked after create so live workflow refs stay valid.
</span>
</label>
</>
) : (
<p className="text-sm opacity-70">
<span className="font-mono">{form.owner}/{form.name}</span>
<span className="ml-2 opacity-60">(name cannot be changed)</span>
</p>
)}
<label className="form-control w-full">
<span className="label py-0 text-sm">Script</span>
<select
className="select w-full font-mono"
value={form.script}
onChange={(e) => {
setForm({ ...form, script: e.target.value });
setConfirmScript(false);
}}
required
>
<option value="">Select script</option>
{scripts.map((s) => {
const name = typeof s === "string" ? s : s.name;
return (
<option key={name} value={name}>
{name}
</option>
);
})}
{form.script && !scripts.some((s) => (typeof s === "string" ? s : s.name) === form.script) ? (
<option value={form.script}>{form.script}</option>
) : null}
</select>
</label>
{scriptChanged && usageCount > 0 ? (
<p className="text-warning text-sm">
Changing the script updates {usageCount} workflow
{usageCount === 1 ? "" : "s"} that use this profile
{confirmScript ? ". Save to apply." : "."}
</p>
) : null}
<label className="form-control w-full">
<span className="label py-0 text-sm">Description</span>
<input
className="input w-full"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
maxLength={500}
/>
</label>
<div>
<p className="text-sm mb-1">Config (defaults for every step that uses this profile)</p>
<ConfigFields
script={form.script}
config={form.config}
meta={meta}
owner={form.owner}
onChange={(config) => setForm({ ...form, config })}
/>
</div>
{formError ? <p className="text-error text-sm">{formError}</p> : null}
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={upsert.isPending}>
{upsert.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
{scriptChanged && usageCount > 0 && !confirmScript ? "Confirm script change" : "Save"}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -1,13 +1,18 @@
import { useMemo, useState } from "react";
import { useScripts } from "../../api/hooks.js";
import { useProfiles, useScripts } from "../../api/hooks.js";
import { ScriptIcon } from "../ScriptIcon.jsx";
import { scriptTags } from "../../lib/script.js";
export function AddScriptDialog({ open, onClose, onPick }) {
export function AddScriptDialog({ open, onClose, onPick, owner }) {
const { data: scripts = [], isLoading } = useScripts();
const { data: profiles = [], isLoading: profilesLoading } = useProfiles(
owner || undefined,
{ enabled: open && Boolean(owner) },
);
const [q, setQ] = useState("");
const [tab, setTab] = useState("scripts");
const filtered = useMemo(() => {
const filteredScripts = useMemo(() => {
const term = q.trim().toLowerCase();
const list = scripts.map((s) => ({
name: typeof s === "string" ? s : s.name,
@@ -26,60 +31,128 @@ export function AddScriptDialog({ open, onClose, onPick }) {
);
}, [scripts, q]);
const filteredProfiles = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return profiles;
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
String(p.script ?? "").toLowerCase().includes(term) ||
String(p.description ?? "").toLowerCase().includes(term),
);
}, [profiles, q]);
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<h3 className="font-bold text-lg">Add script</h3>
<h3 className="font-bold text-lg">Add step</h3>
<div role="tablist" className="tabs tabs-box mt-3 w-full">
<button
type="button"
role="tab"
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
onClick={() => setTab("scripts")}
>
Scripts
</button>
<button
type="button"
role="tab"
className={`tab ${tab === "profiles" ? "tab-active" : ""}`}
onClick={() => setTab("profiles")}
>
Profiles
</button>
</div>
<input
className="input input-sm w-full mt-3"
placeholder="Search scripts"
placeholder={tab === "profiles" ? "Search profiles" : "Search scripts"}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
<li>
<button
type="button"
onClick={() => {
onPick({ kind: "set" });
setQ("");
}}
>
<span className="font-semibold">Set (JSONata)</span>
<span className="text-xs opacity-70">
JSONata over ctx; result becomes the next step’s data
</span>
</button>
</li>
{isLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
{tab === "scripts" ? (
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
<li>
<button
type="button"
onClick={() => {
onPick({ kind: "set" });
setQ("");
}}
>
<span className="font-semibold">Set (JSONata)</span>
<span className="text-xs opacity-70">
JSONata over ctx; result becomes the next step’s data
</span>
</button>
</li>
) : (
filtered.map((s) => (
<li key={s.name}>
<button
type="button"
onClick={() => {
onPick({ kind: "script", name: s.name, meta: s.meta });
setQ("");
}}
>
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{s.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{s.metaError ? s.metaError : s.description || "No description"}
</span>
</span>
</button>
{isLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
</li>
))
)}
</ul>
) : (
filteredScripts.map((s) => (
<li key={s.name}>
<button
type="button"
onClick={() => {
onPick({ kind: "script", name: s.name, meta: s.meta });
setQ("");
}}
>
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{s.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{s.metaError ? s.metaError : s.description || "No description"}
</span>
</span>
</button>
</li>
))
)}
</ul>
) : (
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-accent/40 p-0">
{!owner ? (
<li className="p-4 text-sm opacity-60">Save the workflow with an owner first.</li>
) : profilesLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
</li>
) : filteredProfiles.length === 0 ? (
<li className="p-4 text-sm opacity-60">No profiles for this owner.</li>
) : (
filteredProfiles.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
onPick({
kind: "profile",
name: p.name,
script: p.script,
});
setQ("");
}}
>
<ScriptIcon name={p.script} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{p.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{p.script}
{p.description ? ` · ${p.description}` : ""}
</span>
</span>
</button>
</li>
))
)}
</ul>
)}
<div className="modal-action">
<button
type="button"
@@ -487,15 +487,23 @@ export function ConfigFields({
owner,
excludeFile,
disabled,
inheritedConfig = null,
}) {
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
const inherited =
inheritedConfig && typeof inheritedConfig === "object" && !Array.isArray(inheritedConfig)
? inheritedConfig
: null;
const metaKeys = Object.keys(meta?.config ?? {});
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
const inheritedExtraKeys = inherited
? Object.keys(inherited).filter((k) => !metaKeys.includes(k) && !(k in cfg))
: [];
function setField(key, value) {
const spec = fieldSpec(meta, key);
const next = { ...cfg };
const required = Boolean(spec.required);
const required = Boolean(spec.required) && !inherited;
if (value === undefined || (value === "" && !required)) {
delete next[key];
} else {
@@ -504,6 +512,12 @@ export function ConfigFields({
onChange(next);
}
function resetField(key) {
const next = { ...cfg };
delete next[key];
onChange(next);
}
function renameExtra(oldKey, nextKey) {
if (!nextKey || nextKey === oldKey) return;
const next = {};
@@ -516,35 +530,70 @@ export function ConfigFields({
function addExtra() {
let key = "key";
let i = 1;
while (key in cfg) {
while (key in cfg || (inherited && key in inherited)) {
key = `key${i}`;
i += 1;
}
onChange({ ...cfg, [key]: "" });
}
function renderField(key, { extra = false, inheritedOnly = false } = {}) {
const spec = extra ? { type: "string" } : fieldSpec(meta, key);
const overridden = Boolean(inherited && Object.prototype.hasOwnProperty.call(cfg, key));
const displayValue = inheritedOnly
? inherited?.[key]
: overridden || !inherited
? cfg[key]
: inherited[key];
const unknown =
extra && metaKeys.length > 0 && !Object.prototype.hasOwnProperty.call(meta?.config ?? {}, key);
return (
<div
key={key}
className={`space-y-1 ${
overridden ? "rounded-box border border-warning/50 bg-warning/5 p-2" : ""
}`}
>
<FieldLabel name={key} required={Boolean(spec.required) && !inherited} description={spec.description}>
{overridden ? (
<span className="badge badge-warning badge-xs">overridden</span>
) : inherited && !inheritedOnly ? (
<span className="badge badge-ghost badge-xs">from profile</span>
) : null}
{unknown ? (
<span className="badge badge-error badge-xs">not in schema</span>
) : null}
{overridden ? (
<button
type="button"
className="btn btn-ghost btn-xs"
disabled={disabled}
onClick={() => resetField(key)}
>
Reset
</button>
) : null}
</FieldLabel>
<ValueEditor
value={displayValue}
onChange={(v) => setField(key, v)}
spec={extra ? { type: displayValue != null && typeof displayValue === "object" ? "object" : "string" } : spec}
script={script}
fieldKey={key}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
disabled={disabled}
/>
<ConfigRefHint value={displayValue} owner={owner} />
</div>
);
}
return (
<div className="space-y-2">
{metaKeys.map((key) => {
const spec = fieldSpec(meta, key);
return (
<div key={key} className="space-y-1">
<FieldLabel name={key} required={Boolean(spec.required)} description={spec.description} />
<ValueEditor
value={cfg[key]}
onChange={(v) => setField(key, v)}
spec={spec}
script={script}
fieldKey={key}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
disabled={disabled}
/>
<ConfigRefHint value={cfg[key]} owner={owner} />
</div>
);
})}
{metaKeys.map((key) => renderField(key))}
{extraKeys.map((key) => (
<div key={key} className="space-y-1">
<div className="flex items-center gap-1">
@@ -552,16 +601,18 @@ export function ConfigFields({
value={key}
onCommit={(nk) => renameExtra(key, nk)}
className="font-mono text-sm"
disabled={disabled}
/>
{inherited ? <span className="badge badge-warning badge-xs">overridden</span> : null}
{metaKeys.length > 0 ? (
<span className="badge badge-error badge-xs">not in schema</span>
) : null}
<button
type="button"
className="btn btn-ghost btn-xs btn-square text-error"
aria-label={`Remove ${key}`}
onClick={() => {
const next = { ...cfg };
delete next[key];
onChange(next);
}}
disabled={disabled}
onClick={() => resetField(key)}
>
<LuTrash2 className="size-3.5" />
</button>
@@ -580,6 +631,7 @@ export function ConfigFields({
<ConfigRefHint value={cfg[key]} owner={owner} />
</div>
))}
{inheritedExtraKeys.map((key) => renderField(key, { extra: true, inheritedOnly: true }))}
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
<LuPlus className="size-3.5" />
Add field
@@ -11,12 +11,14 @@ import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
import { ScriptIcon } from "../ScriptIcon.jsx";
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
export function ScriptCard({
step,
index,
otherSteps,
scriptsByName,
profilesByName,
onChange,
onRemove,
disabled,
@@ -35,25 +37,33 @@ export function ScriptCard({
};
const [tryOpen, setTryOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const listed = scriptsByName?.get(step.script);
const profile = step.profile && profilesByName?.get(step.profile);
const scriptName = profile?.script || step.script;
const listed = scriptsByName?.get(scriptName);
const meta = listed?.meta ?? null;
const inheritedConfig = profile?.config ?? null;
const mergedConfig = profile
? mergeProfileConfig(profile.config, step.config)
: step.config;
const overridden = Boolean(step.profile && configHasOverlay(step.config));
const duplicateId =
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(step.config, meta?.previewConfigKey);
const previewFull = configValueText(step.config, meta?.previewConfigKey);
const baseName = step.kind === "set" ? "set" : step.script || "untitled";
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig =
step.kind === "set" ? { expression: step.expression ?? "" } : null;
const missingProfile = Boolean(step.profile) && !profile;
return (
<article
ref={setNodeRef}
style={style}
className={`card bg-base-100 border border-primary ${
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
}`}
className={`card bg-base-100 border ${
step.profile ? "border-accent" : "border-primary"
} ${isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"}`}
>
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
<div className="flex items-center gap-2">
@@ -67,8 +77,8 @@ export function ScriptCard({
>
<LuGripVertical className="size-4 opacity-60" />
</button>
{step.kind === "script" && step.script ? (
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
{step.kind === "script" && scriptName ? (
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
) : null}
<button
type="button"
@@ -87,10 +97,29 @@ export function ScriptCard({
</span>
) : null}
</h3>
{step.profile ? (
<span
className={`badge badge-sm shrink-0 font-mono font-normal ${
overridden ? "badge-warning" : "badge-accent"
}`}
title={
overridden
? `profile ${step.profile} + overridden`
: `profile ${step.profile}`
}
>
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`}
</span>
) : null}
{step.id ? (
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
) : null}
</div>
{expanded && step.kind === "script" && missingProfile ? (
<p className="text-sm text-error mt-1">
Profile {step.profile} not found for owner {owner || "?"}
</p>
) : null}
{expanded && step.kind === "script" && meta?.description ? (
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
) : null}
@@ -104,10 +133,10 @@ export function ScriptCard({
<SchemaTooltip label="Input" fields={meta?.input} />
) : null}
{!expanded ? (
<ConfigTooltip config={step.kind === "set" ? setConfig : (step.config ?? {})} />
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} />
) : null}
<div className="card-actions shrink-0">
{step.kind === "script" && step.script ? (
{step.kind === "script" && scriptName ? (
<button
type="button"
className="btn btn-ghost btn-xs"
@@ -158,8 +187,9 @@ export function ScriptCard({
</div>
) : (
<ConfigFields
script={step.script}
script={scriptName}
config={step.config}
inheritedConfig={inheritedConfig}
meta={meta}
disabled={disabled}
workflows={workflows}
@@ -211,8 +241,8 @@ export function ScriptCard({
</div>
{tryOpen ? (
<ScriptTryDialog
script={step.script}
config={step.config}
script={scriptName}
config={mergedConfig}
meta={meta}
owner={owner}
onClose={() => setTryOpen(false)}
@@ -15,7 +15,7 @@ import {
} from "@dnd-kit/sortable";
import { LuPlus } from "react-icons/lu";
import { defaultConfigFromMeta } from "../../lib/script.js";
import { newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
import { newProfileStep, newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
import { AddScriptDialog } from "./AddScriptDialog.jsx";
import { ScriptCard } from "./ScriptCard.jsx";
@@ -24,6 +24,7 @@ export function ScriptsTab({
onPatch,
disabled,
scripts = [],
profiles = [],
workflows = [],
owner,
excludeFile,
@@ -42,6 +43,10 @@ export function ScriptsTab({
const name = typeof s === "string" ? s : s.name;
scriptsByName.set(name, s);
}
const profilesByName = new Map();
for (const p of profiles) {
if (p?.name) profilesByName.set(p.name, p);
}
function patchSteps(next) {
onPatch((d) => {
@@ -72,7 +77,7 @@ export function ScriptsTab({
onClick={() => setAddOpen(true)}
>
<LuPlus className="size-4" />
Add script
Add step
</button>
</div>
{steps.length === 0 ? (
@@ -88,6 +93,7 @@ export function ScriptsTab({
index={index}
otherSteps={steps}
scriptsByName={scriptsByName}
profilesByName={profilesByName}
disabled={disabled}
workflows={workflows}
owner={owner}
@@ -106,10 +112,13 @@ export function ScriptsTab({
)}
<AddScriptDialog
open={addOpen}
owner={owner}
onClose={() => setAddOpen(false)}
onPick={(picked) => {
if (picked.kind === "set") {
patchSteps([...steps, newSetStep()]);
} else if (picked.kind === "profile") {
patchSteps([...steps, newProfileStep(picked.name, picked.script)]);
} else {
patchSteps([
...steps,
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { parse as parseYaml } from "yaml";
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js";
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
@@ -49,6 +49,7 @@ export function WorkflowVisualEditor({
const visualDisabled = !displayDoc;
const { data: scripts = [] } = useScripts();
const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) });
const { data: workflows = [] } = useWorkflows(owner || undefined);
const { data: auths = [] } = useHttpAuths();
const { data: allPages = [] } = useHttpPages();
@@ -90,9 +91,17 @@ export function WorkflowVisualEditor({
return map;
}, [scripts]);
const profilesByName = useMemo(() => {
const map = new Map();
for (const p of profiles) {
if (p?.name) map.set(p.name, p);
}
return map;
}, [profiles]);
const inputMeta = useMemo(
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
[displayDoc?.scripts, scriptsByName],
() => firstInputMeta(displayDoc?.scripts, scriptsByName, profilesByName),
[displayDoc?.scripts, scriptsByName, profilesByName],
);
const defaultData = useMemo(() => {
@@ -175,6 +184,7 @@ export function WorkflowVisualEditor({
onPatch={patchDoc}
disabled={visualDisabled}
scripts={scripts}
profiles={profiles}
workflows={workflows}
owner={owner}
excludeFile={file}
+16
View File
@@ -0,0 +1,16 @@
export function mergeProfileConfig(base, overlay) {
const a = base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {};
if (overlay == null || typeof overlay !== "object" || Array.isArray(overlay)) {
return a;
}
return { ...a, ...overlay };
}
export function configHasOverlay(config) {
return (
config != null &&
typeof config === "object" &&
!Array.isArray(config) &&
Object.keys(config).length > 0
);
}
+5 -2
View File
@@ -121,11 +121,14 @@ export function inputHasFields(meta) {
}
/** First script step with a non-empty `meta.input`; else first script meta. */
export function firstInputMeta(steps, scriptsByName) {
export function firstInputMeta(steps, scriptsByName, profilesByName) {
let fallback = null;
for (const step of steps ?? []) {
if (step?.kind === "set") continue;
const name = typeof step === "string" ? step : step?.script;
let name = typeof step === "string" ? step : step?.script;
if (!name && step?.profile && profilesByName) {
name = profilesByName.get(step.profile)?.script;
}
if (!name) continue;
const listed = scriptsByName?.get(name);
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
+23 -2
View File
@@ -113,6 +113,7 @@ export function newScriptStep(script, config = {}) {
uiId: nextUiId("step"),
kind: "script",
script,
profile: "",
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
id: "",
when: "",
@@ -120,6 +121,19 @@ export function newScriptStep(script, config = {}) {
};
}
export function newProfileStep(profileName, script = "") {
return {
uiId: nextUiId("step"),
kind: "script",
script,
profile: profileName,
config: {},
id: "",
when: "",
needs: null,
};
}
export function newSetStep() {
return {
uiId: nextUiId("step"),
@@ -202,6 +216,7 @@ function normalizeStep(step) {
uiId,
kind: "script",
script: step,
profile: "",
config: {},
id: "",
when: "",
@@ -213,6 +228,7 @@ function normalizeStep(step) {
uiId,
kind: "script",
script: "",
profile: "",
config: {},
id: "",
when: "",
@@ -231,7 +247,7 @@ function normalizeStep(step) {
needs: step.needs ?? null,
};
}
const known = new Set(["script", "config", "id", "when", "needs", "set"]);
const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]);
/** @type {Record<string, unknown>} */
const extra = {};
for (const [key, value] of Object.entries(step)) {
@@ -245,6 +261,7 @@ function normalizeStep(step) {
uiId,
kind: "script",
script: typeof step.script === "string" ? step.script : "",
profile: typeof step.profile === "string" ? step.profile : "",
config,
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
@@ -313,7 +330,11 @@ function dumpStep(step) {
/** @type {Record<string, unknown>} */
const out = {};
if (step.id) out.id = step.id;
out.script = step.script ?? "";
if (step.profile) {
out.profile = step.profile;
} else {
out.script = step.script ?? "";
}
const config = step.config;
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
out.config = config;
+8 -5
View File
@@ -1,20 +1,22 @@
function parseStep(step) {
if (typeof step === "string") {
return { kind: "script", script: step, id: null, needs: null, when: null };
return { kind: "script", script: step, profile: null, id: null, needs: null, when: null };
}
if (step?.set) {
return {
kind: "set",
script: null,
profile: null,
id: typeof step.id === "string" && step.id ? step.id : null,
needs: step.needs ?? null,
when: typeof step.when === "string" && step.when ? step.when : null,
};
}
if (step?.script) {
if (step?.script || step?.profile) {
return {
kind: "script",
script: step.script,
script: typeof step.script === "string" && step.script ? step.script : null,
profile: typeof step.profile === "string" && step.profile ? step.profile : null,
id: typeof step.id === "string" && step.id ? step.id : null,
needs: step.needs ?? null,
when: typeof step.when === "string" && step.when ? step.when : null,
@@ -55,7 +57,8 @@ function stepLabel(s) {
const base = s.id ? `${s.id}: set` : "set";
return s.when ? `${base} when: ${s.when}` : base;
}
const base = s.id ? `${s.id}: ${s.script}` : s.script;
const target = s.profile ? `profile ${s.profile}` : s.script;
const base = s.id ? `${s.id}: ${target}` : target;
return s.when ? `${base} when: ${s.when}` : base;
}
@@ -112,7 +115,7 @@ export function workflowToFlowchart(parsed) {
if (s.kind === "set") {
lines.push(` ${s.mermaidId}(["${label}"])`);
} else {
scriptIds[s.mermaidId] = s.script;
scriptIds[s.mermaidId] = s.script || (s.profile ? `profile:${s.profile}` : "");
lines.push(` ${s.mermaidId}["${label}"]`);
}
}
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useDeleteProfile, useOwners, useProfileUsage, useProfiles } from "../api/hooks.js";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { formatTime } from "../lib/format.jsx";
function previewConfig(config) {
if (!config || typeof config !== "object") return "";
if (typeof config.url === "string" && config.url) return config.url;
const first = Object.entries(config).find(
([, v]) => v != null && (typeof v === "string" || typeof v === "number" || typeof v === "boolean"),
);
if (!first) return "";
return `${first[0]}=${String(first[1])}`;
}
function formatUsage(usages) {
const names = (usages ?? []).map((u) => u.name);
if (names.length === 0) return "";
if (names.length === 1) return `${names[0]} uses this.`;
if (names.length === 2) return `${names[0]} and ${names[1]} use this.`;
const rest = names.length - 2;
return `${names[0]}, ${names[1]}, and ${rest} other workflow${rest === 1 ? "" : "s"} use this.`;
}
export function ProfilesPage() {
const navigate = useNavigate();
const location = useLocation();
const { owner: routeOwner, name: routeName } = useParams();
const [params] = useSearchParams();
const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined);
const del = useDeleteProfile();
const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null);
const openedRouteKey = useRef(null);
const listPath = ownerFilter
? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles";
function closeEditor() {
setEditor(null);
openedRouteKey.current = null;
if (isNewRoute || isEditRoute) {
navigate(listPath, { replace: true });
}
}
useEffect(() => {
if (!isNewRoute) return;
const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`;
if (openedRouteKey.current === key) return;
if (!params.get("owner") && !ownerFilter && owners.length === 0) return;
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
if (params.get("owner")) setOwnerFilter(params.get("owner"));
openedRouteKey.current = key;
setEditor({
mode: "add",
initial: {
owner,
name: params.get("name") || "",
script: params.get("script") || "",
config: {},
description: "",
},
});
}, [isNewRoute, params, owners, ownerFilter]);
useEffect(() => {
if (!isEditRoute) {
if (!isNewRoute) openedRouteKey.current = null;
return;
}
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return;
}
if (isLoading) return;
const key = `edit:${routeOwner}/${routeName}`;
if (openedRouteKey.current === key) return;
openedRouteKey.current = key;
setHighlightName(routeName);
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
if (row) {
setEditor({
mode: "edit",
initial: {
owner: row.owner,
name: row.name,
script: row.script,
config: row.config,
description: row.description,
},
usageCount: row.usageCount ?? 0,
});
return;
}
setEditor({
mode: "add",
initial: {
owner: routeOwner,
name: routeName,
script: "",
config: {},
description: "",
},
});
}, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter, isLoading, profiles]);
useEffect(() => {
if (!highlightName || isLoading) return;
highlightRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [highlightName, isLoading, profiles]);
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Profiles</h1>
<div className="flex gap-2">
<select
className="select select-sm"
value={ownerFilter}
onChange={(e) => {
setOwnerFilter(e.target.value);
setHighlightName("");
navigate(
e.target.value
? `/profiles?owner=${encodeURIComponent(e.target.value)}`
: "/profiles",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div>
<p className="text-sm opacity-70">
A profile is saved config for one script. Workflow steps can use it as initial config;
local keys override. Changing a profile updates every workflow that references it.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : profiles.length === 0 ? (
<p className="text-sm opacity-60">No profiles yet.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{profiles.map((row) => {
const highlighted =
highlightName &&
row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter);
const preview = previewConfig(row.config);
return (
<article
key={row.id}
ref={highlighted ? highlightRef : undefined}
className={`card bg-base-100 border border-accent ${
highlighted ? "outline outline-2 outline-accent" : ""
}`}
>
<div className="card-body p-4 gap-2">
<div className="flex items-start gap-2">
<ScriptIcon name={row.script} className="size-10 shrink-0" />
<div className="min-w-0 flex-1">
<h2 className="font-mono font-semibold truncate" title={row.name}>
{row.name}
</h2>
<p className="text-xs opacity-60 font-mono truncate">{row.owner}</p>
</div>
</div>
<p className="text-xs font-mono opacity-80 truncate" title={row.script}>
{row.script}
</p>
{row.description ? (
<p className="text-sm opacity-80 line-clamp-2">{row.description}</p>
) : null}
{preview ? (
<p className="text-xs font-mono opacity-60 truncate" title={preview}>
{preview}
</p>
) : null}
<p className="text-xs opacity-50">
{row.usageCount
? `${row.usageCount} workflow${row.usageCount === 1 ? "" : "s"}`
: "unused"}
{row.updated_at ? ` · ${formatTime(row.updated_at)}` : ""}
</p>
<div className="card-actions justify-end">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
onClick={() =>
navigate(
`/profiles/${encodeURIComponent(row.owner)}/${encodeURIComponent(row.name)}/edit`,
)
}
>
<LuPencil className="size-4" />
Edit
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete({ ...row, force: false })}
>
<LuTrash2 className="size-4" />
Delete
</button>
</div>
</div>
</article>
);
})}
</div>
)}
{editor ? (
<ProfileEditorModal
mode={editor.mode}
initial={editor.initial}
usageCount={editor.usageCount ?? 0}
onClose={closeEditor}
onSaved={(saved) => {
setHighlightName(saved?.name || editor.initial.name);
}}
/>
) : null}
{confirmDelete ? (
<DeleteProfileDialog
profile={confirmDelete}
del={del}
onClose={() => {
del.reset();
setConfirmDelete(null);
}}
/>
) : null}
</div>
);
}
function DeleteProfileDialog({ profile, del, onClose }) {
const [force, setForce] = useState(false);
const usage = useProfileUsage(profile.id, true);
const usages = usage.data ?? [];
const used = usages.length > 0;
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">
Delete {profile.owner}/{profile.name}?
</h3>
{usage.isLoading ? (
<p className="text-sm mt-2 opacity-70">Checking workflow usage…</p>
) : used ? (
<>
<p className="text-sm mt-2">{formatUsage(usages)}</p>
<ul className="mt-2 max-h-40 overflow-auto text-sm font-mono">
{usages.map((u) => (
<li key={u.file}>
{u.name}
{u.file !== u.name ? ` (${u.file})` : ""}
</li>
))}
</ul>
{force ? (
<p className="text-warning text-sm mt-2">
Workflows that still reference this profile will fail until you fix them.
</p>
) : null}
</>
) : (
<p className="text-sm mt-2">This cannot be undone.</p>
)}
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Cancel
</button>
{used && !force ? (
<button type="button" className="btn btn-warning" onClick={() => setForce(true)}>
Force delete
</button>
) : (
<button
type="button"
className="btn btn-error"
disabled={del.isPending || usage.isLoading}
onClick={() =>
del.mutate({ id: profile.id, force: used }, { onSuccess: () => onClose() })
}
>
{del.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
Delete
</button>
)}
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}