- Added `workflowLastModifiedAt` function to retrieve the last modified timestamp of workflow YAML files. - Introduced `encodeBinaryForWire` and `reviveBinaryFromWire` functions for better handling of binary data in JSON. - Implemented `useDeleteKv` hook for deleting key-value pairs in the web API. - Enhanced `scriptsPluginFactory` to support dry-run evaluations with JSONata expressions. - Added `set-dry-run-smoke.js` test to validate dry-run functionality. - Updated profile configuration to include `overlayFromMerged` for better profile management. - Introduced try-session management in the web components to handle step execution states. - Improved various components to support new try-session features and maintain UI consistency. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
393 lines
14 KiB
React
393 lines
14 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 { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
|
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
|
import { formatTime, WorkflowStatusBadge } from "../lib/format";
|
|
|
|
const SORT_COLUMNS = [
|
|
{ key: "name", label: "Name", defaultOrder: "asc" },
|
|
{ key: "owner", label: "Owner", defaultOrder: "asc" },
|
|
{ key: "status", label: "Status", defaultOrder: "asc" },
|
|
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
|
|
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
|
|
{ key: "invocationCount", label: "Runs", defaultOrder: "desc" },
|
|
];
|
|
|
|
function SortHeader({ column, label, sort, order, onSort }) {
|
|
const active = sort === column;
|
|
return (
|
|
<th>
|
|
<button
|
|
type="button"
|
|
className={`font-semibold hover:underline ${active ? "" : "opacity-80"}`}
|
|
onClick={() => onSort(column)}
|
|
>
|
|
{label}
|
|
{active ? (order === "asc" ? " ↑" : " ↓") : ""}
|
|
</button>
|
|
</th>
|
|
);
|
|
}
|
|
|
|
function statusSortKey(w) {
|
|
if (w.loadError) return "0-broken";
|
|
if (!w.enabled) return "1-disabled";
|
|
if (w.lastStatus === "failed") return "2-failed";
|
|
if (w.lastStatus === "queued") return "3-queued";
|
|
if (w.lastStatus === "running") return "4-running";
|
|
if (w.lastStatus === "success") return "5-working";
|
|
return "6-never";
|
|
}
|
|
|
|
function compareName(a, b) {
|
|
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
|
|
sensitivity: "base",
|
|
});
|
|
if (byName !== 0) return byName;
|
|
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
|
|
}
|
|
|
|
function compareWorkflows(a, b, sort) {
|
|
if (sort === "name") return compareName(a, b);
|
|
if (sort === "owner") {
|
|
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
|
|
sensitivity: "base",
|
|
});
|
|
return byOwner !== 0 ? byOwner : compareName(a, b);
|
|
}
|
|
if (sort === "status") {
|
|
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
|
|
return byStatus !== 0 ? byStatus : compareName(a, b);
|
|
}
|
|
if (sort === "invocationCount") {
|
|
const byCount = (Number(a.invocationCount) || 0) - (Number(b.invocationCount) || 0);
|
|
return byCount !== 0 ? byCount : compareName(a, b);
|
|
}
|
|
const aTime = a[sort] ?? "";
|
|
const bTime = b[sort] ?? "";
|
|
const byTime = String(aTime).localeCompare(String(bTime));
|
|
return byTime !== 0 ? byTime : compareName(a, b);
|
|
}
|
|
|
|
export function WorkflowsPage() {
|
|
const navigate = useNavigate();
|
|
const [params] = useSearchParams();
|
|
const editParam = params.get("edit");
|
|
const { data: workflows = [], isLoading } = useWorkflows();
|
|
const [sort, setSortColumn] = useState("name");
|
|
const [order, setOrder] = useState("asc");
|
|
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;
|
|
|
|
const dir = order === "desc" ? -1 : 1;
|
|
const sortedWorkflows = [...workflows].sort((a, b) => dir * compareWorkflows(a, b, sort));
|
|
|
|
function setSort(column) {
|
|
const spec = SORT_COLUMNS.find((c) => c.key === column);
|
|
if (sort === column) {
|
|
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
|
return;
|
|
}
|
|
setSortColumn(column);
|
|
setOrder(spec?.defaultOrder ?? "asc");
|
|
}
|
|
|
|
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">
|
|
<Link to="/workflows/trash" className="btn btn-ghost btn-sm">
|
|
<LuTrash2 className="size-4" />
|
|
Trash
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
title="Reregister workflows"
|
|
aria-label="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>
|
|
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
|
|
<SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
|
|
<SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
|
|
<th>Triggers</th>
|
|
<SortHeader
|
|
column="lastModifiedAt"
|
|
label="Last modified"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={setSort}
|
|
/>
|
|
<SortHeader
|
|
column="lastInvokedAt"
|
|
label="Last run"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={setSort}
|
|
/>
|
|
<SortHeader
|
|
column="invocationCount"
|
|
label="Runs"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={setSort}
|
|
/>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedWorkflows.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}
|
|
<span className="block font-mono text-xs opacity-50">{w.file}</span>
|
|
</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.lastModifiedAt)}</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"}
|
|
aria-label={w.enabled ? "Disable" : "Enable"}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="toggle toggle-success 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"
|
|
aria-label="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"
|
|
aria-label="Events"
|
|
to={`/events?workflow=${encodeURIComponent(w.key)}`}
|
|
>
|
|
<LuActivity className="size-4" />
|
|
</Link>
|
|
<Link
|
|
className="btn btn-ghost btn-xs"
|
|
title="Edit"
|
|
aria-label="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"
|
|
aria-label="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="Move to trash"
|
|
aria-label="Move to trash"
|
|
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}
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(confirmDelete)}
|
|
title={confirmDelete ? `Move ${confirmDelete.key} to trash?` : ""}
|
|
message="The workflow is removed from the list but kept in trash for 7 days. Revision history is preserved."
|
|
confirmLabel="Move to trash"
|
|
error={del.isError ? errorMessage(del.error) : null}
|
|
loading={del.isPending}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
onConfirm={() =>
|
|
del.mutate(
|
|
{ owner: confirmDelete.owner, file: confirmDelete.file },
|
|
{ onSuccess: () => setConfirmDelete(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>
|
|
);
|
|
}
|