diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 1348da9..2e169c7 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -9,6 +9,7 @@ import { ScriptsPage } from "./pages/ScriptsPage.jsx"; import { ScriptDryRunPage } from "./pages/ScriptDryRunPage.jsx"; import { ScriptEditPage, ScriptNewPage } from "./pages/ScriptEditPage.jsx"; import { WorkflowsPage } from "./pages/WorkflowsPage.jsx"; +import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"; import { EventsPage } from "./pages/EventsPage.jsx"; import { EventDetailPage } from "./pages/EventDetailPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx"; @@ -51,6 +52,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> {user.role === "admin" ? ( diff --git a/packages/web/src/lib/workflow-mermaid.js b/packages/web/src/lib/workflow-mermaid.js index e6410c5..9876a67 100644 --- a/packages/web/src/lib/workflow-mermaid.js +++ b/packages/web/src/lib/workflow-mermaid.js @@ -1,12 +1,3 @@ -function sanitize(label) { - const cleaned = String(label ?? "") - .replace(/[^A-Za-z0-9]+/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, "") - .slice(0, 48); - return cleaned || "node"; -} - function parseStep(step) { if (typeof step === "string") { return { script: step, id: null, needs: null }; @@ -32,27 +23,35 @@ function parentIdsFromNeeds(needs) { return []; } -export function workflowToArchitecture(parsed) { +function mermaidLabel(text) { + return String(text ?? "") + .replace(/"/g, "#quot;") + .replace(/[\[\]]/g, " ") + .trim() || "node"; +} + +function triggerLabel(t) { + const type = String(t?.type ?? "").toLowerCase(); + if (type === "cron") return `cron ${t.schedule ?? ""}`.trim(); + const method = t?.method ?? "POST"; + const path = t?.path ?? ""; + return `${method} ${path}`.trim(); +} + +export function workflowToFlowchart(parsed) { /** @type {Record} */ const scriptIds = {}; if (!parsed || typeof parsed !== "object") { return { chart: "", scriptIds }; } - const groupId = "wf"; - const name = sanitize(parsed.name || "workflow"); - const lines = ["architecture-beta", ` group ${groupId}[${name}]`]; + const lines = ["flowchart LR"]; const triggerIds = []; const triggers = Array.isArray(parsed.triggers) ? parsed.triggers : []; triggers.forEach((t, i) => { const id = `t${i}`; - const icon = t?.type === "cron" ? "cloud" : "internet"; - const label = - t?.type === "cron" - ? `cron ${t.schedule ?? ""}` - : `${t?.method ?? "POST"} ${t?.path ?? ""}`; - lines.push(` service ${id}(${icon})[${sanitize(label)}] in ${groupId}`); + lines.push(` ${id}(["${mermaidLabel(triggerLabel(t))}"])`); triggerIds.push(id); }); @@ -74,9 +73,8 @@ export function workflowToArchitecture(parsed) { for (const s of parsedSteps) { scriptIds[s.mermaidId] = s.script; - lines.push( - ` service ${s.mermaidId}(server)[${sanitize(s.id || s.script)}] in ${groupId}`, - ); + const label = s.id ? `${s.id}: ${s.script}` : s.script; + lines.push(` ${s.mermaidId}["${mermaidLabel(label)}"]`); } if (dagMode) { @@ -89,12 +87,12 @@ export function workflowToArchitecture(parsed) { } for (const pid of parents) { const from = idToMermaid[pid]; - if (from) lines.push(` ${from}:R -- L:${s.mermaidId}`); + if (from) lines.push(` ${from} --> ${s.mermaidId}`); } } for (const tid of triggerIds) { for (const rid of roots) { - lines.push(` ${tid}:R -- L:${rid}`); + lines.push(` ${tid} --> ${rid}`); } } } else { @@ -102,18 +100,18 @@ export function workflowToArchitecture(parsed) { let firstScript = null; for (const s of parsedSteps) { if (!firstScript) firstScript = s.mermaidId; - if (prev) lines.push(` ${prev}:R -- L:${s.mermaidId}`); + if (prev) lines.push(` ${prev} --> ${s.mermaidId}`); prev = s.mermaidId; } if (firstScript) { for (const tid of triggerIds) { - lines.push(` ${tid}:R -- L:${firstScript}`); + lines.push(` ${tid} --> ${firstScript}`); } } } - if (triggerIds.length >= 2) { - lines.push(` align column ${triggerIds.join(" ")}`); + if (lines.length === 1) { + return { chart: "", scriptIds }; } return { chart: lines.join("\n"), scriptIds }; diff --git a/packages/web/src/pages/HomePage.jsx b/packages/web/src/pages/HomePage.jsx index c8cd838..6b515ac 100644 --- a/packages/web/src/pages/HomePage.jsx +++ b/packages/web/src/pages/HomePage.jsx @@ -70,7 +70,7 @@ export function HomePage() {
  • {w.key}: {w.loadError} diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx new file mode 100644 index 0000000..bc462a4 --- /dev/null +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { parse as parseYaml } from "yaml"; +import { LuArrowLeft, LuSave } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { useOwners, useSaveWorkflow, useWorkflow } from "../api/hooks.js"; +import { CodeEditor } from "../components/CodeEditor.jsx"; +import { MermaidDiagram } from "../components/MermaidDiagram.jsx"; +import { workflowToFlowchart } from "../lib/workflow-mermaid.js"; + +const NEW_YAML = `name: new workflow +scripts: + - get-current-time.js +triggers: + - type: HTTP + method: POST + path: /new +`; + +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]); +} + +function WorkflowEditorLayout({ + title, + backTo = "/workflows", + savePending, + saveDisabled, + saveError, + saveSuccess, + formId, + children, +}) { + return ( +
    +
    + + + +

    {title}

    +
    + +
    + {children} + {saveError ? ( +

    {saveError}

    + ) : null} + {saveSuccess ? ( +

    Workflow saved

    + ) : null} +
    + ); +} + +function WorkflowYamlAndDiagram({ content, onChange, parseError, mermaid, parsed }) { + return ( +
    +
    + +
    +
    + {parseError ? ( +

    {parseError}

    + ) : ( + + )} + {parsed?.name ? ( +

    {parsed.name}

    + ) : null} +
    +
    + ); +} + +export function WorkflowNewPage() { + const navigate = useNavigate(); + const { data: owners = [] } = useOwners(); + const [owner, setOwner] = useState(""); + const [file, setFile] = useState(""); + const [content, setContent] = useState(NEW_YAML); + const save = useSaveWorkflow(); + const { parsed, parseError, mermaid } = useYamlPreview(content); + + useEffect(() => { + if (!owner && owners[0]) setOwner(owners[0]); + }, [owner, owners]); + + function onSave(e) { + e.preventDefault(); + const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`; + save.mutate( + { owner, file: yamlFile, content }, + { + onSuccess: () => + navigate( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(yamlFile)}/edit`, + ), + }, + ); + } + + return ( + +
    +
    + setOwner(e.target.value)} + required + /> + setFile(e.target.value)} + required + /> +
    + + +
    + ); +} + +export function WorkflowEditPage() { + const { owner: rawOwner, file: rawFile } = useParams(); + const owner = decodeURIComponent(rawOwner ?? ""); + const file = decodeURIComponent(rawFile ?? ""); + const existing = useWorkflow(owner, file); + const save = useSaveWorkflow(); + const [content, setContent] = useState(""); + const [contentReady, setContentReady] = useState(false); + const { parsed, parseError, mermaid } = useYamlPreview(content); + + useEffect(() => { + if (existing.isLoading) return; + if (existing.data?.content != null) { + setContent(existing.data.content); + setContentReady(true); + } + }, [existing.data, existing.isLoading]); + + function onSave(e) { + e.preventDefault(); + save.mutate({ owner, file, content }); + } + + if (existing.isLoading) { + return ( +
    + +
    + ); + } + + if (existing.isError) { + return ( +
    + + + Back + +

    Workflow not found

    +
    + ); + } + + return ( + +
    + + +
    + ); +} diff --git a/packages/web/src/pages/WorkflowsPage.jsx b/packages/web/src/pages/WorkflowsPage.jsx index 3a9f393..17dc895 100644 --- a/packages/web/src/pages/WorkflowsPage.jsx +++ b/packages/web/src/pages/WorkflowsPage.jsx @@ -1,127 +1,38 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; -import { parse as parseYaml } from "yaml"; -import { - LuActivity, - LuPencil, - LuPlay, - LuPlus, - LuRefreshCw, - LuSave, - LuTrash2, - LuX, -} from "react-icons/lu"; +import { useState } from "react"; +import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2 } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { useDeleteWorkflow, - useOwners, useReregisterWorkflows, useRunWorkflow, - useSaveWorkflow, - useWorkflow, useWorkflows, } from "../api/hooks.js"; -import { CodeEditor } from "../components/CodeEditor.jsx"; -import { MermaidDiagram } from "../components/MermaidDiagram.jsx"; import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx"; -import { workflowToArchitecture } from "../lib/workflow-mermaid.js"; - -const NEW_YAML = `name: new workflow -scripts: - - get-current-time.js -triggers: - - type: HTTP - method: POST - path: /new -`; export function WorkflowsPage() { const navigate = useNavigate(); - const [params, setParams] = useSearchParams(); + const [params] = useSearchParams(); const editParam = params.get("edit"); const { data: workflows = [], isLoading } = useWorkflows(); - const { data: owners = [] } = useOwners(); - const [mode, setMode] = useState(null); - const [owner, setOwner] = useState("default"); - const [file, setFile] = useState(""); - const [content, setContent] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); const [runError, setRunError] = useState(null); - - const existing = useWorkflow( - mode === "edit" ? owner : null, - mode === "edit" ? file : null, - mode === "edit", - ); - const save = useSaveWorkflow(); const del = useDeleteWorkflow(); const run = useRunWorkflow(); const reregister = useReregisterWorkflows(); - useEffect(() => { - if (!editParam) return; + if (editParam) { const slash = editParam.indexOf("/"); - if (slash === -1) return; - setMode("edit"); - setOwner(editParam.slice(0, slash)); - setFile(editParam.slice(slash + 1)); - }, [editParam]); - - useEffect(() => { - if (mode === "edit" && existing.data?.content != null) { - setContent(existing.data.content); + if (slash !== -1) { + const owner = editParam.slice(0, slash); + const file = editParam.slice(slash + 1); + return ( + + ); } - }, [mode, existing.data]); - - const { parsed, parseError, mermaid } = useMemo(() => { - try { - const parsedYaml = parseYaml(content); - const arch = workflowToArchitecture(parsedYaml); - return { parsed: parsedYaml, parseError: null, mermaid: arch }; - } catch (err) { - return { - parsed: null, - parseError: err instanceof Error ? err.message : String(err), - mermaid: { chart: "", scriptIds: {} }, - }; - } - }, [content]); - - function openAdd() { - setMode("add"); - setOwner(owners[0] || "default"); - setFile(""); - setContent(NEW_YAML); - setParams({}); - } - - function openEdit(w) { - setMode("edit"); - setOwner(w.owner); - setFile(w.file); - setParams({ edit: `${w.owner}/${w.file}` }); - } - - function closeForm() { - setMode(null); - setFile(""); - setContent(""); - setParams({}); - } - - function onSave(e) { - e.preventDefault(); - const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`; - save.mutate( - { owner, file: yamlFile, content }, - { - onSuccess: () => { - setMode("edit"); - setFile(yamlFile); - setParams({ edit: `${owner}/${yamlFile}` }); - }, - }, - ); } function onRun(w) { @@ -145,9 +56,7 @@ export function WorkflowsPage() { } const runningKey = - run.isPending && run.variables - ? `${run.variables.owner}/${run.variables.file}` - : null; + run.isPending && run.variables ? `${run.variables.owner}/${run.variables.file}` : null; return (
    @@ -168,10 +77,10 @@ export function WorkflowsPage() { )} Reregister - +
    @@ -200,7 +109,14 @@ export function WorkflowsPage() { {workflows.map((w) => ( - {w.name} + + + {w.name} + + {w.owner} @@ -231,14 +147,13 @@ export function WorkflowsPage() { > - + - - {mode === "add" ? ( -
    - setOwner(e.target.value)} - required - /> - setFile(e.target.value)} - required - /> -
    - ) : null} - {mode === "edit" && existing.isLoading ? ( - - ) : ( -
    - -
    - {parseError ? ( -

    {parseError}

    - ) : ( - - )} - {parsed?.name ? ( -

    {parsed.name}

    - ) : null} -
    -
    - )} - {save.isError ? ( -

    {errorMessage(save.error)}

    - ) : null} - - - ) : null} - {confirmDelete ? (
    @@ -331,14 +188,7 @@ export function WorkflowsPage() { onClick={() => del.mutate( { owner: confirmDelete.owner, file: confirmDelete.file }, - { - onSuccess: () => { - if (owner === confirmDelete.owner && file === confirmDelete.file) { - closeForm(); - } - setConfirmDelete(null); - }, - }, + { onSuccess: () => setConfirmDelete(null) }, ) } >