Merge branch 'main' into dev
Deploy to Raspberry Pi / deploy (push) Successful in 4m3s

This commit is contained in:
2026-08-22 21:48:51 +07:00
172 changed files with 11061 additions and 5442 deletions
+24 -23
View File
@@ -6,6 +6,7 @@ import {
useHttpPages,
useUpsertHttpAuth,
} from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx";
function emptyCred(source = "literal") {
return { source, value: "", kv: "", namespace: "", secret: "" };
@@ -120,15 +121,15 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {
<div className="space-y-3 rounded-box border border-base-300 p-3">
<p className="text-sm font-medium">{label}</p>
<Field label="Source">
<select
className="select select-bordered w-full"
<FormSelect
className="w-full"
value={cred.source}
onChange={(e) => onChange({ ...cred, source: e.target.value, keep: false })}
>
<option value="literal">Plain text</option>
<option value="kv">From KV</option>
<option value="secret">From secret</option>
</select>
</FormSelect>
</Field>
{cred.source === "literal" ? (
<Field
@@ -140,9 +141,9 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {
}
>
<div className="flex w-full gap-1">
<input
<FormInput
type={masked && !show ? "password" : "text"}
className="input input-bordered w-full font-mono"
className="w-full font-mono"
value={cred.value}
onChange={(e) => onChange({ ...cred, value: e.target.value, keep: false })}
placeholder={
@@ -168,16 +169,16 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {
{cred.source === "kv" ? (
<>
<Field label="Namespace (optional)">
<input
className="input input-bordered w-full font-mono"
<FormInput
className="w-full font-mono"
placeholder="namespace"
value={cred.namespace}
onChange={(e) => onChange({ ...cred, namespace: e.target.value })}
/>
</Field>
<Field label="Key">
<input
className="input input-bordered w-full font-mono"
<FormInput
className="w-full font-mono"
placeholder="key"
value={cred.kv}
onChange={(e) => onChange({ ...cred, kv: e.target.value })}
@@ -191,8 +192,8 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {
label="Secret name"
hint="Encrypted secret — value is never shown here. Manage it on the Secrets page."
>
<input
className="input input-bordered w-full font-mono"
<FormInput
className="w-full font-mono"
placeholder="secret name"
value={cred.secret}
onChange={(e) => onChange({ ...cred, secret: e.target.value })}
@@ -297,8 +298,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
) : (
<form className="mt-3 space-y-4" onSubmit={onSubmit}>
<Field label="Name">
<input
className="input input-bordered w-full font-mono"
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
@@ -308,15 +309,15 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
</Field>
<Field label="Type">
<select
className="select select-bordered w-full"
<FormSelect
className="w-full"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
<option value="bearer">bearer</option>
<option value="basic">basic</option>
<option value="header">header</option>
</select>
</FormSelect>
</Field>
{form.type === "bearer" ? (
@@ -345,8 +346,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
{form.type === "header" ? (
<>
<Field label="Header name">
<input
className="input input-bordered w-full font-mono"
<FormInput
className="w-full font-mono"
value={form.header}
onChange={(e) => setForm({ ...form, header: e.target.value })}
required
@@ -362,9 +363,9 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
) : null}
<Field label="Unauthorized status (optional)">
<input
<FormInput
type="number"
className="input input-bordered w-full"
className="w-full"
value={form.unauthorized_status}
onChange={(e) => setForm({ ...form, unauthorized_status: e.target.value })}
min={100}
@@ -374,8 +375,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
</Field>
<Field label="Unauthorized response page (optional)">
<select
className="select select-bordered w-full"
<FormSelect
className="w-full"
value={form.unauthorized_response}
onChange={(e) => setForm({ ...form, unauthorized_response: e.target.value })}
>
@@ -385,7 +386,7 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) {
{p.name}
</option>
))}
</select>
</FormSelect>
</Field>
{upsert.isError ? (
+1 -1
View File
@@ -6,7 +6,7 @@ export function CodeEditor({ language, value, onChange, height = "50vh", readOnl
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return (
<div className="overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<div className="nokey overflow-hidden rounded-box border border-base-300 h-full min-h-0">
<Editor
height={height}
language={language}
@@ -0,0 +1,55 @@
import { Modal } from "./Modal.jsx";
/**
* Shared confirm / delete dialog.
*
* @param {object} props
* @param {boolean} props.open
* @param {string} props.title
* @param {import("react").ReactNode} [props.message]
* @param {string} [props.confirmLabel]
* @param {string} [props.confirmClass]
* @param {boolean} [props.loading]
* @param {boolean} [props.pending] alias for loading
* @param {boolean} [props.confirmDisabled]
* @param {import("react").ReactNode} [props.error]
* @param {() => void} props.onCancel
* @param {() => void} props.onConfirm
*/
export function ConfirmDialog({
open,
title,
message,
confirmLabel = "Delete",
confirmClass = "btn-error",
loading,
pending,
confirmDisabled = false,
error,
onCancel,
onConfirm,
}) {
const busy = Boolean(loading ?? pending);
return (
<Modal open={open} onClose={onCancel} boxClassName="max-w-md" aria-label={title}>
<h3 className="font-bold">{title}</h3>
{message ? <div className="mt-2 text-sm opacity-70">{message}</div> : null}
{error ? <p className="text-error text-sm mt-2">{error}</p> : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={busy}>
Cancel
</button>
<button
type="button"
className={`btn ${confirmClass}`}
disabled={busy || confirmDisabled}
onClick={onConfirm}
>
{busy ? <span className="loading loading-spinner loading-xs" /> : null}
{confirmLabel}
</button>
</div>
</Modal>
);
}
@@ -1,22 +1,19 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useDuplicateWorkflow, useOwners } from "../api/hooks.js";
import { useDuplicateWorkflow } from "../api/hooks.js";
import { useNotifications } from "../notifications.jsx";
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const duplicate = useDuplicateWorkflow();
const [destOwner, setDestOwner] = useState(source.owner);
function onSubmit(e) {
e.preventDefault();
if (destOwner === source.owner && duplicate.isPending) return;
if (duplicate.isPending) return;
duplicate.mutate(
{
owner: source.owner,
file: source.file,
destOwner,
destOwner: source.owner,
},
{
onSuccess: (data) => {
@@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
<h3 className="font-bold">Duplicate {source.key}?</h3>
<p className="mt-2 text-sm opacity-70">
A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are
rewritten when staying under the same owner so triggers do not collide.
rewritten so triggers do not collide.
</p>
{warnUnsaved ? (
<p className="text-warning mt-2 text-sm">
@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
</p>
) : null}
<form className="mt-4 space-y-3" onSubmit={onSubmit}>
<label className="form-control w-full">
<span className="label py-0 text-sm">Owner</span>
<input
className="input input-sm w-full"
list="duplicate-workflow-owners"
value={destOwner}
onChange={(e) => setDestOwner(e.target.value)}
required
/>
<datalist id="duplicate-workflow-owners">
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
{duplicate.isError ? (
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
) : null}
@@ -0,0 +1,27 @@
/**
* Shared DaisyUI form controls — always bordered + sm.
*/
import { forwardRef } from "react";
export const FormInput = forwardRef(function FormInput({ className = "", ...props }, ref) {
return <input ref={ref} className={`input input-bordered input-sm ${className}`.trim()} {...props} />;
});
export const FormSelect = forwardRef(function FormSelect({ className = "", children, ...props }, ref) {
return (
<select ref={ref} className={`select select-bordered select-sm ${className}`.trim()} {...props}>
{children}
</select>
);
});
export const FormTextarea = forwardRef(function FormTextarea({ className = "", ...props }, ref) {
return (
<textarea
ref={ref}
className={`textarea textarea-bordered textarea-sm ${className}`.trim()}
{...props}
/>
);
});
@@ -2,7 +2,7 @@ import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx";
import { useNotifications } from "../notifications.jsx";
import { formatBytes, formatCpu, formatDuration } from "../lib/format.js";
import { formatBytes, formatCpu, formatDuration } from "../lib/format";
const MAX_WORKERS = 32;
+33 -33
View File
@@ -1,4 +1,5 @@
import { LuCircleCheck, LuCircleX } from "react-icons/lu";
import { OpsStatusCard } from "./OpsStatusCard.jsx";
export function HttpProcessCard({
httpOnline,
@@ -8,39 +9,38 @@ export function HttpProcessCard({
stopPending = false,
}) {
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">HTTP process</h2>
<div className="flex items-center gap-2">
{httpOnline ? (
<LuCircleCheck className="size-6 text-success" aria-hidden />
) : (
<LuCircleX className="size-6 text-error" aria-hidden />
)}
<span>{httpOnline ? "Running" : "Stopped"}</span>
</div>
<div className="card-actions justify-start px-0">
{httpOnline ? (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={stopPending}
onClick={onStop}
>
Stop HTTP
</button>
) : (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={startPending}
onClick={onStart}
>
Start HTTP
</button>
)}
</div>
<OpsStatusCard
title="HTTP process"
actions={
httpOnline ? (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={stopPending}
onClick={onStop}
>
Stop HTTP
</button>
) : (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={startPending}
onClick={onStart}
>
Start HTTP
</button>
)
}
>
<div className="flex items-center gap-2">
{httpOnline ? (
<LuCircleCheck className="size-6 text-success" aria-hidden />
) : (
<LuCircleX className="size-6 text-error" aria-hidden />
)}
<span>{httpOnline ? "Running" : "Stopped"}</span>
</div>
</div>
</OpsStatusCard>
);
}
+26 -20
View File
@@ -5,36 +5,42 @@ import { useTheme } from "../theme.jsx";
/**
* Collapsible JSON tree using @uiw/react-json-view.
* Docs: value (JSON), keyName (root label), collapsed (depth),
* enableClipboard, displayObjectSize, displayDataTypes, style theme vars.
*/
export function JsonViewBlock({ title, value }) {
export function JsonTree({ value, keyName, className = "" }) {
const { theme } = useTheme() ?? { theme: "light" };
if (value === undefined) return null;
const isTree = value != null && typeof value === "object";
return (
<div className={`overflow-auto rounded-box bg-base-200 p-3 text-xs ${className}`}>
<JsonView
value={isTree ? value : { value }}
keyName={keyName}
collapsed={1}
enableClipboard
displayObjectSize
displayDataTypes={false}
highlightUpdates={false}
shortenTextAfterLength={80}
style={{
...(theme === "dark" ? darkTheme : lightTheme),
"--w-rjv-background-color": "transparent",
fontSize: "12px",
}}
/>
</div>
);
}
export function JsonViewBlock({ title, value }) {
if (value === undefined) return null;
return (
<details open className="collapse collapse-arrow border border-base-300 bg-base-100">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
<div className="collapse-content">
<div className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 text-xs">
<JsonView
value={isTree ? value : { value }}
keyName={title}
collapsed={1}
enableClipboard
displayObjectSize
displayDataTypes={false}
highlightUpdates={false}
shortenTextAfterLength={80}
style={{
...(theme === "dark" ? darkTheme : lightTheme),
"--w-rjv-background-color": "transparent",
fontSize: "12px",
}}
/>
</div>
<JsonTree value={value} keyName={title} className="max-h-80" />
</div>
</details>
);
+2 -2
View File
@@ -9,6 +9,7 @@ import {
LuGitBranch,
LuHouse,
LuKey,
LuLayers,
LuLogOut,
LuMenu,
LuMoon,
@@ -16,7 +17,6 @@ import {
LuShield,
LuSun,
LuTags,
LuUsers,
} from "react-icons/lu";
import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js";
@@ -31,6 +31,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 },
],
},
@@ -50,7 +51,6 @@ const navSections = [
items: [
{ to: "/manage", label: "Manage", icon: LuServer },
{ to: "/backup", label: "Backup", icon: LuArchive },
{ to: "/users", label: "Users", icon: LuUsers },
],
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { LuX } from "react-icons/lu";
import { levelName } from "../lib/format.jsx";
import { levelName } from "../lib/format";
export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) {
const [wordWrap, setWordWrap] = useState(true);
@@ -1,75 +0,0 @@
import { useEffect, useId, useRef } from "react";
import { useNavigate } from "react-router-dom";
import mermaid from "mermaid";
import { useTheme } from "../theme.jsx";
function findNode(root, id) {
return (
root.querySelector(`#${CSS.escape(id)}`) ||
root.querySelector(`[id="${CSS.escape(id)}"]`) ||
root.querySelector(`[id*="${CSS.escape(id)}"]`)
);
}
export function MermaidDiagram({ chart, scriptIds = {} }) {
const { theme } = useTheme();
const navigate = useNavigate();
const ref = useRef(null);
const reactId = useId().replace(/:/g, "");
const scriptKey = JSON.stringify(scriptIds);
useEffect(() => {
if (!chart || !ref.current) {
if (ref.current) ref.current.innerHTML = "";
return;
}
let cancelled = false;
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
suppressErrorRendering: true,
theme: theme === "dark" ? "dark" : "default",
});
const ids = JSON.parse(scriptKey);
const renderId = `mmd-${reactId}-${Date.now()}`;
mermaid
.render(renderId, chart)
.then(({ svg }) => {
if (cancelled || !ref.current) return;
ref.current.innerHTML = svg;
for (const [nodeId, script] of Object.entries(ids)) {
const el = findNode(ref.current, nodeId);
if (!el) continue;
el.style.cursor = "pointer";
el.setAttribute("title", script);
el.addEventListener("click", () => {
navigate(`/scripts/${encodeURIComponent(script)}/edit`);
});
}
})
.catch((err) => {
if (cancelled || !ref.current) return;
ref.current.innerHTML = "";
const p = document.createElement("p");
p.className = "text-error text-sm";
p.textContent = "Could not render diagram";
ref.current.appendChild(p);
});
return () => {
cancelled = true;
};
}, [chart, scriptKey, theme, navigate, reactId]);
if (!chart) return null;
return (
<div
ref={ref}
className="mermaid-clickable overflow-x-auto rounded-box border border-base-300 bg-base-200 p-3"
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useId, useRef } from "react";
/**
* DaisyUI modal shell with Escape, labeled backdrop close, and a light focus trap.
*
* @param {object} props
* @param {boolean} props.open
* @param {() => void} props.onClose
* @param {import("react").ReactNode} props.children
* @param {string} [props.boxClassName]
* @param {string} [props["aria-labelledby"]]
* @param {string} [props["aria-label"]]
*/
export function Modal({
open,
onClose,
children,
boxClassName = "",
"aria-labelledby": ariaLabelledBy,
"aria-label": ariaLabel,
}) {
const boxRef = useRef(/** @type {HTMLDivElement | null} */ (null));
const fallbackId = useId();
const labelledBy = ariaLabelledBy || (ariaLabel ? undefined : fallbackId);
useEffect(() => {
if (!open) return undefined;
const box = boxRef.current;
const previous = /** @type {HTMLElement | null} */ (document.activeElement);
function focusables() {
if (!box) return [];
return [
...box.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
].filter((el) => el instanceof HTMLElement && !el.hasAttribute("disabled"));
}
const first = focusables()[0];
first?.focus();
function onKeyDown(e) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key !== "Tab" || !box) return;
const items = focusables();
if (items.length === 0) return;
const firstEl = items[0];
const lastEl = items[items.length - 1];
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
previous?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return (
<dialog className="modal modal-open" aria-modal="true" aria-labelledby={labelledBy} aria-label={ariaLabel}>
<div ref={boxRef} className={`modal-box ${boxClassName}`.trim()} id={ariaLabelledBy ? undefined : fallbackId}>
{children}
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" aria-label="Close dialog" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -0,0 +1,172 @@
import { useState } from "react";
import { api, errorMessage } from "../api/client.js";
import { useWorkflowExamples } from "../api/hooks.js";
export const SKIP_NEW_WORKFLOW_PRESET_KEY = "jflow.skipNewWorkflowPreset";
export function shouldSkipNewWorkflowPreset() {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem(SKIP_NEW_WORKFLOW_PRESET_KEY) === "1";
}
export function setSkipNewWorkflowPreset(skip) {
if (typeof localStorage === "undefined") return;
if (skip) localStorage.setItem(SKIP_NEW_WORKFLOW_PRESET_KEY, "1");
else localStorage.removeItem(SKIP_NEW_WORKFLOW_PRESET_KEY);
}
/**
* @param {{
* onChoose: (content: string) => void,
* onCancel: () => void,
* }} props
*/
export function NewWorkflowPresetDialog({ onChoose, onCancel }) {
const { data: examples = [], isLoading, isError, error } = useWorkflowExamples();
const [mode, setMode] = useState(/** @type {"empty" | "example"} */ ("empty"));
const [exampleId, setExampleId] = useState("");
const [dontAsk, setDontAsk] = useState(false);
const [pending, setPending] = useState(false);
const [loadError, setLoadError] = useState(/** @type {string | null} */ (null));
async function onContinue(e) {
e.preventDefault();
setLoadError(null);
if (dontAsk) setSkipNewWorkflowPreset(true);
if (mode === "empty") {
onChoose(null);
return;
}
if (!exampleId) {
setLoadError("Select an example");
return;
}
setPending(true);
try {
const { data } = await api.get(
`/workflow-examples/${encodeURIComponent(exampleId)}`,
);
onChoose(typeof data?.content === "string" ? data.content : "");
} catch (err) {
setLoadError(errorMessage(err));
} finally {
setPending(false);
}
}
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<h3 className="font-bold text-lg">New workflow</h3>
<p className="mt-2 text-sm opacity-70">
Start empty, or copy a shipped example into the editor. Nothing is saved until you click
Save.
</p>
<form className="mt-4 space-y-4" onSubmit={onContinue}>
<fieldset className="space-y-2">
<label className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="preset"
className="radio radio-sm mt-1"
checked={mode === "empty"}
onChange={() => setMode("empty")}
/>
<span>
<span className="font-medium">Empty</span>
<span className="block text-sm opacity-70">Blank name, no scripts or triggers</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="preset"
className="radio radio-sm mt-1"
checked={mode === "example"}
onChange={() => {
setMode("example");
if (!exampleId && examples[0]) setExampleId(examples[0].id);
}}
/>
<span>
<span className="font-medium">Select example</span>
<span className="block text-sm opacity-70">
Core-script starters from examples/workflows
</span>
</span>
</label>
</fieldset>
{mode === "example" ? (
<div className="space-y-2 pl-8">
{isLoading ? <span className="loading loading-spinner loading-sm" /> : null}
{isError ? (
<p className="text-error text-sm">{errorMessage(error)}</p>
) : null}
{!isLoading && !isError && examples.length === 0 ? (
<p className="text-sm opacity-70">No examples available.</p>
) : null}
{examples.map((ex) => (
<label key={ex.id} className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="exampleId"
className="radio radio-sm mt-1"
checked={exampleId === ex.id}
onChange={() => setExampleId(ex.id)}
/>
<span>
<span className="font-medium">{ex.name}</span>
{ex.description ? (
<span className="block text-sm opacity-70 line-clamp-2">
{ex.description}
</span>
) : null}
</span>
</label>
))}
</div>
) : null}
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={dontAsk}
onChange={(e) => {
const checked = e.target.checked;
setDontAsk(checked);
if (checked) setMode("empty");
}}
/>
<span className="label-text">Don&apos;t ask this. Always create empty workflow.</span>
</label>
{loadError ? <p className="text-error text-sm">{loadError}</p> : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={pending}>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={pending || (mode === "example" && !exampleId)}
>
{pending ? <span className="loading loading-spinner loading-xs" /> : null}
Continue
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onCancel}>
close
</button>
</form>
</dialog>
);
}
@@ -0,0 +1,11 @@
export function OpsStatusCard({ title, children, actions }) {
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">{title}</h2>
{children}
{actions ? <div className="card-actions justify-start px-0">{actions}</div> : null}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { formatBytes, formatCpu } from "../lib/format.js";
import { formatBytes, formatCpu } from "../lib/format";
export function ProcessResourcesCard({ children }) {
const totals = children?.totals ?? { memory: 0, cpu: 0 };
@@ -0,0 +1,176 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useScripts, useUpsertProfile } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx";
import { ConfigFields } from "./workflow/ConfigFields.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/**
* @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: scripts = [] } = useScripts();
const upsert = useUpsertProfile();
const [form, setForm] = useState(() => ({
owner: initial.owner || DEFAULT_OWNER,
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.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">Name</span>
<FormInput
className="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.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>
<FormSelect
className="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}
</FormSelect>
</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>
<FormInput
className="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>
);
}
+45 -45
View File
@@ -1,4 +1,5 @@
import { LuCircleCheck, LuCirclePause } from "react-icons/lu";
import { OpsStatusCard } from "./OpsStatusCard.jsx";
export function QueueCard({
paused = false,
@@ -11,51 +12,50 @@ export function QueueCard({
resumePending = false,
}) {
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">Queue</h2>
<div className="flex items-center gap-2">
{paused ? (
<LuCirclePause className="size-6 text-warning" aria-hidden />
) : (
<LuCircleCheck className="size-6 text-success" aria-hidden />
)}
<span>
{paused
? "Paused"
: `Running (${workersOnline} worker${workersOnline === 1 ? "" : "s"})`}
</span>
</div>
<div className="space-y-1 text-sm tabular-nums">
<p>
{activeJobs} running job{activeJobs === 1 ? "" : "s"}
</p>
<p>
{queuedJobs} queued job{queuedJobs === 1 ? "" : "s"}
</p>
</div>
<div className="card-actions justify-start px-0">
{paused ? (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={resumePending}
onClick={onResume}
>
Resume workers
</button>
) : (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={pausePending}
onClick={onPause}
>
Pause workers
</button>
)}
</div>
<OpsStatusCard
title="Queue"
actions={
paused ? (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={resumePending}
onClick={onResume}
>
Resume workers
</button>
) : (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={pausePending}
onClick={onPause}
>
Pause workers
</button>
)
}
>
<div className="flex items-center gap-2">
{paused ? (
<LuCirclePause className="size-6 text-warning" aria-hidden />
) : (
<LuCircleCheck className="size-6 text-success" aria-hidden />
)}
<span>
{paused
? "Paused"
: `Running (${workersOnline} worker${workersOnline === 1 ? "" : "s"})`}
</span>
</div>
</div>
<div className="space-y-1 text-sm tabular-nums">
<p>
{activeJobs} running job{activeJobs === 1 ? "" : "s"}
</p>
<p>
{queuedJobs} queued job{queuedJobs === 1 ? "" : "s"}
</p>
</div>
</OpsStatusCard>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
return `/api/scripts/${encodeURIComponent(name)}/icon`;
}
/** Same-name PNG/JPG next to the script, or a JS document icon. */
/** Same-name PNG/JPG/JPEG next to the script, or a JS document icon. */
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
const [failed, setFailed] = useState(false);
@@ -1,28 +1,22 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertSecret } from "../api/hooks.js";
import { useUpsertSecret } from "../api/hooks.js";
import { FormInput } from "./FormControls.jsx";
import { Modal } from "./Modal.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/**
* Add / replace an encrypted secret.
* Reusable: mount when open; parent supplies mode + initial fields.
*
* @param {"add" | "replace"} mode
* @param {{ owner: string, name?: string }} initial
* @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner]
*/
export function SecretEditorModal({
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
const upsert = useUpsertSecret();
const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default",
owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "",
value: "",
}));
@@ -40,88 +34,53 @@ export function SecretEditorModal({
);
}
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`;
const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-md">
<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
disabled={lockOwner}
>
{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
disabled={lockOwner}
/>
)}
</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"
/>
</label>
</>
) : null}
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
<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">Value</span>
<input
type="password"
className="input w-full"
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
autoComplete="new-password"
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
<p className="text-xs opacity-60">
Values are encrypted at rest and never shown again after save. Values shorter than 8
characters are not redacted from logs.
</p>
{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}
Save
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
) : null}
<label className="form-control w-full">
<span className="label py-0 text-sm">Value</span>
<FormInput
type="password"
className="w-full"
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
required
autoComplete="new-password"
/>
</label>
<p className="text-xs opacity-60">
Values are encrypted at rest and never shown again after save. Values shorter than 8
characters are not redacted from logs.
</p>
{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}
Save
</button>
</div>
</form>
</dialog>
</Modal>
);
}
@@ -1,6 +1,7 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useCreateUser, useUpdateUser } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx";
/**
* Add / edit a local user.
@@ -61,8 +62,8 @@ export function UserEditorModal({ mode, user, onClose, onSaved }) {
{mode === "add" ? (
<label className="form-control w-full">
<span className="label py-0 text-sm">Username</span>
<input
className="input w-full"
<FormInput
className="w-full"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
@@ -73,9 +74,9 @@ export function UserEditorModal({ mode, user, onClose, onSaved }) {
<span className="label py-0 text-sm">
{mode === "add" ? "Password" : "New password"}
</span>
<input
<FormInput
type="password"
className="input w-full"
className="w-full"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required={mode === "add"}
@@ -85,14 +86,14 @@ export function UserEditorModal({ mode, user, onClose, onSaved }) {
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Role</span>
<select
className="select w-full"
<FormSelect
className="w-full"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
</FormSelect>
</label>
{mutation.isError ? (
<p className="text-error text-sm">{errorMessage(mutation.error)}</p>
@@ -1,6 +1,8 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertVariable } from "../api/hooks.js";
import { useUpsertVariable } from "../api/hooks.js";
import { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
const TYPES = ["string", "number", "boolean"];
@@ -12,25 +14,16 @@ function defaultValue(type) {
/**
* Add / edit a plaintext workflow variable.
* Reusable: mount when open; parent supplies mode + initial fields.
*
* @param {"add" | "edit"} mode
* @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
* @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
*/
export function VariableEditorModal({
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
const upsert = useUpsertVariable();
const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default",
owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "",
type: initial.type || "string",
value:
@@ -71,7 +64,7 @@ export function VariableEditorModal({
);
}
const title = mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`;
const title = mode === "add" ? "New variable" : `Edit ${form.name}`;
return (
<dialog className="modal modal-open">
@@ -79,50 +72,22 @@ export function VariableEditorModal({
<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
disabled={lockOwner}
>
{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
disabled={lockOwner}
/>
)}
</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"
/>
</label>
</>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="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"
/>
</label>
) : null}
<label className="form-control w-full">
<span className="label py-0 text-sm">Type</span>
<select
className="select w-full"
<FormSelect
className="w-full"
value={form.type}
onChange={(e) => onTypeChange(e.target.value)}
>
@@ -131,31 +96,31 @@ export function VariableEditorModal({
{t}
</option>
))}
</select>
</FormSelect>
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Value</span>
{form.type === "boolean" ? (
<select
className="select w-full"
<FormSelect
className="w-full"
value={form.value === true ? "true" : "false"}
onChange={(e) => setForm({ ...form, value: e.target.value === "true" })}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
</FormSelect>
) : form.type === "number" ? (
<input
<FormInput
type="number"
className="input w-full font-mono"
className="w-full font-mono"
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
required
step="any"
/>
) : (
<textarea
className="textarea w-full font-mono min-h-24"
<FormTextarea
className="w-full font-mono min-h-24"
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
spellCheck={false}
@@ -1,13 +1,19 @@
import { useMemo, useState } from "react";
import { useScripts } from "../../api/hooks.js";
import { useProfiles, useScripts } from "../../api/hooks.js";
import { FormInput } from "../FormControls.jsx";
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 +32,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>
<input
className="input input-sm w-full mt-3"
placeholder="Search scripts"
<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>
<FormInput
className="w-full mt-3"
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"
@@ -0,0 +1,68 @@
import { useState } from "react";
export function AddTriggerDialog({ open, onClose, onPick }) {
const [kind, setKind] = useState("HTTP");
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Add trigger</h3>
<div className="form-control mt-3">
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "HTTP"}
onChange={() => setKind("HTTP")}
/>
<span>
HTTP — webhook at <span className="font-mono">/u/owner/path</span>
</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "cron"}
onChange={() => setKind("cron")}
/>
<span>Cron — run on a schedule</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "workflow"}
onChange={() => setKind("workflow")}
/>
<span>Workflow — callable by other workflows</span>
</label>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onPick(kind);
setKind("HTTP");
}}
>
Add
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { LuChevronDown, LuX } from "react-icons/lu";
import { FormInput } from "../FormControls.jsx";
/**
* Searchable dropdown: pick an auth to add; chips with X to remove.
* YAML stores profile UUIDs; UI shows names.
*/
export function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef(null);
const inputRef = useRef(null);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
const available = useMemo(() => {
const q = query.trim().toLowerCase();
return auths
.filter((a) => !selectedSet.has(a.id))
.filter((a) => {
if (!q) return true;
return (
a.name.toLowerCase().includes(q) ||
String(a.type).toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
);
});
}, [auths, selectedSet, query]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
function addAuth(id) {
if (selectedSet.has(id)) return;
onChange([...selectedIds, id]);
setQuery("");
setOpen(false);
}
function removeAuth(id) {
onChange(selectedIds.filter((x) => x !== id));
}
return (
<div className="flex w-full flex-col gap-1" ref={rootRef}>
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => {
const auth = byId.get(id);
return (
<span
key={id}
className="badge badge-outline gap-1 h-7 px-2 font-normal"
title={id}
>
<span className="max-w-[10rem] truncate">
{auth ? (
<>
{auth.name}
<span className="opacity-60"> · {auth.type}</span>
</>
) : (
<span className="opacity-60">missing</span>
)}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square -mr-1"
title="Remove"
aria-label={`Remove ${auth?.name ?? id}`}
disabled={disabled}
onClick={() => removeAuth(id)}
>
<LuX className="size-3.5" />
</button>
</span>
);
})}
</div>
) : null}
<div className="relative w-full">
<div className="join w-full">
<FormInput
ref={inputRef}
type="search"
className="join-item w-full min-w-0"
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
value={query}
disabled={disabled || auths.length === 0}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
if (e.key === "Enter") {
e.preventDefault();
if (available[0]) addAuth(available[0].id);
}
}}
/>
<button
type="button"
className="btn btn-sm join-item btn-square"
disabled={disabled || auths.length === 0}
aria-label="Open auth list"
onClick={() => {
setOpen((v) => !v);
if (!open) inputRef.current?.focus();
}}
>
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</div>
{open && !disabled && auths.length > 0 ? (
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{available.length === 0 ? (
<li className="disabled">
<span className="opacity-60">
{query.trim() ? "No matches" : "All profiles selected"}
</span>
</li>
) : (
available.map((a) => (
<li key={a.id}>
<button type="button" onClick={() => addAuth(a.id)}>
<span className="font-mono text-sm">{a.name}</span>
<span className="opacity-60 text-xs">{a.type}</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
{inlineCount > 0 ? (
<span className="text-xs opacity-60">
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
</span>
) : null}
</div>
);
}
@@ -1,152 +1,12 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { triggerDestinations } from "../../lib/workflow-doc.js";
import { prettyJson } from "../../lib/script.js";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { FieldLabel } from "./FieldHelp.jsx";
import { ConfigRefHint } from "./ConfigRefHint.jsx";
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
const VAR_PEEK_MAX = 48;
const CONFIG_REF_PREFIXES = [
{ prefix: "$SECRET_", label: "secret" },
{ prefix: "$CONTEXT_", label: "context" },
{ prefix: "$VAR_", label: "variable" },
];
function describeConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
return { label, name: trimmed.slice(prefix.length), kind: prefix };
}
}
return null;
}
function formatVarDisplay(value) {
if (typeof value === "string") return value === "" ? '""' : value;
return String(value);
}
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
if (!text) return "";
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
}
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name);
if (match) {
return { status: "found", value: match.value, type: match.type };
}
const otherOwners = [
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
];
if (otherOwners.length > 0) {
return { status: "other_owner", otherOwners };
}
return { status: "missing" };
}
function variablesDeepLink({ owner, name, missing }) {
if (missing) {
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
if (name) params.set("name", name);
const q = params.toString();
return q ? `/variables/new?${q}` : "/variables/new";
}
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
}
function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_";
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false);
useEffect(() => {
setRevealed(false);
}, [value, owner]);
if (!ref) return null;
if (!isVar) {
return (
<p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span>
</p>
);
}
const lookup = !owner
? { status: "missing" }
: isPending
? { status: "loading" }
: lookupVariable(variables, owner, ref.name);
const linkTo = variablesDeepLink({
owner: owner || "",
name: ref.name,
missing: lookup.status !== "found",
});
let statusNote = null;
if (lookup.status === "missing") {
statusNote = <span className="text-warning">(missing)</span>;
} else if (lookup.status === "other_owner") {
statusNote = (
<span className="text-warning">
(missing · other owner: {lookup.otherOwners.join(", ")})
</span>
);
}
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
const peek = truncatePeek(fullText);
const masked = lookup.status === "found" ? "******" : null;
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
<span>
from variable <span className="font-mono">{ref.name}</span>
{lookup.status === "found" ? ":" : null}
</span>
{lookup.status === "found" ? (
<>
<span className="font-mono" title={revealed ? fullText : undefined}>
{revealed ? peek : masked}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
aria-label={revealed ? "Hide value" : "Show value"}
title={revealed ? "Hide value" : "Show value"}
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
</button>
</>
) : null}
{statusNote}
{lookup.status === "loading" ? null : (
<Link
to={linkTo}
target="_blank"
rel="noopener noreferrer"
className="link link-hover inline-flex items-center gap-0.5"
>
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
<LuExternalLink className="size-3" aria-hidden />
</Link>
)}
</div>
);
}
function fieldSpec(meta, key) {
const spec = meta?.config?.[key];
@@ -197,8 +57,8 @@ function EditableText({ value, onCommit, className = "", placeholder = "…", di
}
return (
<input
className={`input input-sm w-full ${className}`}
<FormInput
className={`w-full ${className}`.trim()}
value={draft}
autoFocus
onChange={(e) => setDraft(e.target.value)}
@@ -239,8 +99,8 @@ function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner
const current = value === undefined || value === null ? fallback : value;
const known = options.some((o) => o.value === current);
return (
<select
className="select select-sm w-full"
<FormSelect
className="w-full"
value={current === undefined || current === null ? "" : String(current)}
onChange={(e) => {
const next = e.target.value;
@@ -262,7 +122,7 @@ function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner
{known || current === "" || current == null ? null : (
<option value={String(current)}>{String(current)}</option>
)}
</select>
</FormSelect>
);
}
@@ -280,9 +140,9 @@ function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner
if (type === "number") {
return (
<input
<FormInput
type="number"
className="input input-sm w-full"
className="w-full"
value={value ?? ""}
disabled={disabled}
onChange={(e) => {
@@ -329,8 +189,8 @@ function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner
const multiline = MULTILINE_KEYS.has(fieldKey) || str.includes("\n");
if (multiline) {
return (
<textarea
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
<FormTextarea
className="w-full min-h-24 font-mono text-xs"
value={str}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
@@ -347,8 +207,8 @@ function JsonOrTextArea({ text, onCommit, disabled }) {
setDraft(text);
}, [text]);
return (
<textarea
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
<FormTextarea
className="w-full min-h-24 font-mono text-xs"
value={draft}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
@@ -441,8 +301,8 @@ function WorkflowNamePicker({ value, onChange, workflows, owner, excludeFile, di
return (
<div className="space-y-1">
<select
className="select select-sm w-full"
<FormSelect
className="w-full"
value={selectValue}
disabled={disabled}
onChange={(e) => {
@@ -464,10 +324,10 @@ function WorkflowNamePicker({ value, onChange, workflows, owner, excludeFile, di
</option>
))}
<option value="__custom__">Custom…</option>
</select>
</FormSelect>
{selectValue === "__custom__" ? (
<input
className="input input-sm w-full font-mono"
<FormInput
className="w-full font-mono"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
@@ -487,15 +347,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 +372,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 +390,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 +461,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 +491,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
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js";
const VAR_PEEK_MAX = 48;
const CONFIG_REF_PREFIXES = [
{ prefix: "$SECRET_", label: "secret" },
{ prefix: "$CONTEXT_", label: "context" },
{ prefix: "$VAR_", label: "variable" },
];
function describeConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
return { label, name: trimmed.slice(prefix.length), kind: prefix };
}
}
return null;
}
function formatVarDisplay(value) {
if (typeof value === "string") return value === "" ? '""' : value;
return String(value);
}
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
if (!text) return "";
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
}
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name);
if (match) {
return { status: "found", value: match.value, type: match.type };
}
const otherOwners = [
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
];
if (otherOwners.length > 0) {
return { status: "other_owner", otherOwners };
}
return { status: "missing" };
}
function variablesDeepLink({ owner, name, missing }) {
if (missing) {
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
if (name) params.set("name", name);
const q = params.toString();
return q ? `/variables/new?${q}` : "/variables/new";
}
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
}
export function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_";
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false);
useEffect(() => {
setRevealed(false);
}, [value, owner]);
if (!ref) return null;
if (!isVar) {
return (
<p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span>
</p>
);
}
const lookup = !owner
? { status: "missing" }
: isPending
? { status: "loading" }
: lookupVariable(variables, owner, ref.name);
const linkTo = variablesDeepLink({
owner: owner || "",
name: ref.name,
missing: lookup.status !== "found",
});
let statusNote = null;
if (lookup.status === "missing") {
statusNote = <span className="text-warning">(missing)</span>;
} else if (lookup.status === "other_owner") {
statusNote = (
<span className="text-warning">
(missing · other owner: {lookup.otherOwners.join(", ")})
</span>
);
}
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
const peek = truncatePeek(fullText);
const masked = lookup.status === "found" ? "******" : null;
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
<span>
from variable <span className="font-mono">{ref.name}</span>
{lookup.status === "found" ? ":" : null}
</span>
{lookup.status === "found" ? (
<>
<span className="font-mono" title={revealed ? fullText : undefined}>
{revealed ? peek : masked}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
aria-label={revealed ? "Hide value" : "Show value"}
title={revealed ? "Hide value" : "Show value"}
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
</button>
</>
) : null}
{statusNote}
{lookup.status === "loading" ? null : (
<Link
to={linkTo}
target="_blank"
rel="noopener noreferrer"
className="link link-hover inline-flex items-center gap-0.5"
>
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
<LuExternalLink className="size-3" aria-hidden />
</Link>
)}
</div>
);
}
@@ -0,0 +1,442 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
applyNodeChanges,
useReactFlow,
} from "@xyflow/react";
import { LuPlus, LuRotateCcw } from "react-icons/lu";
import { defaultConfigFromMeta } from "../../lib/script.js";
import {
newCronTrigger,
newHttpTrigger,
newProfileStep,
newScriptStep,
newSetStep,
newWorkflowTrigger,
withAllocatedStepId,
} from "../../lib/workflow-doc.js";
import {
addStepEdge,
buildGraphElements,
canConnectSteps,
clearGraphLayout,
enteringDagWouldStripWhen,
parseGraphNodeId,
readGraphLayout,
removeStepEdge,
stepNodeId,
writeGraphLayout,
} from "../../lib/workflow-graph.js";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
import { ScriptCard } from "./ScriptCard.jsx";
import { TriggerCard } from "./TriggerCard.jsx";
import { AddScriptDialog } from "./AddScriptDialog.jsx";
import { AddTriggerDialog } from "./AddTriggerDialog.jsx";
import { StepGraphNode, TriggerGraphNode } from "./graph/GraphNodes.jsx";
import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
function listedHasIcon(listed) {
if (listed == null || typeof listed === "string") return undefined;
return listed.hasIcon;
}
function stepIconData(node, scriptsByName, profilesByName) {
if (node.type !== "step" || node.data?.kind === "set") {
return { iconScript: "", hasIcon: false };
}
const profile = node.data?.profile ? profilesByName.get(node.data.profile) : null;
const iconScript = profile?.script || node.data?.script || "";
return { iconScript, hasIcon: listedHasIcon(scriptsByName.get(iconScript)) };
}
/** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
export function GraphTab(props) {
return (
<ReactFlowProvider>
<GraphTabInner {...props} />
</ReactFlowProvider>
);
}
function GraphTabInner({
doc,
onPatch,
disabled,
scripts = [],
profiles = [],
workflows = [],
owner,
file,
excludeFile,
auths = [],
pages = [],
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const { fitView } = useReactFlow();
const [addStepOpen, setAddStepOpen] = useState(false);
const [addTriggerOpen, setAddTriggerOpen] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const [pendingConnect, setPendingConnect] = useState(null);
const [positions, setPositions] = useState(() => readGraphLayout(owner, file));
const [nodes, setNodes] = useState([]);
useEffect(() => {
setPositions(readGraphLayout(owner, file));
setSelectedId(null);
}, [owner, file]);
const steps = doc?.scripts ?? [];
const triggers = doc?.triggers ?? [];
const scriptsByName = useMemo(() => {
const map = new Map();
for (const s of scripts) {
const name = typeof s === "string" ? s : s.name;
if (name) map.set(name, s);
}
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 { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
useEffect(() => {
const built = buildGraphElements(doc, positions);
setNodes((prev) => {
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
return built.nodes.map((n) => ({
...n,
data: { ...n.data, ...stepIconData(n, scriptsByName, profilesByName) },
selected: selected.has(n.id),
deletable: !disabled,
draggable: !disabled,
}));
});
}, [doc, disabled, positions, scriptsByName, profilesByName]);
useEffect(() => {
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t);
}, [owner, file, fitView]);
const selected = parseGraphNodeId(selectedId);
const displayEdges = useMemo(
() =>
edges.map((e) => ({
...e,
deletable: Boolean(e.deletable) && !disabled,
})),
[edges, disabled],
);
const persistPositions = useCallback(
(next) => {
setPositions(next);
writeGraphLayout(owner, file, next);
},
[owner, file],
);
const onNodesChange = useCallback((changes) => {
setNodes((nds) => applyNodeChanges(changes, nds));
}, []);
function patchSteps(next) {
onPatch((d) => {
d.scripts = next;
});
}
function patchTriggers(next) {
onPatch((d) => {
d.triggers = next;
});
}
function applyConnect(fromUiId, toUiId) {
patchSteps(addStepEdge(doc, fromUiId, toUiId));
}
const isValidConnection = useCallback(
(conn) => {
if (disabled) return false;
const from = parseGraphNodeId(conn.source);
if (from?.kind !== "step") return false;
if (!conn.target) return true;
const to = parseGraphNodeId(conn.target);
if (to?.kind !== "step") return false;
return canConnectSteps(doc, from.uiId, to.uiId);
},
[doc, disabled],
);
function onConnect(conn) {
const from = parseGraphNodeId(conn.source);
const to = parseGraphNodeId(conn.target);
if (from?.kind !== "step" || to?.kind !== "step") return;
if (!canConnectSteps(doc, from.uiId, to.uiId)) return;
if (enteringDagWouldStripWhen(doc)) {
setPendingConnect({ fromUiId: from.uiId, toUiId: to.uiId });
return;
}
applyConnect(from.uiId, to.uiId);
}
function onEdgesDelete(deleted) {
if (disabled) return;
for (const edge of deleted) {
const kind = edge.data?.edgeKind;
if (kind !== "list" && kind !== "linear") continue;
const from = parseGraphNodeId(edge.source);
const to = parseGraphNodeId(edge.target);
if (from?.kind !== "step" || to?.kind !== "step") continue;
if (enteringDagWouldStripWhen(doc) && kind === "linear") {
setPendingConnect({ fromUiId: from.uiId, toUiId: to.uiId, remove: true });
continue;
}
patchSteps(removeStepEdge(doc, from.uiId, to.uiId));
}
}
function onNodesDelete(deleted) {
if (disabled) return;
const dropSteps = new Set();
const dropTrigs = new Set();
for (const n of deleted) {
const parsed = parseGraphNodeId(n.id);
if (parsed?.kind === "step") dropSteps.add(parsed.uiId);
if (parsed?.kind === "trigger") dropTrigs.add(parsed.uiId);
}
if (dropSteps.size) patchSteps(steps.filter((s) => !dropSteps.has(s.uiId)));
if (dropTrigs.size) patchTriggers(triggers.filter((t) => !dropTrigs.has(t.uiId)));
setSelectedId(null);
}
function onNodeDragStop(_ev, node) {
const key = node.data?.layoutKey;
if (!key) return;
persistPositions({ ...positions, [key]: { ...node.position } });
}
function resetLayout() {
clearGraphLayout(owner, file);
persistPositions({});
requestAnimationFrame(() => fitView(FIT_VIEW));
}
const selectedStep = selected?.kind === "step" ? steps.find((s) => s.uiId === selected.uiId) : null;
const selectedTrigger =
selected?.kind === "trigger" ? triggers.find((t) => t.uiId === selected.uiId) : null;
const selectedStepIndex = selectedStep ? steps.findIndex((s) => s.uiId === selectedStep.uiId) : -1;
const selectedTriggerIndex = selectedTrigger
? triggers.findIndex((t) => t.uiId === selectedTrigger.uiId)
: -1;
return (
<div className="flex min-h-0 flex-1 gap-3">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<p className="text-sm opacity-70 flex-1">
Drag nodes to arrange. Connect steps to set list <span className="font-mono">needs</span>.
Trigger links are automatic.
</p>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={disabled}
onClick={resetLayout}
title="Reset layout"
>
<LuRotateCcw className="size-4" />
Reset layout
</button>
<button
type="button"
className="btn btn-sm"
disabled={disabled}
onClick={() => setAddTriggerOpen(true)}
>
<LuPlus className="size-4" />
Add trigger
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={disabled}
onClick={() => setAddStepOpen(true)}
>
<LuPlus className="size-4" />
Add step
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-box border border-base-300 bg-base-200">
<ReactFlow
className="workflow-graph"
nodes={nodes}
edges={displayEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onSelectionChange={({ nodes: selectedNodes }) => {
const id = selectedNodes[0]?.id ?? null;
setSelectedId((cur) => (cur === id ? cur : id));
}}
onNodeDragStop={onNodeDragStop}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
nodesConnectable={!disabled}
nodesDraggable={!disabled}
connectionRadius={80}
defaultViewport={{ x: 0, y: 0, zoom: INITIAL_ZOOM }}
defaultEdgeOptions={{ type: "smoothstep" }}
proOptions={{ hideAttribution: false }}
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
</div>
<aside className="flex w-full max-w-md shrink-0 flex-col gap-2 overflow-auto sm:w-96">
{selectedStep && selectedStepIndex >= 0 ? (
<ScriptCard
key={selectedStep.uiId}
step={selectedStep}
index={selectedStepIndex}
otherSteps={steps}
scriptsByName={scriptsByName}
profilesByName={profilesByName}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
sortable={false}
defaultExpanded
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === selectedStep.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? selectedStep.uiId : null);
}}
onNavigateTry={(targetUiId) => {
const id = stepNodeId(targetUiId);
setSelectedId(id);
setNodes((prev) =>
prev.map((n) => ({ ...n, selected: n.id === id })),
);
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[selectedStepIndex] = next;
patchSteps(copy);
}}
onRemove={() => {
patchSteps(steps.filter((_, i) => i !== selectedStepIndex));
setSelectedId(null);
onTryFocus?.(null);
}}
/>
) : selectedTrigger && selectedTriggerIndex >= 0 ? (
<TriggerCard
key={selectedTrigger.uiId}
trigger={selectedTrigger}
index={selectedTriggerIndex}
owner={owner}
auths={auths}
pages={pages}
workflows={workflows}
excludeFile={excludeFile}
disabled={disabled}
sortable={false}
defaultExpanded
onChange={(next) => {
const copy = [...triggers];
copy[selectedTriggerIndex] = next;
patchTriggers(copy);
}}
onRemove={() => {
patchTriggers(triggers.filter((_, i) => i !== selectedTriggerIndex));
setSelectedId(null);
}}
/>
) : (
<div className="rounded-box border border-dashed border-base-300 p-4 text-sm opacity-70">
<p>Select a node to edit it. Add a trigger or step to grow the graph.</p>
<p className="mt-2 text-xs">
Named <span className="font-mono">needs</span> maps stay in the inspector; the canvas
only edits list dependencies.
</p>
</div>
)}
</aside>
<AddScriptDialog
open={addStepOpen}
owner={owner}
onClose={() => setAddStepOpen(false)}
onPick={(picked) => {
let next;
if (picked.kind === "set") next = newSetStep();
else if (picked.kind === "profile") next = newProfileStep(picked.name, picked.script);
else next = newScriptStep(picked.name, defaultConfigFromMeta(picked.meta));
next = withAllocatedStepId(next, steps);
patchSteps([...steps, next]);
setSelectedId(`step:${next.uiId}`);
setAddStepOpen(false);
}}
/>
<AddTriggerDialog
open={addTriggerOpen}
onClose={() => setAddTriggerOpen(false)}
onPick={(kind) => {
const next =
kind === "cron"
? newCronTrigger()
: kind === "workflow"
? newWorkflowTrigger()
: newHttpTrigger();
patchTriggers([...triggers, next]);
setSelectedId(`trig:${next.uiId}`);
setAddTriggerOpen(false);
}}
/>
<ConfirmDialog
open={Boolean(pendingConnect)}
title="Switch to DAG mode?"
message="Connecting or removing a step edge writes needs and is not compatible with when. Existing when conditions on steps will be cleared."
confirmLabel="Continue"
confirmClass="btn-primary"
onCancel={() => setPendingConnect(null)}
onConfirm={() => {
const pending = pendingConnect;
setPendingConnect(null);
if (!pending) return;
if (pending.remove) patchSteps(removeStepEdge(doc, pending.fromUiId, pending.toUiId));
else applyConnect(pending.fromUiId, pending.toUiId);
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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, withAllocatedStepId } from "../../lib/workflow-doc.js";
import { AddScriptDialog } from "./AddScriptDialog.jsx";
import { ScriptCard } from "./ScriptCard.jsx";
@@ -24,9 +24,14 @@ export function ScriptsTab({
onPatch,
disabled,
scripts = [],
profiles = [],
workflows = [],
owner,
excludeFile,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
}) {
const [addOpen, setAddOpen] = useState(false);
const sensors = useSensors(
@@ -42,6 +47,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 +81,7 @@ export function ScriptsTab({
onClick={() => setAddOpen(true)}
>
<LuPlus className="size-4" />
Add script
Add step
</button>
</div>
{steps.length === 0 ? (
@@ -88,16 +97,29 @@ export function ScriptsTab({
index={index}
otherSteps={steps}
scriptsByName={scriptsByName}
profilesByName={profilesByName}
disabled={disabled}
workflows={workflows}
owner={owner}
excludeFile={excludeFile}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryOpen={tryFocusUiId === step.uiId}
onTryOpenChange={(open) => {
onTryFocus?.(open ? step.uiId : null);
}}
onNavigateTry={(targetUiId) => {
onTryFocus?.(targetUiId);
}}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
patchSteps(copy);
}}
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))}
onRemove={() => {
if (tryFocusUiId === step.uiId) onTryFocus?.(null);
patchSteps(steps.filter((_, i) => i !== index));
}}
/>
))}
</div>
@@ -106,16 +128,14 @@ export function ScriptsTab({
)}
<AddScriptDialog
open={addOpen}
owner={owner}
onClose={() => setAddOpen(false)}
onPick={(picked) => {
if (picked.kind === "set") {
patchSteps([...steps, newSetStep()]);
} else {
patchSteps([
...steps,
newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)),
]);
}
let next;
if (picked.kind === "set") next = newSetStep();
else if (picked.kind === "profile") next = newProfileStep(picked.name, picked.script);
else next = newScriptStep(picked.name, defaultConfigFromMeta(picked.meta));
patchSteps([...steps, withAllocatedStepId(next, steps)]);
setAddOpen(false);
}}
/>
@@ -1,12 +1,29 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu";
import { useState } from "react";
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import cronstrue from "cronstrue";
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
import { FormInput, FormSelect } from "../FormControls.jsx";
import { AuthPicker } from "./AuthPicker.jsx";
export function TriggerCard({
export function TriggerCard(props) {
if (props.sortable === false) {
return <TriggerCardView {...props} drag={null} />;
}
return <TriggerCardSortable {...props} />;
}
function TriggerCardSortable(props) {
const drag = useSortable({
id: props.trigger.uiId,
disabled: props.disabled,
});
return <TriggerCardView {...props} drag={drag} />;
}
function TriggerCardView({
trigger,
index,
owner,
@@ -17,18 +34,25 @@ export function TriggerCard({
pages = [],
workflows = [],
excludeFile,
sortable = true,
defaultExpanded = false,
drag,
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: trigger.uiId,
disabled,
});
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = drag ?? {
attributes: {},
listeners: {},
setNodeRef: undefined,
transform: null,
transition: undefined,
isDragging: false,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const type = trigger.type;
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(defaultExpanded);
const alertDestinations = triggerDestinations(workflows, { owner, excludeFile });
return (
@@ -41,16 +65,18 @@ export function TriggerCard({
>
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
aria-label={`Drag trigger ${index + 1}`}
disabled={disabled}
{...attributes}
{...listeners}
>
<LuGripVertical className="size-4 opacity-60" />
</button>
{sortable !== false ? (
<button
type="button"
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
aria-label={`Drag trigger ${index + 1}`}
disabled={disabled}
{...attributes}
{...listeners}
>
<LuGripVertical className="size-4 opacity-60" />
</button>
) : null}
<button
type="button"
className="min-w-0 flex-1 text-left"
@@ -174,8 +200,8 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
return (
<div className="space-y-3">
<Field label="Method">
<select
className="select select-bordered select-sm w-full"
<FormSelect
className="w-full"
value={trigger.method || "POST"}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, method: e.target.value })}
@@ -188,12 +214,12 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
{HTTP_METHODS.includes(trigger.method || "POST") ? null : (
<option value={trigger.method}>{trigger.method}</option>
)}
</select>
</FormSelect>
</Field>
<Field label="Path">
<input
className="input input-bordered input-sm w-full font-mono"
<FormInput
className="w-full font-mono"
value={trigger.path ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, path: e.target.value })}
@@ -203,8 +229,8 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
<Field label="URL">
<div className="flex w-full items-center gap-1">
<input
className="input input-bordered input-sm w-full font-mono opacity-80"
<FormInput
className="w-full font-mono opacity-80"
value={url}
readOnly
tabIndex={-1}
@@ -229,8 +255,8 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
/>
<Field label="Response page">
<select
className="select select-bordered select-sm w-full"
<FormSelect
className="w-full"
value={trigger.response ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, response: e.target.value })}
@@ -244,7 +270,7 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
{trigger.response && !pages.some((p) => p.name === trigger.response) ? (
<option value={trigger.response}>{trigger.response}</option>
) : null}
</select>
</FormSelect>
</Field>
<FailureAlertFields
@@ -257,164 +283,6 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
);
}
/**
* Searchable dropdown: pick an auth to add; chips with X to remove.
* YAML stores profile UUIDs; UI shows names.
*/
function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef(null);
const inputRef = useRef(null);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
const available = useMemo(() => {
const q = query.trim().toLowerCase();
return auths
.filter((a) => !selectedSet.has(a.id))
.filter((a) => {
if (!q) return true;
return (
a.name.toLowerCase().includes(q) ||
String(a.type).toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
);
});
}, [auths, selectedSet, query]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
function addAuth(id) {
if (selectedSet.has(id)) return;
onChange([...selectedIds, id]);
setQuery("");
setOpen(false);
}
function removeAuth(id) {
onChange(selectedIds.filter((x) => x !== id));
}
return (
<div className="flex w-full flex-col gap-1" ref={rootRef}>
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => {
const auth = byId.get(id);
return (
<span
key={id}
className="badge badge-outline gap-1 h-7 px-2 font-normal"
title={id}
>
<span className="max-w-[10rem] truncate">
{auth ? (
<>
{auth.name}
<span className="opacity-60"> · {auth.type}</span>
</>
) : (
<span className="opacity-60">missing</span>
)}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square -mr-1"
title="Remove"
aria-label={`Remove ${auth?.name ?? id}`}
disabled={disabled}
onClick={() => removeAuth(id)}
>
<LuX className="size-3.5" />
</button>
</span>
);
})}
</div>
) : null}
<div className="relative w-full">
<div className="join w-full">
<input
ref={inputRef}
type="search"
className="input input-bordered input-sm join-item w-full min-w-0"
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
value={query}
disabled={disabled || auths.length === 0}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
if (e.key === "Enter") {
e.preventDefault();
if (available[0]) addAuth(available[0].id);
}
}}
/>
<button
type="button"
className="btn btn-sm join-item btn-square"
disabled={disabled || auths.length === 0}
aria-label="Open auth list"
onClick={() => {
setOpen((v) => !v);
if (!open) inputRef.current?.focus();
}}
>
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</div>
{open && !disabled && auths.length > 0 ? (
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{available.length === 0 ? (
<li className="disabled">
<span className="opacity-60">
{query.trim() ? "No matches" : "All profiles selected"}
</span>
</li>
) : (
available.map((a) => (
<li key={a.id}>
<button type="button" onClick={() => addAuth(a.id)}>
<span className="font-mono text-sm">{a.name}</span>
<span className="opacity-60 text-xs">{a.type}</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
{inlineCount > 0 ? (
<span className="text-xs opacity-60">
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
</span>
) : null}
</div>
);
}
function CronFields({ trigger, disabled, onChange, alertDestinations }) {
const preset = matchCronPreset(trigger.schedule);
@@ -429,8 +297,7 @@ function CronFields({ trigger, disabled, onChange, alertDestinations }) {
<div className="space-y-2">
<label className="form-control">
<span className="label py-0 text-sm">Preset</span>
<select
className="select select-sm"
<FormSelect
value={preset}
disabled={disabled}
onChange={(e) => {
@@ -446,12 +313,12 @@ function CronFields({ trigger, disabled, onChange, alertDestinations }) {
</option>
))}
<option value={CRON_CUSTOM}>Custom</option>
</select>
</FormSelect>
</label>
<label className="form-control">
<span className="label py-0 text-sm">Schedule</span>
<input
className="input input-sm font-mono"
<FormInput
className="font-mono"
value={trigger.schedule ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, schedule: e.target.value })}
@@ -478,8 +345,8 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
<div className="space-y-3 border-t border-base-300 pt-3">
<p className="text-sm font-medium opacity-80">Failure alert</p>
<Field label="Consecutive failures">
<input
className="input input-bordered input-sm w-full"
<FormInput
className="w-full"
type="number"
min="1"
step="1"
@@ -501,8 +368,8 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
</>
}
>
<select
className="select select-bordered select-sm w-full"
<FormSelect
className="w-full"
value={trigger.onFailureWorkflow ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, onFailureWorkflow: e.target.value })}
@@ -519,7 +386,7 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
) ? (
<option value={trigger.onFailureWorkflow}>{trigger.onFailureWorkflow}</option>
) : null}
</select>
</FormSelect>
</Field>
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
@@ -538,68 +405,3 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
</div>
);
}
export function AddTriggerDialog({ open, onClose, onPick }) {
const [kind, setKind] = useState("HTTP");
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Add trigger</h3>
<div className="form-control mt-3">
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "HTTP"}
onChange={() => setKind("HTTP")}
/>
<span>HTTP — webhook at <span className="font-mono">/u/owner/path</span></span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "cron"}
onChange={() => setKind("cron")}
/>
<span>Cron — run on a schedule</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="trig-kind"
className="radio radio-sm"
checked={kind === "workflow"}
onChange={() => setKind("workflow")}
/>
<span>Workflow — callable by other workflows</span>
</label>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onPick(kind);
setKind("HTTP");
}}
>
Add
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onClose}>
close
</button>
</form>
</dialog>
);
}
@@ -15,7 +15,8 @@ import {
} from "@dnd-kit/sortable";
import { LuPlus } from "react-icons/lu";
import { newCronTrigger, newHttpTrigger, newWorkflowTrigger } from "../../lib/workflow-doc.js";
import { AddTriggerDialog, TriggerCard } from "./TriggerCard.jsx";
import { AddTriggerDialog } from "./AddTriggerDialog.jsx";
import { TriggerCard } from "./TriggerCard.jsx";
export function TriggersTab({
doc,
@@ -5,14 +5,14 @@ import {
useRevertWorkflowRevision,
useWorkflowRevisions,
} from "../../api/hooks.js";
import { formatTime } from "../../lib/format.jsx";
import { formatTime } from "../../lib/format";
import { useNotifications } from "../../notifications.jsx";
import {
SaveWorkflowWarningsDialog,
isSaveWarningsError,
saveWarningsFromError,
} from "./SaveWorkflowWarningsDialog.jsx";
import { ConfirmDialog } from "./WorkflowRevisionBanner.jsx";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
function reasonLabel(reason, meta) {
if (reason === "duplicated" && meta?.from) return `duplicated from ${meta.from}`;
@@ -127,6 +127,7 @@ export function WorkflowHistoryPanel({
type="button"
className="btn btn-ghost btn-xs shrink-0"
title="Revert to this revision"
aria-label="Revert to this revision"
disabled={revert.isPending}
onClick={(e) => {
e.stopPropagation();
@@ -152,6 +153,7 @@ export function WorkflowHistoryPanel({
title={`Revert to revision #${confirmRev.revision}?`}
message={`This replaces the live workflow with revision #${confirmRev.revision} from ${formatTime(confirmRev.created_at)} and creates a new revision.`}
confirmLabel="Revert"
confirmClass="btn-warning"
pending={revert.isPending}
onCancel={() => setConfirmRevision(null)}
onConfirm={() => doRevert(confirmRev.revision, false)}
@@ -2,54 +2,15 @@ import { useState } from "react";
import { LuHistory } from "react-icons/lu";
import { errorMessage } from "../../api/client.js";
import { useRevertWorkflowRevision } from "../../api/hooks.js";
import { formatTime } from "../../lib/format.jsx";
import { formatTime } from "../../lib/format";
import { useNotifications } from "../../notifications.jsx";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
import {
SaveWorkflowWarningsDialog,
isSaveWarningsError,
saveWarningsFromError,
} from "./SaveWorkflowWarningsDialog.jsx";
function ConfirmDialog({
open,
title,
message,
confirmLabel,
confirmClass = "btn-warning",
pending,
onCancel,
onConfirm,
}) {
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-md">
<h3 className="font-bold">{title}</h3>
<p className="mt-2 text-sm opacity-70">{message}</p>
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className={`btn ${confirmClass}`}
disabled={pending}
onClick={onConfirm}
>
{pending ? <span className="loading loading-spinner loading-xs" /> : null}
{confirmLabel}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onCancel}>
close
</button>
</form>
</dialog>
);
}
export function WorkflowRevisionBanner({
owner,
file,
@@ -110,6 +71,7 @@ export function WorkflowRevisionBanner({
title={`Revert to revision #${revision}?`}
message={`This replaces the live workflow with revision #${revision} from ${formatTime(createdAt)} and creates a new revision.`}
confirmLabel="Revert"
confirmClass="btn-warning"
pending={revert.isPending}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => doRevert(false)}
@@ -125,5 +87,3 @@ export function WorkflowRevisionBanner({
</>
);
}
export { ConfirmDialog };
@@ -4,7 +4,8 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
import { api, errorMessage } from "../../api/client.js";
import { useRunWorkflow } from "../../api/hooks.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { StatusBadge } from "../../lib/format.jsx";
import { JsonTree } from "../JsonViewBlock.jsx";
import { StatusBadge } from "../../lib/format";
import { prettyJson } from "../../lib/script.js";
import {
overlayWorkflowTestData,
@@ -50,6 +51,7 @@ export function WorkflowTestPanel({
const [last, setLast] = useState(null);
const [polling, setPolling] = useState(false);
const [expanded, setExpanded] = useState(false);
const [inputView, setInputView] = useState("json");
const saveTimer = useRef(null);
const pollAbort = useRef(null);
@@ -146,9 +148,15 @@ export function WorkflowTestPanel({
);
}
const resultJson = last
? prettyJson(last.error ? { error: last.error } : last.result)
: "";
const resultValue = last ? (last.error ? { error: last.error } : last.result) : undefined;
let inputTreeValue;
let inputTreeError = null;
try {
inputTreeValue = JSON.parse(dataJson || "null");
} catch (err) {
inputTreeError = err instanceof Error ? err.message : String(err);
}
const busy = run.isPending || polling;
@@ -206,9 +214,31 @@ export function WorkflowTestPanel({
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
data
<SchemaTooltip label="Input" fields={inputFields} />
<span className="join ml-auto">
<button
type="button"
className={`join-item btn btn-xs ${inputView === "json" ? "btn-active" : ""}`}
onClick={() => setInputView("json")}
>
JSON
</button>
<button
type="button"
className={`join-item btn btn-xs ${inputView === "tree" ? "btn-active" : ""}`}
onClick={() => setInputView("tree")}
>
Tree
</button>
</span>
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
{inputView === "json" ? (
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
) : inputTreeError ? (
<p className="text-error text-sm p-2">Fix JSON to preview the tree: {inputTreeError}</p>
) : (
<JsonTree value={inputTreeValue} keyName="data" className="h-full max-h-full" />
)}
</div>
</div>
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
@@ -222,13 +252,11 @@ export function WorkflowTestPanel({
) : null}
</p>
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
<CodeEditor
language="json"
value={resultJson}
onChange={() => {}}
readOnly
height="100%"
/>
{resultValue === undefined ? (
<p className="text-xs opacity-50 p-2">Run the workflow to see a result tree.</p>
) : (
<JsonTree value={resultValue} keyName="result" className="h-full max-h-full" />
)}
</div>
</div>
</div>
@@ -1,33 +1,19 @@
import { useMemo, useState } from "react";
import { parse as parseYaml } from "yaml";
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
import { useEffect, useMemo, useState } from "react";
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 {
emptyTrySession,
pruneTrySession,
recordTrySuccess,
} from "../../lib/try-session.js";
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
import { FormInput, FormTextarea } from "../FormControls.jsx";
import { GraphTab } from "./GraphTab.jsx";
import { ScriptsTab } from "./ScriptsTab.jsx";
import { TriggersTab } from "./TriggersTab.jsx";
import { YamlTab } from "./YamlTab.jsx";
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
export function useYamlPreview(content) {
return useMemo(() => {
try {
const parsedYaml = parseYaml(content);
return {
parsed: parsedYaml,
parseError: null,
mermaid: workflowToFlowchart(parsedYaml),
};
} catch (err) {
return {
parsed: null,
parseError: err instanceof Error ? err.message : String(err),
mermaid: { chart: "", scriptIds: {} },
};
}
}, [content]);
}
export function WorkflowVisualEditor({
yaml,
onYamlChange,
@@ -39,16 +25,19 @@ export function WorkflowVisualEditor({
testOpen,
onTestClose,
}) {
const [tab, setTab] = useState("scripts");
const [tab, setTab] = useState("graph");
const [lastEdited, setLastEdited] = useState("yaml");
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
const [trySession, setTrySession] = useState(emptyTrySession);
const [tryFocusUiId, setTryFocusUiId] = useState(/** @type {string | null} */ (null));
const { parsed, parseError, mermaid } = useYamlPreview(yaml);
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
const visualDisabled = !displayDoc;
const parseError = parsedDoc.parseError;
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();
@@ -57,6 +46,38 @@ export function WorkflowVisualEditor({
const scriptCount = displayDoc?.scripts?.length ?? 0;
const triggerCount = displayDoc?.triggers?.length ?? 0;
const unsaved = savedYaml != null && yaml !== savedYaml;
const trySessionCount = Object.keys(trySession.byStep ?? {}).length;
useEffect(() => {
setTrySession((prev) => {
const next = pruneTrySession(prev, displayDoc?.scripts ?? []);
const prevKeys = Object.keys(prev.byStep ?? {});
const nextKeys = Object.keys(next.byStep ?? {});
if (
prevKeys.length === nextKeys.length &&
prevKeys.every((k) => next.byStep[k]) &&
prev.lastTriedUiId === next.lastTriedUiId
) {
return prev;
}
return next;
});
const ids = new Set((displayDoc?.scripts ?? []).map((s) => s.uiId));
setTryFocusUiId((cur) => (cur && !ids.has(cur) ? null : cur));
}, [displayDoc?.scripts]);
function onTrySuccess(uiId, result) {
if (!uiId) return;
setTrySession((prev) => recordTrySuccess(prev, uiId, result));
}
function onTryFocus(uiId) {
setTryFocusUiId(uiId ?? null);
}
function clearTrySession() {
setTrySession(emptyTrySession());
}
function patchDoc(mutator) {
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
@@ -90,9 +111,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(() => {
@@ -106,12 +135,30 @@ export function WorkflowVisualEditor({
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
const graphProps = {
doc: displayDoc,
onPatch: patchDoc,
disabled: visualDisabled,
scripts,
profiles,
workflows,
owner,
file,
excludeFile: file,
auths,
pages,
trySession,
onTrySuccess,
tryFocusUiId,
onTryFocus,
};
return (
<>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{extraChrome}
<input
className="input input-sm sm:max-w-xs"
<FormInput
className="sm:max-w-xs"
placeholder="name"
value={displayDoc?.name ?? ""}
disabled={visualDisabled}
@@ -121,8 +168,8 @@ export function WorkflowVisualEditor({
})
}
/>
<textarea
className="textarea textarea-sm min-h-10 w-full sm:max-w-md"
<FormTextarea
className="min-h-10 w-full sm:max-w-md"
placeholder="description"
rows={1}
value={displayDoc?.description ?? ""}
@@ -134,9 +181,27 @@ export function WorkflowVisualEditor({
}
/>
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
{trySessionCount > 0 ? (
<button
type="button"
className="btn btn-ghost btn-xs"
title="Clear Try session (cached step outputs for seeding)"
onClick={clearTrySession}
>
Clear try session ({trySessionCount})
</button>
) : null}
</div>
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
<button
type="button"
role="tab"
className={`tab ${tab === "graph" ? "tab-active" : ""}`}
onClick={() => selectTab("graph")}
>
Graph
</button>
<button
type="button"
role="tab"
@@ -169,15 +234,21 @@ export function WorkflowVisualEditor({
</p>
) : null}
{tab === "graph" ? <GraphTab {...graphProps} /> : null}
{tab === "scripts" ? (
<ScriptsTab
doc={displayDoc}
onPatch={patchDoc}
disabled={visualDisabled}
scripts={scripts}
profiles={profiles}
workflows={workflows}
owner={owner}
excludeFile={file}
trySession={trySession}
onTrySuccess={onTrySuccess}
tryFocusUiId={tryFocusUiId}
onTryFocus={onTryFocus}
/>
) : null}
{tab === "triggers" ? (
@@ -193,13 +264,7 @@ export function WorkflowVisualEditor({
/>
) : null}
{tab === "yaml" ? (
<YamlTab
content={yaml}
onChange={onYamlTabChange}
parseError={parseError}
mermaid={mermaid}
parsed={parsed}
/>
<YamlTab content={yaml} onChange={onYamlTabChange} parseError={parseError} />
) : null}
{showTest && owner && file ? (
@@ -1,20 +1,12 @@
import { CodeEditor } from "../CodeEditor.jsx";
import { MermaidDiagram } from "../MermaidDiagram.jsx";
export function YamlTab({ content, onChange, parseError, mermaid, parsed }) {
export function YamlTab({ content, onChange, parseError }) {
return (
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
<div className="min-h-0">
<div className="flex min-h-0 flex-1 flex-col gap-2">
{parseError ? <p className="text-error text-sm shrink-0">{parseError}</p> : null}
<div className="min-h-0 flex-1">
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
</div>
<div className="min-h-0 overflow-auto">
{parseError ? (
<p className="text-error text-sm">{parseError}</p>
) : (
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
)}
{parsed?.name ? <p className="text-sm opacity-70 mt-2">{parsed.name}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { Handle, Position } from "@xyflow/react";
import { ScriptIcon } from "../../ScriptIcon.jsx";
export function TriggerGraphNode({ data, selected }) {
return (
<div
className={`relative min-w-44 max-w-56 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
selected ? "border-primary ring-2 ring-primary/40" : "border-base-300"
}`}
>
<p className="text-[10px] uppercase tracking-wide opacity-60">Trigger</p>
<p className="font-semibold text-sm leading-tight">{data.triggerType || "HTTP"}</p>
<p className="font-mono text-[11px] opacity-70 truncate" title={data.label}>
{data.label}
</p>
<Handle type="source" position={Position.Right} isConnectable={false} className="!h-3 !w-3 opacity-40" />
</div>
);
}
export function StepGraphNode({ data, selected }) {
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
const iconScript = data.iconScript || "";
return (
<div
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
data.kind === "set"
? "border-secondary"
: data.profile
? "border-accent"
: "border-primary"
} ${selected ? "ring-2 ring-primary/40" : ""}`}
>
<Handle
type="target"
position={Position.Left}
id="in"
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
isConnectable
/>
<div className="flex items-start gap-2 min-w-0">
{data.kind !== "set" && iconScript ? (
<ScriptIcon
name={iconScript}
hasIcon={data.hasIcon}
className="size-7 shrink-0"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
{data.stepId ? (
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
) : null}
{data.when ? (
<span className="badge badge-warning badge-xs" title={data.when}>
when
</span>
) : null}
{data.needsMode === "map" ? (
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
map
</span>
) : null}
</div>
<p
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
title={data.label}
>
{data.label}
</p>
{data.missingNeeds?.length ? (
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
id="out"
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
isConnectable
/>
</div>
);
}