feat(server): add RSS feed fetching functionality and enhance workflow management
- Introduced a new script for fetching RSS feeds, allowing workflows to process and transform feed data using JSONata. - Updated workflow configuration to include a new RSS feed script, enhancing the capabilities of existing workflows. - Enhanced the workflow management API to support enabling and disabling workflows dynamically. - Improved UI components to reflect the new workflow features, including enabling/disabling workflows and displaying their statuses. - Added support for wildcard filtering in workflow queries, improving the flexibility of workflow management.
This commit is contained in:
@@ -18,5 +18,8 @@
|
|||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"rss-parser": "^3.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ export function createRegistry(server) {
|
|||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
handler: async (req, reply) => {
|
handler: async (req, reply) => {
|
||||||
|
const entry = workflows.get(key);
|
||||||
|
if (!entry || entry.workflow?.enabled === false) {
|
||||||
|
return reply.code(404).send({ error: "workflow disabled" });
|
||||||
|
}
|
||||||
const result = await runWorkflow(
|
const result = await runWorkflow(
|
||||||
key,
|
key,
|
||||||
{ data: req.body },
|
{ data: req.body },
|
||||||
@@ -434,6 +438,10 @@ export function createRegistry(server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { owner, workflow } = entry;
|
const { owner, workflow } = entry;
|
||||||
|
if (workflow?.enabled === false && trigger.type !== "manual") {
|
||||||
|
log.debug({ workflow: key, trigger }, "skipping disabled workflow");
|
||||||
|
return { runId: null, status: "failed", error: "workflow disabled" };
|
||||||
|
}
|
||||||
const run = await store.startRun({
|
const run = await store.startRun({
|
||||||
owner,
|
owner,
|
||||||
workflow: key,
|
workflow: key,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getSecretPlaintext } from "./secrets-store.js";
|
|||||||
|
|
||||||
const hostRequire = createRequire(import.meta.url);
|
const hostRequire = createRequire(import.meta.url);
|
||||||
|
|
||||||
const ALLOWED_MODULES = new Set(["axios", "jsonata", "node-html-parser"]);
|
const ALLOWED_MODULES = new Set(["axios", "jsonata", "node-html-parser", "rss-parser"]);
|
||||||
|
|
||||||
const BUILTIN_NAMES = [
|
const BUILTIN_NAMES = [
|
||||||
"Infinity",
|
"Infinity",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import rssParser from "rss-parser";
|
||||||
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
|
function ensureDataObject(ctx) {
|
||||||
|
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||||
|
ctx.data = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewValue(value) {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
if (json.length <= 500) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeed(ctx) {
|
||||||
|
const url = ctx.config?.url ?? ctx.data?.url;
|
||||||
|
if (typeof url !== "string" || url.length === 0) {
|
||||||
|
throw new Error("fetch-rss-feed: url is required (ctx.config.url or ctx.data.url)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputVar = ctx.config?.outputVar;
|
||||||
|
const jsonataExpr = ctx.config?.jsonata;
|
||||||
|
|
||||||
|
if (jsonataExpr && (typeof outputVar !== "string" || outputVar.length === 0)) {
|
||||||
|
throw new Error("fetch-rss-feed: ctx.config.outputVar is required when jsonata is set");
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDataObject(ctx);
|
||||||
|
|
||||||
|
log.info({ url }, "fetch-rss-feed: fetching feed");
|
||||||
|
const parser = new rssParser({
|
||||||
|
customFields: {
|
||||||
|
item: [
|
||||||
|
["media:content", "mediaContent"],
|
||||||
|
["content:encoded", "contentEncoded"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const feed = await parser.parseURL(url);
|
||||||
|
const itemCount = Array.isArray(feed.items) ? feed.items.length : 0;
|
||||||
|
log.info(
|
||||||
|
{ title: feed.title, itemCount },
|
||||||
|
"fetch-rss-feed: fetch complete",
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.data.rssFeed = feed;
|
||||||
|
log.info(
|
||||||
|
{ title: feed.title, itemCount },
|
||||||
|
"fetch-rss-feed: saved rssFeed",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jsonataExpr) {
|
||||||
|
log.info({ outputVar, jsonata: jsonataExpr }, "fetch-rss-feed: evaluating jsonata");
|
||||||
|
const expression = jsonata(jsonataExpr);
|
||||||
|
const result = await expression.evaluate(feed);
|
||||||
|
ctx.data[outputVar] = result;
|
||||||
|
log.info(
|
||||||
|
{ outputVar, result: previewValue(result) },
|
||||||
|
"fetch-rss-feed: saved jsonata result",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRssFeed.meta = {
|
||||||
|
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
||||||
|
config: {
|
||||||
|
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
|
||||||
|
outputVar: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Required when jsonata is set; destination on ctx.data",
|
||||||
|
},
|
||||||
|
jsonata: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "JSONata applied to the parsed feed object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
url: { type: "string", required: false, description: "Feed URL when config.url is omitted" },
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
rssFeed: { type: "object", description: "Parsed RSS/Atom feed from rss-parser" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: {},
|
||||||
|
config: {
|
||||||
|
url: "https://selfh.st/rss/",
|
||||||
|
outputVar: "item",
|
||||||
|
jsonata: "items[0]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fetchRssFeed;
|
||||||
@@ -191,6 +191,43 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return reply.code(existed ? 200 : 201).send({ owner, file });
|
return reply.code(existed ? 200 : 201).send({ owner, file });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.patch("/workflows/:owner/:file", 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 body = /** @type {{ enabled?: unknown }} */ (req.body ?? {});
|
||||||
|
if (typeof body.enabled !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "enabled boolean is required" });
|
||||||
|
}
|
||||||
|
const content = fsStore.readWorkflowYaml(owner, file);
|
||||||
|
if (content == null) {
|
||||||
|
return reply.code(404).send({ error: "workflow not found" });
|
||||||
|
}
|
||||||
|
const doc = yaml.parseDocument(content);
|
||||||
|
if (doc.errors?.length) {
|
||||||
|
const msg = doc.errors[0]?.message ?? "invalid yaml";
|
||||||
|
return reply.code(400).send({ error: msg });
|
||||||
|
}
|
||||||
|
const parsed = doc.toJSON();
|
||||||
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return reply.code(400).send({ error: "workflow yaml must be an object" });
|
||||||
|
}
|
||||||
|
if (body.enabled) {
|
||||||
|
doc.delete("enabled");
|
||||||
|
} else {
|
||||||
|
doc.set("enabled", false);
|
||||||
|
}
|
||||||
|
fsStore.writeWorkflowYaml(owner, file, String(doc));
|
||||||
|
registry.reregister();
|
||||||
|
return { owner, file, enabled: body.enabled };
|
||||||
|
});
|
||||||
|
|
||||||
fastify.delete("/workflows/:owner/:file", async (req, reply) => {
|
fastify.delete("/workflows/:owner/:file", async (req, reply) => {
|
||||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||||
req.params
|
req.params
|
||||||
@@ -221,6 +258,17 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||||
}
|
}
|
||||||
const key = `${owner}/${file}`;
|
const key = `${owner}/${file}`;
|
||||||
|
if (fsStore.readWorkflowYaml(owner, file) == null) {
|
||||||
|
return reply.code(404).send({ error: "workflow not found" });
|
||||||
|
}
|
||||||
|
const registered = fsStore.readRegisters(owner);
|
||||||
|
if (!registered.includes(file)) {
|
||||||
|
registered.push(file);
|
||||||
|
fsStore.writeRegisters(owner, registered);
|
||||||
|
registry.reregister();
|
||||||
|
} else if (!registry.workflows.has(key) && !registry.loadErrors.has(key)) {
|
||||||
|
registry.reregister();
|
||||||
|
}
|
||||||
if (!registry.workflows.has(key)) {
|
if (!registry.workflows.has(key)) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: registry.loadErrors.get(key) ?? "workflow not loaded",
|
error: registry.loadErrors.get(key) ?? "workflow not loaded",
|
||||||
|
|||||||
@@ -190,7 +190,19 @@ export async function listRuns(filters = {}) {
|
|||||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
||||||
if (filters.owner) q = q.where("owner", filters.owner);
|
if (filters.owner) q = q.where("owner", filters.owner);
|
||||||
if (filters.workflow) q = q.where("workflow", filters.workflow);
|
if (filters.workflow) {
|
||||||
|
const key = String(filters.workflow);
|
||||||
|
if (key.includes("*")) {
|
||||||
|
const pattern = key
|
||||||
|
.replaceAll("\\", "\\\\")
|
||||||
|
.replaceAll("%", "\\%")
|
||||||
|
.replaceAll("_", "\\_")
|
||||||
|
.replaceAll("*", "%");
|
||||||
|
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||||
|
} else {
|
||||||
|
q = q.where("workflow", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (filters.status) q = q.where("status", filters.status);
|
if (filters.status) q = q.where("status", filters.status);
|
||||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
if (filters.before) q = q.where("started_at", "<", filters.before);
|
||||||
const rows = await q.limit(limit);
|
const rows = await q.limit(limit);
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ scripts:
|
|||||||
- fetch-devto.yaml
|
- fetch-devto.yaml
|
||||||
- comic-monkeyuser-to-ntfy.yaml
|
- comic-monkeyuser-to-ntfy.yaml
|
||||||
- time-and-comic-to-ntfy.yaml
|
- time-and-comic-to-ntfy.yaml
|
||||||
|
- rss-selfhst-to-ntfy.yaml
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: RSS - selfh.st first item to ntfy
|
||||||
|
scripts:
|
||||||
|
- script: fetch-rss-feed.js
|
||||||
|
config:
|
||||||
|
url: "https://selfh.st/rss/"
|
||||||
|
outputVar: item
|
||||||
|
jsonata: items[0]
|
||||||
|
- script: jsonata.js
|
||||||
|
config:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"title": item.title,
|
||||||
|
"message": item.contentSnippet & "\n" & item.link,
|
||||||
|
"attach": item.mediaContent.url ? item.mediaContent.url : (item.mediaContent.$ ? item.mediaContent.$.url : undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: https://ntfy.sh/scrunner
|
||||||
|
|
||||||
|
triggers:
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /selfhst-rss
|
||||||
@@ -164,6 +164,24 @@ export function useSaveWorkflow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetWorkflowEnabled() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, enabled }) =>
|
||||||
|
(
|
||||||
|
await api.patch(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||||
|
{ enabled },
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteWorkflow() {
|
export function useDeleteWorkflow() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -21,26 +21,59 @@ export function StatusBadge({ status }) {
|
|||||||
|
|
||||||
/** Config load + last-run health for the workflows list. */
|
/** Config load + last-run health for the workflows list. */
|
||||||
export function WorkflowStatusBadge({ workflow }) {
|
export function WorkflowStatusBadge({ workflow }) {
|
||||||
if (workflow.loadError) {
|
const badges = [];
|
||||||
return (
|
|
||||||
<span className="badge badge-error badge-sm" title={workflow.loadError}>
|
if (!workflow.registered) {
|
||||||
broken
|
badges.push(
|
||||||
</span>
|
<span
|
||||||
|
key="unregistered"
|
||||||
|
className="badge badge-warning badge-sm"
|
||||||
|
title="Not listed in registers.yaml; HTTP/cron triggers are not loaded"
|
||||||
|
>
|
||||||
|
unregistered
|
||||||
|
</span>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!workflow.enabled) {
|
|
||||||
return <span className="badge badge-ghost badge-sm">disabled</span>;
|
if (workflow.loadError) {
|
||||||
|
badges.push(
|
||||||
|
<span key="broken" className="badge badge-error badge-sm" title={workflow.loadError}>
|
||||||
|
broken
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else if (!workflow.enabled) {
|
||||||
|
badges.push(
|
||||||
|
<span key="disabled" className="badge badge-ghost badge-sm">
|
||||||
|
disabled
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else if (workflow.lastStatus === "failed") {
|
||||||
|
badges.push(
|
||||||
|
<span key="failed" className="badge badge-error badge-sm">
|
||||||
|
failed
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else if (workflow.lastStatus === "running") {
|
||||||
|
badges.push(
|
||||||
|
<span key="running" className="badge badge-warning badge-sm">
|
||||||
|
running
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else if (workflow.lastStatus === "success") {
|
||||||
|
badges.push(
|
||||||
|
<span key="working" className="badge badge-success badge-sm">
|
||||||
|
working
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else if (workflow.registered) {
|
||||||
|
badges.push(
|
||||||
|
<span key="never" className="badge badge-ghost badge-sm">
|
||||||
|
never run
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (workflow.lastStatus === "failed") {
|
|
||||||
return <span className="badge badge-error badge-sm">failed</span>;
|
return <span className="inline-flex flex-wrap gap-1">{badges}</span>;
|
||||||
}
|
|
||||||
if (workflow.lastStatus === "running") {
|
|
||||||
return <span className="badge badge-warning badge-sm">running</span>;
|
|
||||||
}
|
|
||||||
if (workflow.lastStatus === "success") {
|
|
||||||
return <span className="badge badge-success badge-sm">working</span>;
|
|
||||||
}
|
|
||||||
return <span className="badge badge-ghost badge-sm">never run</span>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function levelName(level) {
|
export function levelName(level) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function EventsPage() {
|
|||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full sm:max-w-sm"
|
className="input input-sm w-full sm:max-w-sm"
|
||||||
placeholder="workflow key"
|
placeholder="workflow key (* wildcard)"
|
||||||
value={workflow}
|
value={workflow}
|
||||||
onChange={(e) => update("workflow", e.target.value)}
|
onChange={(e) => update("workflow", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { LuArrowLeft, LuSave } from "react-icons/lu";
|
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import { useOwners, useSaveWorkflow, useWorkflow } from "../api/hooks.js";
|
import {
|
||||||
|
useOwners,
|
||||||
|
useRunWorkflow,
|
||||||
|
useSaveWorkflow,
|
||||||
|
useSetWorkflowEnabled,
|
||||||
|
useWorkflow,
|
||||||
|
} from "../api/hooks.js";
|
||||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||||
import { MermaidDiagram } from "../components/MermaidDiagram.jsx";
|
import { MermaidDiagram } from "../components/MermaidDiagram.jsx";
|
||||||
import { workflowToFlowchart } from "../lib/workflow-mermaid.js";
|
import { workflowToFlowchart } from "../lib/workflow-mermaid.js";
|
||||||
@@ -44,6 +50,15 @@ function WorkflowEditorLayout({
|
|||||||
saveError,
|
saveError,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
formId,
|
formId,
|
||||||
|
onRun,
|
||||||
|
runPending,
|
||||||
|
runDisabled,
|
||||||
|
runError,
|
||||||
|
onToggleEnabled,
|
||||||
|
enabled,
|
||||||
|
enablePending,
|
||||||
|
enableDisabled,
|
||||||
|
enableError,
|
||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +69,38 @@ function WorkflowEditorLayout({
|
|||||||
</Link>
|
</Link>
|
||||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{onToggleEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={enablePending || enableDisabled}
|
||||||
|
onClick={onToggleEnabled}
|
||||||
|
>
|
||||||
|
{enablePending ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : enabled ? (
|
||||||
|
<LuPause className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LuPlay className="size-4" />
|
||||||
|
)}
|
||||||
|
{enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onRun ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={runPending || runDisabled}
|
||||||
|
onClick={onRun}
|
||||||
|
>
|
||||||
|
{runPending ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : (
|
||||||
|
<LuPlay className="size-4" />
|
||||||
|
)}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
form={formId}
|
form={formId}
|
||||||
@@ -68,6 +115,12 @@ function WorkflowEditorLayout({
|
|||||||
{saveError ? (
|
{saveError ? (
|
||||||
<p className="text-error text-sm shrink-0">{saveError}</p>
|
<p className="text-error text-sm shrink-0">{saveError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{runError ? (
|
||||||
|
<p className="text-error text-sm shrink-0">{runError}</p>
|
||||||
|
) : null}
|
||||||
|
{enableError ? (
|
||||||
|
<p className="text-error text-sm shrink-0">{enableError}</p>
|
||||||
|
) : null}
|
||||||
{saveSuccess ? (
|
{saveSuccess ? (
|
||||||
<p className="text-success text-sm shrink-0">Workflow saved</p>
|
<p className="text-success text-sm shrink-0">Workflow saved</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -164,15 +217,33 @@ export function WorkflowNewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowEditPage() {
|
export function WorkflowEditPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { owner: rawOwner, file: rawFile } = useParams();
|
const { owner: rawOwner, file: rawFile } = useParams();
|
||||||
const owner = decodeURIComponent(rawOwner ?? "");
|
const owner = decodeURIComponent(rawOwner ?? "");
|
||||||
const file = decodeURIComponent(rawFile ?? "");
|
const file = decodeURIComponent(rawFile ?? "");
|
||||||
const existing = useWorkflow(owner, file);
|
const existing = useWorkflow(owner, file);
|
||||||
const save = useSaveWorkflow();
|
const save = useSaveWorkflow();
|
||||||
|
const run = useRunWorkflow();
|
||||||
|
const setEnabled = useSetWorkflowEnabled();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
||||||
|
|
||||||
|
function onRun() {
|
||||||
|
run.mutate(
|
||||||
|
{ owner, file },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data?.runId) navigate(`/events/${data.runId}`);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const runId = err?.response?.data?.runId;
|
||||||
|
if (runId) navigate(`/events/${runId}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.isLoading) return;
|
if (existing.isLoading) return;
|
||||||
if (existing.data?.content != null) {
|
if (existing.data?.content != null) {
|
||||||
@@ -214,6 +285,21 @@ export function WorkflowEditPage() {
|
|||||||
saveDisabled={!contentReady}
|
saveDisabled={!contentReady}
|
||||||
saveError={save.isError ? errorMessage(save.error) : null}
|
saveError={save.isError ? errorMessage(save.error) : null}
|
||||||
saveSuccess={save.isSuccess}
|
saveSuccess={save.isSuccess}
|
||||||
|
onRun={onRun}
|
||||||
|
runPending={run.isPending}
|
||||||
|
runDisabled={!contentReady || Boolean(parseError) || Boolean(existing.data?.loadError)}
|
||||||
|
runError={run.isError && !run.error?.response?.data?.runId ? errorMessage(run.error) : null}
|
||||||
|
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)}
|
||||||
|
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
||||||
>
|
>
|
||||||
<form id="workflow-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col">
|
<form id="workflow-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col">
|
||||||
<WorkflowYamlAndDiagram
|
<WorkflowYamlAndDiagram
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2 } from "react-icons/lu";
|
import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useDeleteWorkflow,
|
useDeleteWorkflow,
|
||||||
useReregisterWorkflows,
|
useReregisterWorkflows,
|
||||||
useRunWorkflow,
|
useRunWorkflow,
|
||||||
|
useSetWorkflowEnabled,
|
||||||
useWorkflows,
|
useWorkflows,
|
||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
|
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
|
||||||
@@ -19,6 +20,7 @@ export function WorkflowsPage() {
|
|||||||
const [runError, setRunError] = useState(null);
|
const [runError, setRunError] = useState(null);
|
||||||
const del = useDeleteWorkflow();
|
const del = useDeleteWorkflow();
|
||||||
const run = useRunWorkflow();
|
const run = useRunWorkflow();
|
||||||
|
const setEnabled = useSetWorkflowEnabled();
|
||||||
const reregister = useReregisterWorkflows();
|
const reregister = useReregisterWorkflows();
|
||||||
|
|
||||||
if (editParam) {
|
if (editParam) {
|
||||||
@@ -57,6 +59,10 @@ export function WorkflowsPage() {
|
|||||||
|
|
||||||
const runningKey =
|
const runningKey =
|
||||||
run.isPending && run.variables ? `${run.variables.owner}/${run.variables.file}` : null;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -89,6 +95,9 @@ export function WorkflowsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{runError ? <p className="text-error text-sm">{runError}</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 ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner" />
|
<span className="loading loading-spinner" />
|
||||||
@@ -116,6 +125,14 @@ export function WorkflowsPage() {
|
|||||||
>
|
>
|
||||||
{w.name}
|
{w.name}
|
||||||
</Link>
|
</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>
|
||||||
<td className="font-mono text-xs">{w.owner}</td>
|
<td className="font-mono text-xs">{w.owner}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -127,6 +144,21 @@ export function WorkflowsPage() {
|
|||||||
<td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td>
|
<td className="whitespace-nowrap">{formatTime(w.lastInvokedAt)}</td>
|
||||||
<td>{w.invocationCount}</td>
|
<td>{w.invocationCount}</td>
|
||||||
<td className="text-right whitespace-nowrap">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
|
|||||||
Generated
+39
-1
@@ -6,7 +6,11 @@ settings:
|
|||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.: {}
|
.:
|
||||||
|
dependencies:
|
||||||
|
rss-parser:
|
||||||
|
specifier: ^3.13.0
|
||||||
|
version: 3.13.0
|
||||||
|
|
||||||
packages/server:
|
packages/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1148,6 +1152,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@2.2.0:
|
||||||
|
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
|
||||||
|
|
||||||
entities@4.5.0:
|
entities@4.5.0:
|
||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -1757,6 +1764,9 @@ packages:
|
|||||||
roughjs@4.6.6:
|
roughjs@4.6.6:
|
||||||
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
|
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
|
||||||
|
|
||||||
|
rss-parser@3.13.0:
|
||||||
|
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
|
||||||
|
|
||||||
rw@1.3.3:
|
rw@1.3.3:
|
||||||
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
|
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
|
||||||
|
|
||||||
@@ -1774,6 +1784,10 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sax@1.6.1:
|
||||||
|
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||||
|
engines: {node: '>=11.0.0'}
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -1929,6 +1943,14 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
xml2js@0.5.0:
|
||||||
|
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
xmlbuilder@11.0.1:
|
||||||
|
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -2944,6 +2966,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
entities@2.2.0: {}
|
||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
entities@8.0.0: {}
|
entities@8.0.0: {}
|
||||||
@@ -3544,6 +3568,11 @@ snapshots:
|
|||||||
points-on-curve: 0.2.0
|
points-on-curve: 0.2.0
|
||||||
points-on-path: 0.2.1
|
points-on-path: 0.2.1
|
||||||
|
|
||||||
|
rss-parser@3.13.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 2.2.0
|
||||||
|
xml2js: 0.5.0
|
||||||
|
|
||||||
rw@1.3.3: {}
|
rw@1.3.3: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
@@ -3556,6 +3585,8 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
sax@1.6.1: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
secure-json-parse@4.1.0: {}
|
secure-json-parse@4.1.0: {}
|
||||||
@@ -3651,6 +3682,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
xml2js@0.5.0:
|
||||||
|
dependencies:
|
||||||
|
sax: 1.6.1
|
||||||
|
xmlbuilder: 11.0.1
|
||||||
|
|
||||||
|
xmlbuilder@11.0.1: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user