This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user