From 94ba931d9b169e49a7c81af52bfdedd48b3bcd53 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 15 Aug 2026 17:09:28 +0700 Subject: [PATCH 01/11] feat(scripts): add fetch-http script for HTTP requests Introduce a new fetch-http script that facilitates making HTTP requests with configurable methods, headers, and body. The script includes validation for input parameters and logs the request and response details. This addition enhances the server's capability to interact with external APIs and services. --- packages/server/scripts/fetch-http.js | 160 ++++++++++++++++++++++++++ packages/server/scripts/jsonata.js | 4 +- packages/server/scripts/send-email.js | 6 +- 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 packages/server/scripts/fetch-http.js diff --git a/packages/server/scripts/fetch-http.js b/packages/server/scripts/fetch-http.js new file mode 100644 index 0000000..cf0704c --- /dev/null +++ b/packages/server/scripts/fetch-http.js @@ -0,0 +1,160 @@ +const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + +function ensureDataObject(ctx) { + if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { + ctx.data = {}; + } +} + +function isPlainObject(value) { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function resolveMethod(ctx) { + const raw = ctx.config?.method ?? ctx.data?.method ?? "GET"; + if (typeof raw !== "string" || raw.length === 0) { + throw new Error("fetch-http: method must be a non-empty string"); + } + const method = raw.toUpperCase(); + if (!HTTP_METHODS.includes(method)) { + throw new Error(`fetch-http: unsupported method "${raw}"`); + } + return method; +} + +function normalizeHeaders(value, label) { + if (value == null) return {}; + if (!isPlainObject(value)) { + throw new Error(`fetch-http: ${label} must be an object`); + } + + /** @type {Record} */ + const headers = {}; + for (const [key, val] of Object.entries(value)) { + if (val == null) continue; + if (typeof val === "string") { + headers[key] = val; + continue; + } + if (typeof val === "number" || typeof val === "boolean") { + headers[key] = String(val); + continue; + } + // Secret wrappers are unwrapped by $axios; do not String() them. + if (typeof val === "object") { + headers[key] = val; + continue; + } + throw new Error(`fetch-http: header "${key}" must be a string`); + } + return headers; +} + +function resolveHeaders(ctx) { + return { + ...normalizeHeaders(ctx.config?.headers, "config.headers"), + ...normalizeHeaders(ctx.data?.headers, "data.headers"), + }; +} + +function resolveBody(ctx) { + if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) { + return { present: true, value: ctx.config.body }; + } + if (ctx.data != null && typeof ctx.data === "object" && "body" in ctx.data) { + return { present: true, value: ctx.data.body }; + } + return { present: false, value: undefined }; +} + +function responseSize(data) { + if (data == null) return 0; + if (typeof data === "string") return data.length; + if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return data.length; + try { + return JSON.stringify(data).length; + } catch { + return null; + } +} + +async function fetchHttp(ctx) { + const url = ctx.config?.url; + if (typeof url !== "string" || url.length === 0) { + throw new Error("fetch-http: ctx.config.url is required"); + } + + const method = resolveMethod(ctx); + const headers = resolveHeaders(ctx); + const body = resolveBody(ctx); + + ensureDataObject(ctx); + + /** @type {Record} */ + const request = { url, method, headers }; + if (body.present && method !== "HEAD") { + request.data = body.value; + } + + log.info({ url, method }, "fetch-http: fetching"); + const response = await $axios.request(request); + log.info( + { status: response.status, length: responseSize(response.data) }, + "fetch-http: fetch complete", + ); + + ctx.data.httpResponse = response.data; + ctx.data.httpStatus = response.status; + log.info( + { status: ctx.data.httpStatus, length: responseSize(ctx.data.httpResponse) }, + "fetch-http: saved httpResponse", + ); + + return ctx; +} + +fetchHttp.meta = { + description: "Fetch a URL and store the response body as-is (JSON stays an object)", + config: { + url: { type: "string", required: true, description: "Request URL" }, + method: { + type: "string", + default: "GET", + description: "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS)", + }, + headers: { + type: "object", + required: false, + description: "Request headers (string values; Secret values are unwrapped)", + }, + body: { + type: "any", + required: false, + description: "Request body for POST/PUT/PATCH (object, string, or buffer)", + }, + }, + input: { + method: { type: "string", required: false, description: "Fallback when config.method is omitted" }, + headers: { + type: "object", + required: false, + description: "Merged over config.headers (e.g. Authorization from a secret)", + }, + body: { type: "any", required: false, description: "Fallback when config.body is omitted" }, + }, + output: { + httpResponse: { type: "any", description: "Response body as returned by the server" }, + httpStatus: { type: "number", description: "HTTP status code" }, + }, + example: { + data: {}, + config: { + url: "https://httpbin.org/post", + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: { hello: "world" }, + }, + }, +}; + +export default fetchHttp; diff --git a/packages/server/scripts/jsonata.js b/packages/server/scripts/jsonata.js index 9f51b00..ba4f363 100644 --- a/packages/server/scripts/jsonata.js +++ b/packages/server/scripts/jsonata.js @@ -1,10 +1,10 @@ import jsonata from "jsonata"; -function jsonataFn(ctx) { +async function jsonataFn(ctx) { log.info({ctx}, "jsonata: context") log.info("jsonata: evaluating expression %s", ctx.config.expression); const expression = jsonata(ctx.config.expression); - const result = expression.evaluate(ctx.data); + const result = await expression.evaluate(ctx.data); log.info({result}, "jsonata: expression result"); return result; } diff --git a/packages/server/scripts/send-email.js b/packages/server/scripts/send-email.js index cf88b25..e56960e 100644 --- a/packages/server/scripts/send-email.js +++ b/packages/server/scripts/send-email.js @@ -235,7 +235,7 @@ sendEmail.meta = { type: "string", required: false, description: - "Well-known provider shortcut (e.g. gmail, outlook365, sendgrid). Alternative to host.", + "Nodemailer well-known service ID (e.g. Gmail, Outlook365, SendGrid). Sets host, port, and TLS. See https://nodemailer.com/smtp/well-known-services", }, host: { type: "string", @@ -430,9 +430,7 @@ sendEmail.meta = { text: "This is a plain-text test message.", }, config: { - host: "smtp.gmail.com", - port: 587, - secure: false, + service: "Gmail", user: "smtp-login@gmail.com", from: "notifications@example.com", passwordSecret: "gmail_app_password", From 7fe5defc43434f90e47f6bb0de7a7ba876a412a4 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 15 Aug 2026 18:33:13 +0700 Subject: [PATCH 02/11] feat(workflows): add Gmail sending and testing workflows - Introduced a new `send-gmail` workflow for sending emails via Gmail SMTP, with configurable parameters for recipients, subject, and body content. - Added a `test-send-gmail` workflow to facilitate testing the email sending functionality with sample data. - Implemented a `track` workflow to monitor Gojek trips, enhancing the server's capabilities for tracking and notifications. - Updated the workflow management interface to include new workflows and improved organization of existing scripts. Co-authored-by: Nasyarobby Putra --- packages/server/scripts/fetch-http.js | 12 +- .../server/workflows/default/registers.yaml | 3 + .../server/workflows/default/send-gmail.yaml | 40 ++ .../workflows/default/test-send-gmail.yaml | 18 + packages/server/workflows/default/track.yaml | 32 ++ packages/web/package.json | 4 + .../web/src/components/ScriptMetaPanel.jsx | 5 + .../components/workflow/AddScriptDialog.jsx | 99 ++++ .../src/components/workflow/ConfigFields.jsx | 441 +++++++++++++++++ .../web/src/components/workflow/FieldHelp.jsx | 29 ++ .../src/components/workflow/ScriptCard.jsx | 460 ++++++++++++++++++ .../src/components/workflow/ScriptsTab.jsx | 123 +++++ .../src/components/workflow/TriggerCard.jsx | 304 ++++++++++++ .../src/components/workflow/TriggersTab.jsx | 109 +++++ .../components/workflow/WorkflowTestPanel.jsx | 116 +++++ .../workflow/WorkflowVisualEditor.jsx | 193 ++++++++ .../web/src/components/workflow/YamlTab.jsx | 20 + packages/web/src/lib/cron-presets.js | 24 + packages/web/src/lib/script.js | 7 + packages/web/src/lib/workflow-doc.js | 308 ++++++++++++ packages/web/src/pages/WorkflowEditPage.jsx | 228 +++------ pnpm-lock.yaml | 70 +++ 22 files changed, 2486 insertions(+), 159 deletions(-) create mode 100644 packages/server/workflows/default/send-gmail.yaml create mode 100644 packages/server/workflows/default/test-send-gmail.yaml create mode 100644 packages/server/workflows/default/track.yaml create mode 100644 packages/web/src/components/workflow/AddScriptDialog.jsx create mode 100644 packages/web/src/components/workflow/ConfigFields.jsx create mode 100644 packages/web/src/components/workflow/FieldHelp.jsx create mode 100644 packages/web/src/components/workflow/ScriptCard.jsx create mode 100644 packages/web/src/components/workflow/ScriptsTab.jsx create mode 100644 packages/web/src/components/workflow/TriggerCard.jsx create mode 100644 packages/web/src/components/workflow/TriggersTab.jsx create mode 100644 packages/web/src/components/workflow/WorkflowTestPanel.jsx create mode 100644 packages/web/src/components/workflow/WorkflowVisualEditor.jsx create mode 100644 packages/web/src/components/workflow/YamlTab.jsx create mode 100644 packages/web/src/lib/cron-presets.js create mode 100644 packages/web/src/lib/workflow-doc.js diff --git a/packages/server/scripts/fetch-http.js b/packages/server/scripts/fetch-http.js index cf0704c..76ea114 100644 --- a/packages/server/scripts/fetch-http.js +++ b/packages/server/scripts/fetch-http.js @@ -114,13 +114,14 @@ async function fetchHttp(ctx) { } fetchHttp.meta = { - description: "Fetch a URL and store the response body as-is (JSON stays an object)", + description: "Fetch a URL and store the response body.", config: { url: { type: "string", required: true, description: "Request URL" }, method: { type: "string", default: "GET", - description: "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS)", + enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + description: "HTTP method", }, headers: { type: "object", @@ -134,7 +135,12 @@ fetchHttp.meta = { }, }, input: { - method: { type: "string", required: false, description: "Fallback when config.method is omitted" }, + method: { + type: "string", + required: false, + enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + description: "Fallback when config.method is omitted", + }, headers: { type: "object", required: false, diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index a2bcc32..075139f 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -6,3 +6,6 @@ scripts: - comic-monkeyuser-to-ntfy.yaml - time-and-comic-to-ntfy.yaml - rss-selfhst-to-ntfy.yaml + - send-gmail.yaml + - test-send-gmail.yaml + - track.yaml diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml new file mode 100644 index 0000000..0bddf4d --- /dev/null +++ b/packages/server/workflows/default/send-gmail.yaml @@ -0,0 +1,40 @@ +name: send-gmail +description: | + Send email via Gmail SMTP. Configure user/from and the named secret, + then call from other workflows with trigger-workflow.js (name: send-gmail). + + Input (data): + to optional recipient(s); defaults to your Gmail below + subject required + text plain body (aliases: body, message) + html optional HTML body + from, cc, bcc, replyTo, priority, headers optional +scripts: + - script: jsonata.js + config: + expression: | + { + "data": { + "to": $exists(to) ? to : "nasyarobby@gmail.com", + "from": from, + "subject": subject, + "text": $exists(text) ? text : ($exists(body) ? body : message), + "html": html, + "cc": cc, + "bcc": bcc, + "replyTo": replyTo, + "priority": priority, + "headers": headers + } + } + - script: send-email.js + config: + service: Gmail + user: elevent16th@gmail.com + from: elevent16th@gmail.com + passwordSecret: gmail_app_password +triggers: + - type: workflow + - type: HTTP + method: POST + path: /send-gmail diff --git a/packages/server/workflows/default/test-send-gmail.yaml b/packages/server/workflows/default/test-send-gmail.yaml new file mode 100644 index 0000000..4b5b699 --- /dev/null +++ b/packages/server/workflows/default/test-send-gmail.yaml @@ -0,0 +1,18 @@ +name: test-send-gmail +description: | + Kick send-gmail with sample data. Edit the payload below, then Run + (or POST /u/default/test-send-gmail). +scripts: + - script: trigger-workflow.js + config: + name: send-gmail + expression: | + { + "subject": "scrunner test", + "text": "Hello from test-send-gmail", + "html": "

Hello from test-send-gmail

" + } +triggers: + - type: HTTP + method: POST + path: /test-send-gmail diff --git a/packages/server/workflows/default/track.yaml b/packages/server/workflows/default/track.yaml new file mode 100644 index 0000000..c4a70f2 --- /dev/null +++ b/packages/server/workflows/default/track.yaml @@ -0,0 +1,32 @@ +name: Track Gojek Trip +scripts: + - script: fetch-http.js + config: + url: https://api.gojekapi.com/live/track/491c3132c2cc7043 + method: GET + headers: + Content-Type: application/json + Accept: application/json + Origin: https://track.gojek.com + - script: fingerprint.js + config: + key: gojek-track-status + jsonata: data.httpResponse.status + - script: jsonata.js + config: + expression: | + { + "data": { + "title": httpResponse.customer_name, + "message": httpResponse.customer_name & " : " & httpResponse.status + } + } + - script: ntfy.js + config: + url: https://n.0dev.web.id/system +triggers: + - type: cron + schedule: "* * * * *" + - type: HTTP + method: GET + path: /new diff --git a/packages/web/package.json b/packages/web/package.json index b22974e..0778ade 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,9 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.84.0", "axios": "^1.11.0", + "cronstrue": "^3.24.0", "mermaid": "^11.9.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/web/src/components/ScriptMetaPanel.jsx b/packages/web/src/components/ScriptMetaPanel.jsx index cb88bfa..670b48d 100644 --- a/packages/web/src/components/ScriptMetaPanel.jsx +++ b/packages/web/src/components/ScriptMetaPanel.jsx @@ -20,6 +20,11 @@ function FieldTable({ title, fields }) { const notes = [ field.required ? "required" : null, field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null, + Array.isArray(field.enum) + ? `enum ${field.enum.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}` + : Array.isArray(field.options) + ? `enum ${field.options.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}` + : null, field.description, ] .filter(Boolean) diff --git a/packages/web/src/components/workflow/AddScriptDialog.jsx b/packages/web/src/components/workflow/AddScriptDialog.jsx new file mode 100644 index 0000000..a8c2b3c --- /dev/null +++ b/packages/web/src/components/workflow/AddScriptDialog.jsx @@ -0,0 +1,99 @@ +import { useMemo, useState } from "react"; +import { useScripts } from "../../api/hooks.js"; + +export function AddScriptDialog({ open, onClose, onPick }) { + const { data: scripts = [], isLoading } = useScripts(); + const [q, setQ] = useState(""); + + const filtered = useMemo(() => { + const term = q.trim().toLowerCase(); + const list = scripts.map((s) => ({ + name: typeof s === "string" ? s : s.name, + description: typeof s === "string" ? "" : s.meta?.description ?? "", + meta: typeof s === "string" ? null : s.meta, + metaError: typeof s === "string" ? null : s.metaError, + })); + if (!term) return list; + return list.filter( + (s) => + s.name.toLowerCase().includes(term) || + s.description.toLowerCase().includes(term), + ); + }, [scripts, q]); + + if (!open) return null; + + return ( + +
+

Add script

+ setQ(e.target.value)} + autoFocus + /> +
    +
  • + +
  • + {isLoading ? ( +
  • + +
  • + ) : ( + filtered.map((s) => ( +
  • + +
  • + )) + )} +
+
+ +
+
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/workflow/ConfigFields.jsx b/packages/web/src/components/workflow/ConfigFields.jsx new file mode 100644 index 0000000..2b846e1 --- /dev/null +++ b/packages/web/src/components/workflow/ConfigFields.jsx @@ -0,0 +1,441 @@ +import { useEffect, useState } from "react"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; +import { triggerDestinations } from "../../lib/workflow-doc.js"; +import { prettyJson } from "../../lib/script.js"; +import { FieldLabel } from "./FieldHelp.jsx"; + +const MULTILINE_KEYS = new Set(["expression", "jsonata"]); + +function fieldSpec(meta, key) { + const spec = meta?.config?.[key]; + if (spec && typeof spec === "object") return spec; + return {}; +} + +/** Support meta `enum` or `options`: string[], or { value, label }[]. */ +function enumOptions(spec) { + const raw = spec?.enum ?? spec?.options; + if (!Array.isArray(raw) || raw.length === 0) return null; + return raw + .map((item) => { + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") { + return { value: item, label: String(item) }; + } + if (item && typeof item === "object" && item.value != null) { + return { value: item.value, label: item.label != null ? String(item.label) : String(item.value) }; + } + return null; + }) + .filter(Boolean); +} + +function EditableText({ value, onCommit, className = "", placeholder = "…", disabled }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ""); + + useEffect(() => { + setDraft(value ?? ""); + }, [value]); + + if (!editing) { + return ( + + ); + } + + return ( + setDraft(e.target.value)} + onBlur={() => { + onCommit(draft); + setEditing(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") { + setDraft(value ?? ""); + setEditing(false); + } + }} + /> + ); +} + +function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner, excludeFile, disabled }) { + const type = spec?.type ?? (value != null && typeof value === "object" ? "object" : "string"); + + if (script === "trigger-workflow.js" && fieldKey === "name") { + return ( + + ); + } + + const options = enumOptions(spec); + if (options) { + const fallback = spec?.default ?? (spec?.required ? options[0].value : ""); + const current = value === undefined || value === null ? fallback : value; + const known = options.some((o) => o.value === current); + return ( + + ); + } + + if (type === "boolean") { + return ( + onChange(e.target.checked)} + /> + ); + } + + if (type === "number") { + return ( + { + const raw = e.target.value; + if (raw === "") { + onChange(undefined); + return; + } + const n = Number(raw); + onChange(Number.isNaN(n) ? raw : n); + }} + /> + ); + } + + if (type === "object" || (value != null && typeof value === "object" && !Array.isArray(value) && type !== "any")) { + const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return ; + } + + if (type === "any") { + const text = + typeof value === "string" ? value : value === undefined ? "" : prettyJson(value) || ""; + return ( + { + if (next.trim() === "") { + onChange(undefined); + return; + } + try { + onChange(JSON.parse(next)); + } catch { + onChange(next); + } + }} + disabled={disabled} + /> + ); + } + + const str = value == null ? "" : String(value); + const multiline = MULTILINE_KEYS.has(fieldKey) || str.includes("\n"); + if (multiline) { + return ( +