Files
jerapah-flow/packages/web/src/pages/WorkflowsPage.jsx
T
nsrb cf4b342e04 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.
2026-08-15 23:00:16 +07:00

295 lines
10 KiB
React

import { useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteWorkflow,
useReregisterWorkflows,
useRunWorkflow,
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";
export function WorkflowsPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
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();
const setEnabled = useSetWorkflowEnabled();
const reregister = useReregisterWorkflows();
if (editParam) {
const slash = editParam.indexOf("/");
if (slash !== -1) {
const owner = editParam.slice(0, slash);
const file = editParam.slice(slash + 1);
return (
<Navigate
to={`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/edit`}
replace
/>
);
}
}
function onRun(w) {
setRunError(null);
run.mutate(
{ owner: w.owner, file: w.file },
{
onSuccess: (data) => {
if (data?.runId) navigate(`/events/${data.runId}`);
},
onError: (err) => {
const runId = err?.response?.data?.runId;
if (runId) {
navigate(`/events/${runId}`);
return;
}
setRunError(`${w.key}: ${errorMessage(err)}`);
},
},
);
}
const runningKey =
run.isPending && run.variables ? `${run.variables.owner}/${run.variables.file}` : null;
const togglingKey =
setEnabled.isPending && setEnabled.variables
? `${setEnabled.variables.owner}/${setEnabled.variables.file}`
: null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Workflows</h1>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reregister workflows"
disabled={reregister.isPending}
onClick={() => reregister.mutate()}
>
{reregister.isPending ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuRefreshCw className="size-4" />
)}
Reregister
</button>
<Link to="/workflows/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" />
Add
</Link>
</div>
</div>
{reregister.isError ? (
<p className="text-error text-sm">{errorMessage(reregister.error)}</p>
) : null}
{runError ? <p className="text-error text-sm">{runError}</p> : null}
{setEnabled.isError ? (
<p className="text-error text-sm">{errorMessage(setEnabled.error)}</p>
) : null}
{isLoading ? (
<span className="loading loading-spinner" />
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Status</th>
<th>Triggers</th>
<th>Last run</th>
<th>Runs</th>
<th />
</tr>
</thead>
<tbody>
{workflows.map((w) => (
<tr key={w.key} className="hover">
<td>
<Link
className="link inline-flex items-center gap-2"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
<WorkflowFileIcon className="size-7 shrink-0" />
{w.name}
</Link>
{!w.registered ? (
<span
className="text-warning ml-1 inline-flex align-middle"
title="Not listed in registers.yaml"
>
<LuTriangleAlert className="size-3.5" />
</span>
) : null}
</td>
<td className="font-mono text-xs">{w.owner}</td>
<td>
<WorkflowStatusBadge workflow={w} />
</td>
<td>
<TriggerList triggers={w.triggers} />
</td>
<td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td>
<td>{w.invocationCount}</td>
<td className="text-right whitespace-nowrap">
<label className="inline-flex items-center mr-1" title={w.enabled ? "Disable" : "Enable"}>
<input
type="checkbox"
className="toggle toggle-xs"
checked={Boolean(w.enabled)}
disabled={Boolean(w.loadError) || togglingKey === w.key}
onChange={() =>
setEnabled.mutate({
owner: w.owner,
file: w.file,
enabled: !w.enabled,
})
}
/>
</label>
<button
type="button"
className="btn btn-ghost btn-xs"
title="Run"
disabled={Boolean(w.loadError) || runningKey === w.key}
onClick={() => onRun(w)}
>
{runningKey === w.key ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuPlay className="size-4" />
)}
</button>
<Link
className="btn btn-ghost btn-xs"
title="Events"
to={`/events?workflow=${encodeURIComponent(w.key)}`}
>
<LuActivity className="size-4" />
</Link>
<Link
className="btn btn-ghost btn-xs"
title="Edit"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
<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"
title="Delete"
onClick={() => setConfirmDelete(w)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</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">
<h3 className="font-bold">Delete {confirmDelete.key}?</h3>
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
Cancel
</button>
<button
type="button"
className="btn btn-error"
disabled={del.isPending}
onClick={() =>
del.mutate(
{ owner: confirmDelete.owner, file: confirmDelete.file },
{ onSuccess: () => setConfirmDelete(null) },
)
}
>
Delete
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setConfirmDelete(null)}>
close
</button>
</form>
</dialog>
) : null}
</div>
);
}
function triggerLabel(t) {
const type = String(t?.type ?? "").toLowerCase();
if (type === "cron") return t.schedule || "cron";
if (type === "http") return t.path || "/";
if (type === "workflow") return "";
return t?.type ?? "—";
}
function triggerKind(t) {
const type = String(t?.type ?? "").toLowerCase();
if (type === "http") return t.method || "POST";
if (type === "workflow") return "workflow";
return t?.type ?? "—";
}
function TriggerList({ triggers }) {
if (!triggers?.length) return <span className="opacity-50">—</span>;
return (
<ul className="space-y-0.5">
{triggers.map((t, i) => (
<li key={i} className="font-mono text-xs whitespace-nowrap">
<span className="opacity-60">{triggerKind(t)}</span> {triggerLabel(t)}
</li>
))}
</ul>
);
}