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:
2026-08-14 22:52:33 +07:00
parent b8bbd56035
commit c5015bb391
15 changed files with 429 additions and 25 deletions
+8
View File
@@ -175,6 +175,10 @@ export function createRegistry(server) {
method,
url,
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(
key,
{ data: req.body },
@@ -434,6 +438,10 @@ export function createRegistry(server) {
}
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({
owner,
workflow: key,
+1 -1
View File
@@ -11,7 +11,7 @@ import { getSecretPlaintext } from "./secrets-store.js";
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 = [
"Infinity",
+100
View File
@@ -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;
+48
View File
@@ -191,6 +191,43 @@ export default function workflowsPluginFactory(registry) {
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) => {
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
req.params
@@ -221,6 +258,17 @@ export default function workflowsPluginFactory(registry) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
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)) {
return reply.code(404).send({
error: registry.loadErrors.get(key) ?? "workflow not loaded",
+13 -1
View File
@@ -190,7 +190,19 @@ export async function listRuns(filters = {}) {
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
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.before) q = q.where("started_at", "<", filters.before);
const rows = await q.limit(limit);
@@ -5,3 +5,4 @@ scripts:
- fetch-devto.yaml
- comic-monkeyuser-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
@@ -9,4 +9,4 @@ scripts:
triggers:
- type: HTTP
method: POST
path: /time-to-ntfy
path: /time-to-ntfy
+18
View File
@@ -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() {
const qc = useQueryClient();
return useMutation({
+50 -17
View File
@@ -21,26 +21,59 @@ export function StatusBadge({ status }) {
/** Config load + last-run health for the workflows list. */
export function WorkflowStatusBadge({ workflow }) {
if (workflow.loadError) {
return (
<span className="badge badge-error badge-sm" title={workflow.loadError}>
broken
</span>
const badges = [];
if (!workflow.registered) {
badges.push(
<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>;
}
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>;
return <span className="inline-flex flex-wrap gap-1">{badges}</span>;
}
export function levelName(level) {
+1 -1
View File
@@ -25,7 +25,7 @@ export function EventsPage() {
<div className="flex flex-col sm:flex-row gap-2">
<input
className="input input-sm w-full sm:max-w-sm"
placeholder="workflow key"
placeholder="workflow key (* wildcard)"
value={workflow}
onChange={(e) => update("workflow", e.target.value)}
/>
+88 -2
View File
@@ -1,9 +1,15 @@
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 { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
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 { MermaidDiagram } from "../components/MermaidDiagram.jsx";
import { workflowToFlowchart } from "../lib/workflow-mermaid.js";
@@ -44,6 +50,15 @@ function WorkflowEditorLayout({
saveError,
saveSuccess,
formId,
onRun,
runPending,
runDisabled,
runError,
onToggleEnabled,
enabled,
enablePending,
enableDisabled,
enableError,
children,
}) {
return (
@@ -54,6 +69,38 @@ function WorkflowEditorLayout({
</Link>
<h1 className="truncate text-xl font-semibold">{title}</h1>
<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
type="submit"
form={formId}
@@ -68,6 +115,12 @@ function WorkflowEditorLayout({
{saveError ? (
<p className="text-error text-sm shrink-0">{saveError}</p>
) : 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 ? (
<p className="text-success text-sm shrink-0">Workflow saved</p>
) : null}
@@ -164,15 +217,33 @@ export function WorkflowNewPage() {
}
export function WorkflowEditPage() {
const navigate = useNavigate();
const { owner: rawOwner, file: rawFile } = useParams();
const owner = decodeURIComponent(rawOwner ?? "");
const file = decodeURIComponent(rawFile ?? "");
const existing = useWorkflow(owner, file);
const save = useSaveWorkflow();
const run = useRunWorkflow();
const setEnabled = useSetWorkflowEnabled();
const [content, setContent] = useState("");
const [contentReady, setContentReady] = useState(false);
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(() => {
if (existing.isLoading) return;
if (existing.data?.content != null) {
@@ -214,6 +285,21 @@ export function WorkflowEditPage() {
saveDisabled={!contentReady}
saveError={save.isError ? errorMessage(save.error) : null}
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">
<WorkflowYamlAndDiagram
+33 -1
View File
@@ -1,11 +1,12 @@
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 { LuActivity, 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 { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
@@ -19,6 +20,7 @@ export function WorkflowsPage() {
const [runError, setRunError] = useState(null);
const del = useDeleteWorkflow();
const run = useRunWorkflow();
const setEnabled = useSetWorkflowEnabled();
const reregister = useReregisterWorkflows();
if (editParam) {
@@ -57,6 +59,10 @@ export function WorkflowsPage() {
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">
@@ -89,6 +95,9 @@ export function WorkflowsPage() {
) : 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" />
@@ -116,6 +125,14 @@ export function WorkflowsPage() {
>
{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>
@@ -127,6 +144,21 @@ export function WorkflowsPage() {
<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"