feat(workflows): implement workflow duplication feature
- Added functionality to duplicate workflows via a new API endpoint, allowing users to create copies of existing workflows with modified names and owners. - Introduced utility functions for managing workflow filenames and ensuring unique names for duplicates. - Created a user interface component for duplicating workflows, including validation for existing files and owner changes. - Enhanced existing workflows to support the new duplication feature, ensuring seamless integration with the current workflow management system. This update significantly improves user experience by enabling efficient workflow management and organization.
This commit is contained in:
@@ -10,6 +10,11 @@ import {
|
||||
authLabel,
|
||||
validateWorkflowHttpTriggers,
|
||||
} from "../../workflow-http-validate.js";
|
||||
import {
|
||||
duplicateWorkflowYaml,
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
} from "../../workflow-duplicate.js";
|
||||
|
||||
function triggerSummary(owner, workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return [];
|
||||
@@ -259,6 +264,101 @@ export default function workflowsPluginFactory(registry) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/duplicate", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(owner);
|
||||
fsStore.assertWorkflowFile(file);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const source = fsStore.readWorkflowYaml(owner, file);
|
||||
if (source == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
|
||||
const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {});
|
||||
let destOwner = owner;
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
if (typeof body.owner !== "string") {
|
||||
return reply.code(400).send({ error: "owner must be a string" });
|
||||
}
|
||||
try {
|
||||
destOwner = fsStore.assertOwner(body.owner);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let destFile;
|
||||
try {
|
||||
if (body.file == null || body.file === "") {
|
||||
destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner));
|
||||
} else if (typeof body.file !== "string") {
|
||||
return reply.code(400).send({ error: "file must be a string" });
|
||||
} else {
|
||||
destFile = ensureWorkflowFilename(body.file);
|
||||
}
|
||||
fsStore.assertWorkflowFile(destFile);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
if (destOwner === owner && destFile === file) {
|
||||
return reply.code(400).send({ error: "cannot duplicate onto itself" });
|
||||
}
|
||||
if (fsStore.readWorkflowYaml(destOwner, destFile) != null) {
|
||||
return reply.code(409).send({ error: "workflow already exists" });
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = duplicateWorkflowYaml(source, {
|
||||
sourceFile: file,
|
||||
destFile,
|
||||
rewriteHttpPaths: destOwner === owner,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
compileWorkflowScripts(parsed?.scripts);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await validateWorkflowHttpTriggers(parsed);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
fsStore.writeWorkflowYaml(destOwner, destFile, content);
|
||||
const registered = fsStore.readRegisters(destOwner);
|
||||
if (!registered.includes(destFile)) {
|
||||
registered.push(destFile);
|
||||
fsStore.writeRegisters(destOwner, registered);
|
||||
}
|
||||
registry.reregister();
|
||||
return reply.code(201).send({ owner: destOwner, file: destFile });
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/run", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import yaml from "yaml";
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
export function workflowFileStem(file) {
|
||||
return String(file).replace(/\.ya?ml$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused copy filename: `track.yaml` → `track-copy.yaml`,
|
||||
* `track-copy.yaml` → `track-copy-2.yaml`.
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
|
||||
export function nextCopyName(name) {
|
||||
const trimmed = String(name ?? "").trim();
|
||||
if (!trimmed) return "copy";
|
||||
const match = trimmed.match(/^(.*) \(copy(?: (\d+))?\)$/);
|
||||
if (!match) return `${trimmed} (copy)`;
|
||||
const n = match[2] ? Number(match[2]) + 1 : 2;
|
||||
return `${match[1]} (copy ${n})`;
|
||||
}
|
||||
|
||||
export function httpPathCopySuffix(sourceFile, destFile) {
|
||||
const src = workflowFileStem(sourceFile);
|
||||
const dest = workflowFileStem(destFile);
|
||||
if (dest.startsWith(`${src}-`) && dest.length > src.length + 1) {
|
||||
return dest.slice(src.length + 1);
|
||||
}
|
||||
if (dest === src) return "copy";
|
||||
return dest || "copy";
|
||||
}
|
||||
|
||||
function suffixHttpPath(path, suffix) {
|
||||
const trimmed = String(path).replace(/\/+$/, "");
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const safe = String(suffix).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "copy";
|
||||
return `${withSlash}-${safe}`;
|
||||
}
|
||||
|
||||
function rewriteHttpTriggerPaths(doc, suffix) {
|
||||
const triggers = doc.get("triggers");
|
||||
if (!yaml.isSeq(triggers)) return;
|
||||
for (const item of triggers.items) {
|
||||
if (!yaml.isMap(item)) continue;
|
||||
if (String(item.get("type") ?? "").toLowerCase() !== "http") continue;
|
||||
const path = item.get("path");
|
||||
if (typeof path !== "string" || !path.trim()) continue;
|
||||
item.set("path", suffixHttpPath(path, suffix));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy workflow YAML: append " (copy)" to name, disable, optionally rewrite HTTP paths.
|
||||
* Preserves comments via YAML CST.
|
||||
* @param {string} content
|
||||
* @param {{ sourceFile: string, destFile: string, rewriteHttpPaths?: boolean }} opts
|
||||
*/
|
||||
export function duplicateWorkflowYaml(content, opts) {
|
||||
const sourceFile = opts?.sourceFile ?? "";
|
||||
const destFile = opts?.destFile ?? "";
|
||||
const rewriteHttpPaths = opts?.rewriteHttpPaths !== false;
|
||||
|
||||
const doc = yaml.parseDocument(content);
|
||||
if (doc.errors?.length) {
|
||||
const err = new Error(doc.errors[0]?.message ?? "invalid yaml");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const parsed = doc.toJSON();
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const err = new Error("workflow yaml must be an object");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const currentName = doc.get("name");
|
||||
if (typeof currentName === "string" && currentName.trim()) {
|
||||
doc.set("name", nextCopyName(currentName));
|
||||
} else {
|
||||
doc.set("name", workflowFileStem(destFile) || "copy");
|
||||
}
|
||||
doc.set("enabled", false);
|
||||
|
||||
if (rewriteHttpPaths) {
|
||||
rewriteHttpTriggerPaths(doc, httpPathCopySuffix(sourceFile, destFile));
|
||||
}
|
||||
|
||||
return String(doc);
|
||||
}
|
||||
@@ -2,24 +2,23 @@ name: Comic - monkeyuser to ntfy
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
url: "https://www.monkeyuser.com/"
|
||||
outputVar: "httpResponse"
|
||||
selector: ".comic img"
|
||||
url: https://www.monkeyuser.com/
|
||||
outputVar: comic
|
||||
selector: .comic img
|
||||
jsonata: |
|
||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.httpResponse.title,
|
||||
"message": data.httpResponse.title,
|
||||
"attach": data.httpResponse.url
|
||||
"title": data.comic.title,
|
||||
"message": data.comic.title,
|
||||
"attach": data.comic.url
|
||||
}
|
||||
- fetch-binary.js
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -9,3 +9,4 @@ scripts:
|
||||
- send-gmail.yaml
|
||||
- test-send-gmail.yaml
|
||||
- track.yaml
|
||||
- rss-devto-to-ntfy.yaml
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: RSS - selfh.st first item to ntfy (copy)
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss-rss-devto-to-ntfy
|
||||
enabled: false
|
||||
@@ -2,7 +2,7 @@ name: RSS - selfh.st first item to ntfy
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: "https://selfh.st/rss/"
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
@@ -19,8 +19,7 @@ scripts:
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.0",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||
"axios": "^1.11.0",
|
||||
"cronstrue": "^3.24.0",
|
||||
"mermaid": "^11.9.0",
|
||||
|
||||
@@ -199,6 +199,27 @@ export function useDeleteWorkflow() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
||||
(
|
||||
await api.post(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
||||
{
|
||||
...(destOwner ? { owner: destOwner } : {}),
|
||||
...(destFile ? { file: destFile } : {}),
|
||||
},
|
||||
)
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js";
|
||||
import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js";
|
||||
|
||||
const EMPTY_WORKFLOWS = [];
|
||||
|
||||
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows();
|
||||
const duplicate = useDuplicateWorkflow();
|
||||
const [destOwner, setDestOwner] = useState(source.owner);
|
||||
const [destFile, setDestFile] = useState(() => suggestCopyFilename(source.file));
|
||||
const fileTouched = useRef(false);
|
||||
|
||||
const existingFiles = useMemo(
|
||||
() => workflows.filter((w) => w.owner === destOwner).map((w) => w.file),
|
||||
[workflows, destOwner],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTouched.current) return;
|
||||
setDestFile(suggestCopyFilename(source.file, existingFiles));
|
||||
}, [source.file, existingFiles]);
|
||||
|
||||
const yamlFile = ensureWorkflowFilename(destFile);
|
||||
const sameAsSource = destOwner === source.owner && yamlFile === source.file;
|
||||
const exists = existingFiles.includes(yamlFile);
|
||||
const canSubmit = Boolean(destOwner && yamlFile) && !sameAsSource && !exists && !duplicate.isPending;
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
duplicate.mutate(
|
||||
{
|
||||
owner: source.owner,
|
||||
file: source.file,
|
||||
destOwner,
|
||||
destFile: yamlFile,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onDuplicated?.(data);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold">Duplicate {source.key}?</h3>
|
||||
<p className="mt-2 text-sm opacity-70">
|
||||
The copy starts disabled. HTTP paths are rewritten when staying under the same owner so
|
||||
triggers do not collide.
|
||||
</p>
|
||||
{warnUnsaved ? (
|
||||
<p className="text-warning mt-2 text-sm">The copy uses the last saved YAML, not unsaved edits.</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>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">File</span>
|
||||
<input
|
||||
className="input input-sm w-full font-mono"
|
||||
placeholder="file.yaml"
|
||||
value={destFile}
|
||||
onChange={(e) => {
|
||||
fileTouched.current = true;
|
||||
setDestFile(e.target.value);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{sameAsSource ? (
|
||||
<p className="text-error text-sm">Choose a different owner or filename.</p>
|
||||
) : exists ? (
|
||||
<p className="text-error text-sm">{destOwner}/{yamlFile} already exists.</p>
|
||||
) : null}
|
||||
{duplicate.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(duplicate.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={!canSubmit}>
|
||||
{duplicate.isPending ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : null}
|
||||
Duplicate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import JsonView from "@uiw/react-json-view";
|
||||
import { darkTheme } from "@uiw/react-json-view/dark";
|
||||
import { lightTheme } from "@uiw/react-json-view/light";
|
||||
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 }) {
|
||||
const { theme } = useTheme() ?? { theme: "light" };
|
||||
if (value === undefined) return null;
|
||||
|
||||
const isTree = value != null && typeof value === "object";
|
||||
|
||||
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>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,35 @@ triggers:
|
||||
path: /new
|
||||
`;
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused copy filename: `track.yaml` → `track-copy.yaml`,
|
||||
* `track-copy.yaml` → `track-copy-2.yaml`.
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
|
||||
let uidSeq = 0;
|
||||
|
||||
export function nextUiId(prefix = "ui") {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo, useState, Fragment } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
||||
import { useRun } from "../api/hooks.js";
|
||||
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||
import { prettyJson } from "../lib/script.js";
|
||||
|
||||
function stepLabel(s) {
|
||||
if (s.script === "set") return "set";
|
||||
@@ -19,22 +19,6 @@ function isEditableScript(s) {
|
||||
return Boolean(s.script) && s.script !== "set";
|
||||
}
|
||||
|
||||
function JsonBlock({ title, value }) {
|
||||
if (value == null || value === "") return null;
|
||||
const text = prettyJson(value);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<details 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">
|
||||
<pre className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 font-mono text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function envelopeParts(raw) {
|
||||
if (raw != null && typeof raw === "object" && !Array.isArray(raw) && ("output" in raw || "context" in raw)) {
|
||||
return {
|
||||
@@ -128,9 +112,9 @@ export function EventDetailPage() {
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<JsonBlock title="Run input (data)" value={run.input} />
|
||||
<JsonBlock title="Run output" value={runParts.output} />
|
||||
<JsonBlock title="Run context" value={runParts.context} />
|
||||
<JsonViewBlock title="Run input (data)" value={run.input} />
|
||||
<JsonViewBlock title="Run output" value={runParts.output} />
|
||||
<JsonViewBlock title="Run context" value={runParts.context} />
|
||||
</div>
|
||||
{runParts.skipRemaining ? (
|
||||
<p className="text-sm opacity-70">This run stopped early (<span className="font-mono">skipRemaining</span>).</p>
|
||||
@@ -201,8 +185,8 @@ export function EventDetailPage() {
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-base-200">
|
||||
<div className="grid gap-2 p-2 sm:grid-cols-2">
|
||||
<JsonBlock title="output" value={parts.output} />
|
||||
<JsonBlock title="context" value={parts.context} />
|
||||
<JsonViewBlock title="output" value={parts.output} />
|
||||
<JsonViewBlock title="context" value={parts.context} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useOwners,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useSetWorkflowEnabled,
|
||||
useWorkflow,
|
||||
} from "../api/hooks.js";
|
||||
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
|
||||
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||
@@ -21,6 +22,7 @@ function WorkflowEditorLayout({
|
||||
saveSuccess,
|
||||
onSave,
|
||||
onTest,
|
||||
onDuplicate,
|
||||
onToggleEnabled,
|
||||
enabled,
|
||||
enablePending,
|
||||
@@ -60,6 +62,12 @@ function WorkflowEditorLayout({
|
||||
Test
|
||||
</button>
|
||||
) : null}
|
||||
{onDuplicate ? (
|
||||
<button type="button" className="btn btn-sm" onClick={onDuplicate}>
|
||||
<LuCopy className="size-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
@@ -145,6 +153,7 @@ export function WorkflowNewPage() {
|
||||
}
|
||||
|
||||
export function WorkflowEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const { owner: rawOwner, file: rawFile } = useParams();
|
||||
const owner = decodeURIComponent(rawOwner ?? "");
|
||||
const file = decodeURIComponent(rawFile ?? "");
|
||||
@@ -155,6 +164,18 @@ export function WorkflowEditPage() {
|
||||
const [savedYaml, setSavedYaml] = useState("");
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const routeKey = `${owner}/${file}`;
|
||||
const [activeKey, setActiveKey] = useState(routeKey);
|
||||
|
||||
if (activeKey !== routeKey) {
|
||||
setActiveKey(routeKey);
|
||||
setContent("");
|
||||
setSavedYaml("");
|
||||
setContentReady(false);
|
||||
setTestOpen(false);
|
||||
setDuplicateOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.isLoading) return;
|
||||
@@ -200,38 +221,54 @@ export function WorkflowEditPage() {
|
||||
const pageTitle = workflowName ? `${workflowName} (${file})` : file;
|
||||
|
||||
return (
|
||||
<WorkflowEditorLayout
|
||||
title={pageTitle}
|
||||
savePending={save.isPending}
|
||||
saveDisabled={!contentReady}
|
||||
saveError={save.isError ? errorMessage(save.error) : null}
|
||||
saveSuccess={save.isSuccess}
|
||||
onSave={onSave}
|
||||
onTest={() => setTestOpen(true)}
|
||||
onToggleEnabled={() =>
|
||||
setEnabled.mutate({
|
||||
owner,
|
||||
file,
|
||||
enabled: existing.data?.parsed?.enabled === false,
|
||||
})
|
||||
}
|
||||
enabled={existing.data?.parsed?.enabled !== false}
|
||||
enablePending={setEnabled.isPending}
|
||||
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
|
||||
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<WorkflowVisualEditor
|
||||
yaml={content}
|
||||
onYamlChange={setContent}
|
||||
owner={owner}
|
||||
file={file}
|
||||
savedYaml={savedYaml}
|
||||
showTest
|
||||
testOpen={testOpen}
|
||||
onTestClose={() => setTestOpen(false)}
|
||||
<>
|
||||
<WorkflowEditorLayout
|
||||
title={pageTitle}
|
||||
savePending={save.isPending}
|
||||
saveDisabled={!contentReady}
|
||||
saveError={save.isError ? errorMessage(save.error) : null}
|
||||
saveSuccess={save.isSuccess}
|
||||
onSave={onSave}
|
||||
onTest={() => setTestOpen(true)}
|
||||
onDuplicate={() => setDuplicateOpen(true)}
|
||||
onToggleEnabled={() =>
|
||||
setEnabled.mutate({
|
||||
owner,
|
||||
file,
|
||||
enabled: existing.data?.parsed?.enabled === false,
|
||||
})
|
||||
}
|
||||
enabled={existing.data?.parsed?.enabled !== false}
|
||||
enablePending={setEnabled.isPending}
|
||||
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
|
||||
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<WorkflowVisualEditor
|
||||
yaml={content}
|
||||
onYamlChange={setContent}
|
||||
owner={owner}
|
||||
file={file}
|
||||
savedYaml={savedYaml}
|
||||
showTest
|
||||
testOpen={testOpen}
|
||||
onTestClose={() => setTestOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</WorkflowEditorLayout>
|
||||
{duplicateOpen ? (
|
||||
<DuplicateWorkflowDialog
|
||||
source={{ owner, file, key: `${owner}/${file}` }}
|
||||
warnUnsaved={contentReady && content !== savedYaml}
|
||||
onClose={() => setDuplicateOpen(false)}
|
||||
onDuplicated={(data) => {
|
||||
setDuplicateOpen(false);
|
||||
navigate(
|
||||
`/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WorkflowEditorLayout>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteWorkflow,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useSetWorkflowEnabled,
|
||||
useWorkflows,
|
||||
} from "../api/hooks.js";
|
||||
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function WorkflowsPage() {
|
||||
const editParam = params.get("edit");
|
||||
const { data: workflows = [], isLoading } = useWorkflows();
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [duplicateSource, setDuplicateSource] = useState(null);
|
||||
const [runError, setRunError] = useState(null);
|
||||
const del = useDeleteWorkflow();
|
||||
const run = useRunWorkflow();
|
||||
@@ -188,6 +190,15 @@ export function WorkflowsPage() {
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Duplicate"
|
||||
disabled={Boolean(w.loadError)}
|
||||
onClick={() => setDuplicateSource(w)}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
@@ -204,6 +215,19 @@ export function WorkflowsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{duplicateSource ? (
|
||||
<DuplicateWorkflowDialog
|
||||
source={duplicateSource}
|
||||
onClose={() => setDuplicateSource(null)}
|
||||
onDuplicated={(data) => {
|
||||
setDuplicateSource(null);
|
||||
navigate(
|
||||
`/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirmDelete ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
|
||||
Generated
+21
@@ -83,6 +83,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.84.0
|
||||
version: 5.101.4(react@19.2.8)
|
||||
'@uiw/react-json-view':
|
||||
specifier: 2.0.0-alpha.43
|
||||
version: 2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
axios:
|
||||
specifier: ^1.11.0
|
||||
version: 1.19.0
|
||||
@@ -200,6 +203,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@8.0.0':
|
||||
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -824,6 +830,13 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43':
|
||||
resolution: {integrity: sha512-PMj+6xRDCPbVGccfLUDhx7yGrmVBSeFx3py6bqDds4QIYScXo9GEgtTbWXn3gXpoehsmOu//6dveYTf872wu3Q==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.10.0'
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
|
||||
|
||||
@@ -2114,6 +2127,8 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/runtime@8.0.0': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -2652,6 +2667,12 @@ snapshots:
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
optionalDependencies:
|
||||
d3-selection: 3.0.0
|
||||
|
||||
Reference in New Issue
Block a user