Merge branch 'workflow-editor-rehaul'
This commit is contained in:
@@ -5,3 +5,8 @@ logs/
|
||||
*.db-*
|
||||
.DS_Store
|
||||
packages/web/dist
|
||||
|
||||
# Personal/local scripts and workflows (not for the repo)
|
||||
packages/server/scripts/dev-*
|
||||
packages/server/workflows/**/dev-*
|
||||
debug-*.js
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# scrunner
|
||||
# JerapahFlow
|
||||
|
||||

|
||||
|
||||
Workflow runner with a sandboxed script engine, SQLite run history, and an admin UI.
|
||||
|
||||
## Packages
|
||||
|
||||
- `packages/server` — Fastify runner, HTTP/cron triggers, admin REST API
|
||||
- `packages/web` — React admin UI (Vite, DaisyUI, React Query)
|
||||
- `@jerapah-flow/server` (`packages/server`) — Fastify runner, HTTP/cron triggers, admin REST API
|
||||
- `@jerapah-flow/web` (`packages/web`) — React admin UI (Vite, DaisyUI, React Query)
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -19,6 +21,31 @@ pnpm dev
|
||||
|
||||
The first account created becomes **admin**. Later accounts are created from Users.
|
||||
|
||||
## Script contract
|
||||
|
||||
Each script is `async function main(ctx)` and **must** return:
|
||||
|
||||
```js
|
||||
{ output, context?, skipRemaining? }
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
|
||||
| `ctx.context` | Run clipboard (plain object, default `{}`) |
|
||||
| `ctx.config` | This step’s YAML config |
|
||||
| `output` | Becomes the **next** step’s `data` |
|
||||
| `context` | Next snapshot of the bag. Omitted → keep incoming |
|
||||
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` |
|
||||
|
||||
Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned.
|
||||
|
||||
YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same.
|
||||
|
||||
DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run.
|
||||
|
||||
Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / `meta.output` / `meta.context` describe `data`, the return pipe, and clipboard keys.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
@@ -34,12 +61,12 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `SCRUNNER_JWT_SECRET` | `scrunner-dev-secret` (dev only) | **Required in production** |
|
||||
| `SCRUNNER_SECRETS_KEY` | `scrunner-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
||||
| `SCRUNNER_DB_PATH` | `packages/server/data/scrunner.db` | SQLite file |
|
||||
| `SCRUNNER_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `SCRUNNER_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `SCRUNNER_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
||||
| `JERAPAH_FLOW_JWT_SECRET` | `scrunner-dev-secret` (dev only) | **Required in production**. `SCRUNNER_JWT_SECRET` is still accepted. |
|
||||
| `JERAPAH_FLOW_SECRETS_KEY` | `scrunner-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. `SCRUNNER_SECRETS_KEY` is still accepted. |
|
||||
| `JERAPAH_FLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` (or existing `scrunner.db`) | SQLite file. `SCRUNNER_DB_PATH` is still accepted. |
|
||||
| `JERAPAH_FLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `JERAPAH_FLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JERAPAH_FLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
||||
| `PORT` | `9000` | HTTP port |
|
||||
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||
|
||||
@@ -48,7 +75,7 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
SCRUNNER_JWT_SECRET=... SCRUNNER_SECRETS_KEY=... NODE_ENV=production pnpm start
|
||||
JERAPAH_FLOW_JWT_SECRET=... JERAPAH_FLOW_SECRETS_KEY=... NODE_ENV=production pnpm start
|
||||
```
|
||||
|
||||
The server serves `packages/web/dist` when that folder exists.
|
||||
|
||||
+7
-7
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "scrunner",
|
||||
"name": "jerapah-flow",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Script/workflow runner with admin UI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @scrunner/server --filter @scrunner/web --parallel dev",
|
||||
"dev:server": "pnpm --filter @scrunner/server dev",
|
||||
"dev:web": "pnpm --filter @scrunner/web dev",
|
||||
"build": "pnpm --filter @scrunner/web build",
|
||||
"start": "pnpm --filter @scrunner/server start",
|
||||
"migrate": "pnpm --filter @scrunner/server migrate"
|
||||
"dev": "pnpm --filter @jerapah-flow/server --filter @jerapah-flow/web --parallel dev",
|
||||
"dev:server": "pnpm --filter @jerapah-flow/server dev",
|
||||
"dev:web": "pnpm --filter @jerapah-flow/web dev",
|
||||
"build": "pnpm --filter @jerapah-flow/web build",
|
||||
"start": "pnpm --filter @jerapah-flow/server start",
|
||||
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"pnpm": {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { coerceCredentialString } from "./http-trigger-auth.js";
|
||||
import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
|
||||
import { isSecret } from "./secret-value.js";
|
||||
import { assertVariableName, getVariablePlain } from "./variables-store.js";
|
||||
|
||||
const PREFIXES = [
|
||||
{ kind: "context", prefix: "$CONTEXT_" },
|
||||
{ kind: "secret", prefix: "$SECRET_" },
|
||||
{ kind: "var", prefix: "$VAR_" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef
|
||||
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a whole-value config placeholder. Returns null for literals.
|
||||
* @param {unknown} value
|
||||
* @returns {ConfigRef | null}
|
||||
*/
|
||||
export function parseConfigRef(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
for (const { kind, prefix } of PREFIXES) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return { kind, name: trimmed.slice(prefix.length), raw: trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_`
|
||||
* strings. Does not walk trigger data.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @param {WeakSet<object>} [seen]
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
|
||||
if (typeof value === "string") {
|
||||
return resolveStringRef(value, ctx);
|
||||
}
|
||||
if (value == null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const out = [];
|
||||
for (const item of value) {
|
||||
out.push(await resolveConfigRefs(item, ctx, seen));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
out[key] = await resolveConfigRefs(child, ctx, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
*/
|
||||
async function resolveStringRef(value, ctx) {
|
||||
const ref = parseConfigRef(value);
|
||||
if (!ref) return value;
|
||||
|
||||
if (ref.kind === "secret") {
|
||||
return resolveSecretRef(ref, ctx);
|
||||
}
|
||||
if (ref.kind === "var") {
|
||||
return resolveVarRef(ref, ctx);
|
||||
}
|
||||
return resolveContextRef(ref, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveSecretRef(ref, ctx) {
|
||||
try {
|
||||
assertSecretName(ref.name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid secret name`);
|
||||
}
|
||||
const plaintext = await getSecretPlaintext(ctx.owner, ref.name);
|
||||
if (plaintext == null) {
|
||||
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`);
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
*/
|
||||
async function resolveVarRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty variable name`);
|
||||
}
|
||||
try {
|
||||
assertVariableName(ref.name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid variable name`);
|
||||
}
|
||||
const value = await getVariablePlain(ctx.owner, ref.name);
|
||||
if (value == null) {
|
||||
throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveContextRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty context key`);
|
||||
}
|
||||
const bag =
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? /** @type {Record<string, unknown>} */ (ctx.context)
|
||||
: {};
|
||||
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
|
||||
}
|
||||
const raw = bag[ref.name];
|
||||
if (isSecret(raw)) {
|
||||
return raw.reveal();
|
||||
}
|
||||
const coerced = coerceCredentialString(raw);
|
||||
if (coerced == null) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`);
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
@@ -55,11 +55,42 @@ export function writeScript(name, content) {
|
||||
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
||||
* @returns {{ filePath: string, contentType: string } | null}
|
||||
*/
|
||||
export function resolveScriptIcon(name) {
|
||||
assertScriptName(name);
|
||||
const base = name.slice(0, -3);
|
||||
for (const { ext, contentType } of [
|
||||
{ ext: "png", contentType: "image/png" },
|
||||
{ ext: "jpg", contentType: "image/jpeg" },
|
||||
]) {
|
||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
return { filePath, contentType };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scriptHasIcon(name) {
|
||||
return resolveScriptIcon(name) != null;
|
||||
}
|
||||
|
||||
export function deleteScript(name) {
|
||||
assertScriptName(name);
|
||||
const filePath = path.join(SCRIPTS_DIR, name);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const icon = resolveScriptIcon(name);
|
||||
fs.unlinkSync(filePath);
|
||||
if (icon) {
|
||||
try {
|
||||
fs.unlinkSync(icon.filePath);
|
||||
} catch {
|
||||
// ignore missing icon
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { DATA_DIR, SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const dbPath = process.env.SCRUNNER_DB_PATH ?? path.join(DATA_DIR, "scrunner.db");
|
||||
const defaultNew = path.join(DATA_DIR, "jerapah-flow.db");
|
||||
const defaultLegacy = path.join(DATA_DIR, "scrunner.db");
|
||||
const dbPath =
|
||||
process.env.JERAPAH_FLOW_DB_PATH ??
|
||||
process.env.SCRUNNER_DB_PATH ??
|
||||
(fs.existsSync(defaultLegacy) && !fs.existsSync(defaultNew) ? defaultLegacy : defaultNew);
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
/** @type {import("knex").Knex.Config} */
|
||||
|
||||
@@ -105,7 +105,7 @@ fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
const rollingFile = pino.transport({
|
||||
target: "pino-roll",
|
||||
options: {
|
||||
file: path.join(LOGS_DIR, "scrunner.log"),
|
||||
file: path.join(LOGS_DIR, "jerapah-flow.log"),
|
||||
size: "10m",
|
||||
mkdir: true,
|
||||
limit: { count: 5 },
|
||||
@@ -113,7 +113,7 @@ const rollingFile = pino.transport({
|
||||
});
|
||||
|
||||
export const log = pino(
|
||||
{ level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
|
||||
{ level: process.env.JERAPAH_FLOW_LOG_LEVEL ?? process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
|
||||
pino.multistream([
|
||||
{ level: "debug", stream: redactStream(process.stdout) },
|
||||
{ level: "debug", stream: redactStream(rollingFile) },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("variables", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("owner").notNullable();
|
||||
t.text("name").notNullable();
|
||||
t.text("type").notNullable();
|
||||
t.text("value").notNullable();
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
t.unique(["owner", "name"]);
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX variables_owner_name_idx ON variables (owner, name)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("variables");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@scrunner/server",
|
||||
"name": "@jerapah-flow/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+143
-51
@@ -15,6 +15,13 @@ import {
|
||||
namespacedPath,
|
||||
parseScriptStep,
|
||||
} from "./workflow-parse.js";
|
||||
import {
|
||||
chainCtx,
|
||||
mergeContextWave,
|
||||
normalizeContext,
|
||||
normalizeStepResult,
|
||||
storedEnvelope,
|
||||
} from "./step-result.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
import {
|
||||
checkHttpAuth,
|
||||
@@ -23,6 +30,7 @@ import {
|
||||
sendHttpPageOrJson,
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
|
||||
/**
|
||||
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
|
||||
@@ -130,9 +138,12 @@ export function createRegistry(server) {
|
||||
|
||||
const onDisk = fsStore.listOwnerYamlFiles(owner);
|
||||
for (const file of onDisk) {
|
||||
if (!workflowFiles.includes(file)) {
|
||||
log.warn(`Workflow file not in registers.yaml: ${owner}/${file}`);
|
||||
if (workflowFiles.includes(file)) continue;
|
||||
if (file.startsWith("dev-")) {
|
||||
workflowFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
log.warn(`Workflow file not in registers.yaml: ${owner}/${file}`);
|
||||
}
|
||||
|
||||
for (const file of workflowFiles) {
|
||||
@@ -312,7 +323,7 @@ export function createRegistry(server) {
|
||||
pruneTask.destroy();
|
||||
pruneTask = null;
|
||||
}
|
||||
const days = Number(process.env.SCRUNNER_RETENTION_DAYS ?? 30);
|
||||
const days = Number(process.env.JERAPAH_FLOW_RETENTION_DAYS ?? process.env.SCRUNNER_RETENTION_DAYS ?? 30);
|
||||
pruneTask = cron.schedule(
|
||||
"0 0 * * *",
|
||||
async () => {
|
||||
@@ -327,24 +338,15 @@ export function createRegistry(server) {
|
||||
);
|
||||
}
|
||||
|
||||
function isSkipRemaining(result) {
|
||||
return (
|
||||
result != null &&
|
||||
typeof result === "object" &&
|
||||
!Array.isArray(result) &&
|
||||
/** @type {{ skipRemaining?: unknown }} */ (result).skipRemaining === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./workflow-parse.js").CompiledStep} parsed
|
||||
* @param {number} index
|
||||
* @param {unknown} ctx
|
||||
* @param {import("./step-result.js").StepResult} last
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} reason
|
||||
*/
|
||||
async function markStepSkipped(parsed, index, ctx, runId, runLog, reason) {
|
||||
async function markStepSkipped(parsed, index, last, runId, runLog, reason) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
@@ -354,12 +356,12 @@ export function createRegistry(server) {
|
||||
});
|
||||
const stepLog = runLog.child({ stepId: step.id, script });
|
||||
stepLog.debug({ reason }, "step skipped");
|
||||
await store.finishStep(step.id, "skipped", ctx, reason);
|
||||
await store.finishStep(step.id, "skipped", storedEnvelope(last), reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./workflow-parse.js").CompiledScripts} compiled
|
||||
* @param {{ data?: unknown }} ctx
|
||||
* @param {{ data?: unknown, context?: Record<string, unknown> }} ctx
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
@@ -367,11 +369,17 @@ export function createRegistry(server) {
|
||||
* @param {number} depth
|
||||
*/
|
||||
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) {
|
||||
let next = ctx;
|
||||
/** @type {import("./step-result.js").StepResult} */
|
||||
let last = {
|
||||
output: ctx.data,
|
||||
context: normalizeContext(ctx.context),
|
||||
skipRemaining: false,
|
||||
};
|
||||
let next = { data: ctx.data, context: last.context };
|
||||
for (let i = 0; i < compiled.order.length; i++) {
|
||||
const index = compiled.order[i];
|
||||
const parsed = compiled.steps[index];
|
||||
next = await runCompiledStep(
|
||||
last = await runCompiledStep(
|
||||
parsed,
|
||||
next,
|
||||
index,
|
||||
@@ -381,13 +389,14 @@ export function createRegistry(server) {
|
||||
owner,
|
||||
depth,
|
||||
);
|
||||
if (isSkipRemaining(next)) {
|
||||
next = chainCtx(last);
|
||||
if (last.skipRemaining) {
|
||||
for (let j = i + 1; j < compiled.order.length; j++) {
|
||||
const laterIndex = compiled.order[j];
|
||||
await markStepSkipped(
|
||||
compiled.steps[laterIndex],
|
||||
laterIndex,
|
||||
next,
|
||||
last,
|
||||
runId,
|
||||
runLog,
|
||||
"skipRemaining",
|
||||
@@ -396,12 +405,12 @@ export function createRegistry(server) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./workflow-parse.js").CompiledScripts} compiled
|
||||
* @param {{ data?: unknown }} ctx
|
||||
* @param {{ data?: unknown, context?: Record<string, unknown> }} ctx
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
@@ -412,15 +421,64 @@ export function createRegistry(server) {
|
||||
const triggerData = ctx.data;
|
||||
/** @type {Map<string, unknown>} */
|
||||
const outputsById = new Map();
|
||||
let last = ctx;
|
||||
/** @type {Map<string, number>} */
|
||||
const idToIndex = new Map();
|
||||
for (const step of compiled.steps) {
|
||||
if (step.id) idToIndex.set(step.id, step.index);
|
||||
}
|
||||
|
||||
for (const [orderIndex, stepIndex] of compiled.order.entries()) {
|
||||
const parsed = compiled.steps[stepIndex];
|
||||
const remaining = new Set(compiled.steps.map((s) => s.index));
|
||||
const completed = new Set();
|
||||
let context = normalizeContext(ctx.context);
|
||||
/** @type {import("./step-result.js").StepResult} */
|
||||
let last = {
|
||||
output: ctx.data,
|
||||
context,
|
||||
skipRemaining: false,
|
||||
};
|
||||
let execIndex = 0;
|
||||
|
||||
/**
|
||||
* @param {import("./workflow-parse.js").CompiledStep} step
|
||||
*/
|
||||
function needsMet(step) {
|
||||
if (step.needsKind === "none" || step.needs.length === 0) return true;
|
||||
return step.needs.every((edge) => completed.has(idToIndex.get(edge.from)));
|
||||
}
|
||||
|
||||
while (remaining.size) {
|
||||
const wave = compiled.steps
|
||||
.filter((s) => remaining.has(s.index) && needsMet(s))
|
||||
.sort((a, b) => a.index - b.index);
|
||||
if (wave.length === 0) {
|
||||
throw new Error("Workflow has a cycle");
|
||||
}
|
||||
|
||||
const snapshot = { ...context };
|
||||
/** @type {Array<{ id: string, context: unknown }>} */
|
||||
const patches = [];
|
||||
let skipRest = false;
|
||||
|
||||
for (const parsed of wave) {
|
||||
remaining.delete(parsed.index);
|
||||
const index = execIndex;
|
||||
execIndex += 1;
|
||||
if (skipRest) {
|
||||
await markStepSkipped(
|
||||
parsed,
|
||||
index,
|
||||
last,
|
||||
runId,
|
||||
runLog,
|
||||
"skipRemaining",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const data = mergeStepData(parsed, outputsById, triggerData);
|
||||
last = await runCompiledStep(
|
||||
parsed,
|
||||
{ ...ctx, data },
|
||||
orderIndex,
|
||||
{ data, context: { ...snapshot } },
|
||||
index,
|
||||
runId,
|
||||
runLog,
|
||||
key,
|
||||
@@ -428,14 +486,26 @@ export function createRegistry(server) {
|
||||
depth,
|
||||
);
|
||||
if (parsed.id) {
|
||||
outputsById.set(parsed.id, last);
|
||||
outputsById.set(parsed.id, last.output);
|
||||
}
|
||||
if (isSkipRemaining(last)) {
|
||||
for (let j = orderIndex + 1; j < compiled.order.length; j++) {
|
||||
const laterParsed = compiled.steps[compiled.order[j]];
|
||||
patches.push({
|
||||
id: parsed.id ?? String(parsed.index),
|
||||
context: last.context,
|
||||
});
|
||||
if (last.skipRemaining) skipRest = true;
|
||||
}
|
||||
|
||||
context = mergeContextWave(snapshot, patches);
|
||||
last = { ...last, context };
|
||||
|
||||
if (skipRest) {
|
||||
for (const later of compiled.steps.filter((s) => remaining.has(s.index))) {
|
||||
remaining.delete(later.index);
|
||||
const index = execIndex;
|
||||
execIndex += 1;
|
||||
await markStepSkipped(
|
||||
laterParsed,
|
||||
j,
|
||||
later,
|
||||
index,
|
||||
last,
|
||||
runId,
|
||||
runLog,
|
||||
@@ -444,7 +514,10 @@ export function createRegistry(server) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
for (const parsed of wave) completed.add(parsed.index);
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
@@ -483,13 +556,14 @@ export function createRegistry(server) {
|
||||
|
||||
/**
|
||||
* @param {import("./workflow-parse.js").CompiledStep} parsed
|
||||
* @param {{ data?: unknown, config?: unknown }} ctx
|
||||
* @param {{ data?: unknown, context?: unknown, config?: unknown }} ctx
|
||||
* @param {number} index
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
* @param {number} depth
|
||||
* @returns {Promise<import("./step-result.js").StepResult>}
|
||||
*/
|
||||
async function runCompiledStep(
|
||||
parsed,
|
||||
@@ -502,39 +576,57 @@ export function createRegistry(server) {
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const config = parsed.config;
|
||||
const unresolvedConfig = parsed.config;
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
index,
|
||||
script,
|
||||
config,
|
||||
config: unresolvedConfig,
|
||||
});
|
||||
const stepLog = runLog.child({ stepId: step.id, script });
|
||||
try {
|
||||
const config = await resolveConfigRefs(unresolvedConfig, {
|
||||
owner,
|
||||
workflowKey: key,
|
||||
context: incomingContext,
|
||||
});
|
||||
const stepCtx = {
|
||||
data: ctx.data,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
if (parsed.when) {
|
||||
const whenResult = await evaluateJsonata(parsed.when, ctx);
|
||||
const whenResult = await evaluateJsonata(parsed.when, stepCtx);
|
||||
if (!isJsonataTruthy(whenResult)) {
|
||||
stepLog.debug({ when: parsed.when }, "step skipped");
|
||||
await store.finishStep(step.id, "skipped", ctx, "when condition");
|
||||
return ctx;
|
||||
const skipped = {
|
||||
output: stepCtx.data,
|
||||
context: incomingContext,
|
||||
skipRemaining: false,
|
||||
};
|
||||
await store.finishStep(step.id, "skipped", storedEnvelope(skipped), "when condition");
|
||||
return skipped;
|
||||
}
|
||||
}
|
||||
if (parsed.kind === "set") {
|
||||
if (ctx == null || typeof ctx !== "object" || Array.isArray(ctx)) {
|
||||
throw new Error("set requires an object context");
|
||||
}
|
||||
const value = await evaluateJsonata(parsed.expression, ctx);
|
||||
const result = { ...ctx, [parsed.as]: value };
|
||||
await store.finishStep(step.id, "success", result);
|
||||
const value = await evaluateJsonata(parsed.expression, stepCtx);
|
||||
const result = {
|
||||
output: value,
|
||||
context: incomingContext,
|
||||
skipRemaining: false,
|
||||
};
|
||||
await store.finishStep(step.id, "success", storedEnvelope(result));
|
||||
return result;
|
||||
}
|
||||
const result = await runScript(script, { ...ctx, config }, {
|
||||
const raw = await runScript(script, stepCtx, {
|
||||
log: stepLog,
|
||||
workflowName: key,
|
||||
owner,
|
||||
$workflows: createWorkflowsApi(owner, key, runId, depth),
|
||||
});
|
||||
await store.finishStep(step.id, "success", result);
|
||||
const result = normalizeStepResult(raw, incomingContext, script);
|
||||
await store.finishStep(step.id, "success", storedEnvelope(result));
|
||||
return result;
|
||||
} catch (err) {
|
||||
await store.finishStep(step.id, "failed", null, err);
|
||||
@@ -544,7 +636,7 @@ export function createRegistry(server) {
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ data?: unknown }} context
|
||||
* @param {{ data?: unknown, context?: unknown }} context
|
||||
* @param {{ type: string, detail?: string | null }} trigger
|
||||
* @param {{
|
||||
* parentRunId?: string | null,
|
||||
@@ -580,8 +672,8 @@ export function createRegistry(server) {
|
||||
runLog.debug(detach ? "running workflow (detached)" : "running workflow");
|
||||
|
||||
const initialCtx = {
|
||||
...context,
|
||||
data: context.data ?? workflow.data ?? null,
|
||||
context: normalizeContext(context.context),
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
@@ -609,8 +701,8 @@ export function createRegistry(server) {
|
||||
depth,
|
||||
);
|
||||
}
|
||||
await store.finishRun(run.id, "success", ctx);
|
||||
return { runId: run.id, status: "success", result: ctx };
|
||||
await store.finishRun(run.id, "success", storedEnvelope(ctx));
|
||||
return { runId: run.id, status: "success", result: storedEnvelope(ctx) };
|
||||
} catch (err) {
|
||||
runLog.error({ err }, "workflow failed");
|
||||
await store.finishRun(run.id, "failed", null, err);
|
||||
|
||||
@@ -17,6 +17,7 @@ import runsPlugin from "./src/api/runs.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import kvPlugin from "./src/api/kv.js";
|
||||
import variablesPlugin from "./src/api/variables.js";
|
||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
@@ -26,11 +27,12 @@ await migrate();
|
||||
enableLogPersistence();
|
||||
|
||||
const jwtSecret =
|
||||
process.env.JERAPAH_FLOW_JWT_SECRET ??
|
||||
process.env.SCRUNNER_JWT_SECRET ??
|
||||
(process.env.NODE_ENV === "production" ? "" : "scrunner-dev-secret");
|
||||
|
||||
if (!jwtSecret) {
|
||||
log.error("SCRUNNER_JWT_SECRET is required in production");
|
||||
log.error("JERAPAH_FLOW_JWT_SECRET is required in production");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ await server.register(jwt, {
|
||||
},
|
||||
});
|
||||
await server.register(cors, {
|
||||
origin: process.env.SCRUNNER_CORS_ORIGIN ?? "http://localhost:5173",
|
||||
origin: process.env.JERAPAH_FLOW_CORS_ORIGIN ?? process.env.SCRUNNER_CORS_ORIGIN ?? "http://localhost:5173",
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -91,6 +93,7 @@ await server.register(
|
||||
await api.register(authPlugin);
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(variablesPlugin);
|
||||
await api.register(kvPlugin);
|
||||
await api.register(httpPagesPlugin);
|
||||
await api.register(httpAuthsPlugin);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SCRIPTS_DIR } from "./paths.js";
|
||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
import { getVariablePlain } from "./variables-store.js";
|
||||
|
||||
const hostRequire = createRequire(import.meta.url);
|
||||
|
||||
@@ -301,6 +302,24 @@ function createRestrictedRequire(screenedAxios) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} owner
|
||||
*/
|
||||
function createVarsApi(owner) {
|
||||
return {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
async get(name) {
|
||||
const value = await getVariablePlain(owner, name);
|
||||
if (value == null) {
|
||||
throw new Error(`variable "${name}" not found`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} owner
|
||||
*/
|
||||
@@ -380,6 +399,7 @@ function createScriptSandbox({
|
||||
const $kv = createKvApi(workflowName);
|
||||
const $fingerprint = createFingerprintApi($kv);
|
||||
const $secrets = createSecretsApi(owner);
|
||||
const $vars = createVarsApi(owner);
|
||||
const $responses = createResponsesApi();
|
||||
const sandbox = {
|
||||
...pickBuiltins(),
|
||||
@@ -389,13 +409,14 @@ function createScriptSandbox({
|
||||
$kv,
|
||||
$fingerprint,
|
||||
$secrets,
|
||||
$vars,
|
||||
$responses,
|
||||
$workflows,
|
||||
require: createRestrictedRequire($axios),
|
||||
};
|
||||
|
||||
vm.createContext(sandbox, {
|
||||
name: `scrunner:${workflowName}:${script}`,
|
||||
name: `jerapah-flow:${workflowName}:${script}`,
|
||||
codeGeneration: { strings: false, wasm: false },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function filenameFromUrl(url) {
|
||||
@@ -38,8 +46,6 @@ function resolveUrl(ctx) {
|
||||
}
|
||||
|
||||
async function fetchBinary(ctx) {
|
||||
ensureDataObject(ctx);
|
||||
|
||||
const url = resolveUrl(ctx);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
@@ -55,26 +61,32 @@ async function fetchBinary(ctx) {
|
||||
const response = await $axios.get(url, { responseType: "arraybuffer" });
|
||||
const file = Buffer.from(response.data ?? []);
|
||||
const contentType = String(response.headers?.["content-type"] ?? "application/octet-stream");
|
||||
const filename = ctx.config?.filename || ctx.data.filename || filenameFromUrl(url);
|
||||
const filename = ctx.config?.filename || ctx.data?.filename || filenameFromUrl(url);
|
||||
|
||||
ctx.data[outputVar] = file;
|
||||
ctx.data.filename = filename;
|
||||
ctx.data.contentType = contentType;
|
||||
const extra = {
|
||||
[outputVar]: file,
|
||||
filename,
|
||||
contentType,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{ outputVar, filename, contentType, length: file.length },
|
||||
"fetch-binary: saved",
|
||||
);
|
||||
|
||||
return ctx;
|
||||
return {
|
||||
output: { ...mergeData(ctx.data), ...extra },
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
}
|
||||
|
||||
fetchBinary.meta = {
|
||||
description: "Download a binary URL into ctx.data as a Buffer",
|
||||
description: "Download a binary URL and add the Buffer to output (keeps previous data fields)",
|
||||
previewConfigKey: "url",
|
||||
config: {
|
||||
url: { type: "string", required: false, description: "Direct download URL" },
|
||||
urlVar: { type: "string", required: false, description: "Key in ctx.data that holds the URL" },
|
||||
outputVar: { type: "string", default: "file", description: "ctx.data key for the Buffer" },
|
||||
outputVar: { type: "string", default: "file", description: "output key for the Buffer" },
|
||||
filename: { type: "string", required: false, description: "Override saved filename" },
|
||||
},
|
||||
input: {
|
||||
@@ -87,6 +99,11 @@ fetchBinary.meta = {
|
||||
filename: { type: "string" },
|
||||
contentType: { type: "string" },
|
||||
},
|
||||
context: {
|
||||
file: { type: "buffer", description: "Downloaded bytes (or ctx.config.outputVar)" },
|
||||
filename: { type: "string" },
|
||||
contentType: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
data: { attach: "https://example.com/image.png" },
|
||||
config: { outputVar: "file" },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { parse } from "node-html-parser";
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function serializeElement(el) {
|
||||
@@ -43,8 +44,6 @@ async function fetchHtml(ctx) {
|
||||
throw new Error("fetch-html: ctx.config.outputVar is required when selector or jsonata is set");
|
||||
}
|
||||
|
||||
ensureDataObject(ctx);
|
||||
|
||||
log.info({ url }, "fetch-html: fetching page");
|
||||
const response = await $axios.get(url);
|
||||
log.info(
|
||||
@@ -52,19 +51,20 @@ async function fetchHtml(ctx) {
|
||||
"fetch-html: fetch complete",
|
||||
);
|
||||
|
||||
ctx.data.httpResponse = String(response.data ?? "");
|
||||
/** @type {Record<string, unknown>} */
|
||||
const output = { httpResponse: String(response.data ?? "") };
|
||||
log.info(
|
||||
{ length: ctx.data.httpResponse.length },
|
||||
{ length: String(output.httpResponse).length },
|
||||
"fetch-html: saved httpResponse",
|
||||
);
|
||||
|
||||
if (selector) {
|
||||
log.info({ selector, outputVar }, "fetch-html: selecting elements");
|
||||
const root = parse(ctx.data.httpResponse);
|
||||
const root = parse(String(output.httpResponse));
|
||||
const elements = root.querySelectorAll(selector);
|
||||
ctx.data[outputVar] = elements.map(serializeElement);
|
||||
output[outputVar] = elements.map(serializeElement);
|
||||
log.info(
|
||||
{ outputVar, count: ctx.data[outputVar].length },
|
||||
{ outputVar, count: output[outputVar].length },
|
||||
"fetch-html: saved selector matches",
|
||||
);
|
||||
}
|
||||
@@ -72,20 +72,21 @@ async function fetchHtml(ctx) {
|
||||
if (jsonataExpr) {
|
||||
log.info({ outputVar, jsonata: jsonataExpr }, "fetch-html: evaluating jsonata");
|
||||
const expression = jsonata(jsonataExpr);
|
||||
const input = ctx.data[outputVar];
|
||||
const result = await expression.evaluate(input);
|
||||
ctx.data[outputVar] = result;
|
||||
const result = await expression.evaluate(output[outputVar]);
|
||||
output[outputVar] = result;
|
||||
log.info(
|
||||
{ outputVar, result: previewValue(result) },
|
||||
"fetch-html: saved jsonata result",
|
||||
);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return { output, context: { ...passContext(ctx), ...output } };
|
||||
}
|
||||
|
||||
fetchHtml.meta = {
|
||||
description: "Fetch HTML and optionally select elements or transform with JSONata",
|
||||
previewConfigKey: "url",
|
||||
tags: ["HTTP"],
|
||||
config: {
|
||||
url: { type: "string", required: true, description: "Page URL" },
|
||||
selector: { type: "string", required: false, description: "CSS selector" },
|
||||
@@ -98,7 +99,10 @@ fetchHtml.meta = {
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
httpResponse: { type: "string", description: "Raw HTML" },
|
||||
httpResponse: { type: "string", description: "Raw HTML (overwritten when outputVar is httpResponse)" },
|
||||
},
|
||||
context: {
|
||||
httpResponse: { type: "any", description: "Same keys as output" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
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<string, unknown>} */
|
||||
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;
|
||||
}
|
||||
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);
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
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",
|
||||
);
|
||||
|
||||
const output = {
|
||||
httpResponse: response.data,
|
||||
httpStatus: response.status,
|
||||
};
|
||||
log.info(
|
||||
{ status: output.httpStatus, length: responseSize(output.httpResponse) },
|
||||
"fetch-http: saved httpResponse",
|
||||
);
|
||||
|
||||
return { output, context: { ...passContext(ctx), ...output } };
|
||||
}
|
||||
|
||||
fetchHttp.meta = {
|
||||
description: "Fetch a URL and store the response body.",
|
||||
previewConfigKey: "url",
|
||||
tags: ["HTTP"],
|
||||
config: {
|
||||
url: { type: "string", required: true, description: "Request URL" },
|
||||
method: {
|
||||
type: "string",
|
||||
default: "GET",
|
||||
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
description: "HTTP method",
|
||||
},
|
||||
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,
|
||||
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
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" },
|
||||
},
|
||||
context: {
|
||||
httpResponse: { type: "any", description: "Same as output.httpResponse" },
|
||||
httpStatus: { type: "number", description: "Same as output.httpStatus" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
url: "https://httpbin.org/post",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: { hello: "world" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default fetchHttp;
|
||||
@@ -1,10 +1,11 @@
|
||||
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 passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function previewValue(value) {
|
||||
@@ -28,8 +29,6 @@ async function fetchRssFeed(ctx) {
|
||||
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: {
|
||||
@@ -46,7 +45,8 @@ async function fetchRssFeed(ctx) {
|
||||
"fetch-rss-feed: fetch complete",
|
||||
);
|
||||
|
||||
ctx.data.rssFeed = feed;
|
||||
/** @type {Record<string, unknown>} */
|
||||
const output = { rssFeed: feed };
|
||||
log.info(
|
||||
{ title: feed.title, itemCount },
|
||||
"fetch-rss-feed: saved rssFeed",
|
||||
@@ -56,24 +56,26 @@ async function fetchRssFeed(ctx) {
|
||||
log.info({ outputVar, jsonata: jsonataExpr }, "fetch-rss-feed: evaluating jsonata");
|
||||
const expression = jsonata(jsonataExpr);
|
||||
const result = await expression.evaluate(feed);
|
||||
ctx.data[outputVar] = result;
|
||||
output[outputVar] = result;
|
||||
log.info(
|
||||
{ outputVar, result: previewValue(result) },
|
||||
"fetch-rss-feed: saved jsonata result",
|
||||
);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return { output, context: { ...passContext(ctx), ...output } };
|
||||
}
|
||||
|
||||
fetchRssFeed.meta = {
|
||||
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
||||
previewConfigKey: "url",
|
||||
tags: ["RSS"],
|
||||
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",
|
||||
description: "Required when jsonata is set; key on output and context",
|
||||
},
|
||||
jsonata: {
|
||||
type: "string",
|
||||
@@ -87,6 +89,9 @@ fetchRssFeed.meta = {
|
||||
output: {
|
||||
rssFeed: { type: "object", description: "Parsed RSS/Atom feed from rss-parser" },
|
||||
},
|
||||
context: {
|
||||
rssFeed: { type: "object", description: "Same as output.rssFeed (plus outputVar when set)" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fingerprint(ctx) {
|
||||
@@ -28,13 +36,14 @@ async function fingerprint(ctx) {
|
||||
maxAge: ctx.config?.maxAge,
|
||||
});
|
||||
|
||||
ensureDataObject(ctx);
|
||||
ctx.data.fingerprint = result.hash;
|
||||
ctx.data.fingerprintChanged = result.changed;
|
||||
ctx.data.fingerprintPrevious = result.previous;
|
||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
||||
ctx.data.fingerprintAge = result.ageMs;
|
||||
ctx.data.fingerprintExpired = result.expired;
|
||||
const extra = {
|
||||
fingerprint: result.hash,
|
||||
fingerprintChanged: result.changed,
|
||||
fingerprintPrevious: result.previous,
|
||||
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||
fingerprintAge: result.ageMs,
|
||||
fingerprintExpired: result.expired,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{
|
||||
@@ -46,16 +55,22 @@ async function fingerprint(ctx) {
|
||||
"fingerprint: result",
|
||||
);
|
||||
|
||||
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||
const envelope = {
|
||||
output: { ...mergeData(ctx.data), ...extra },
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
if (!result.changed && skipRemaining) {
|
||||
ctx.skipRemaining = true;
|
||||
envelope.skipRemaining = true;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
fingerprint.meta = {
|
||||
description:
|
||||
"Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged",
|
||||
previewConfigKey: "key",
|
||||
reads: "ctx",
|
||||
config: {
|
||||
key: {
|
||||
type: "string",
|
||||
@@ -87,6 +102,14 @@ fingerprint.meta = {
|
||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
context: {
|
||||
fingerprint: { type: "string" },
|
||||
fingerprintChanged: { type: "boolean" },
|
||||
fingerprintPrevious: { type: "string", required: false },
|
||||
fingerprintAt: { type: "string", required: false },
|
||||
fingerprintAge: { type: "number", required: false },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
example: {
|
||||
data: { item: { guid: "https://example.com/post-1" } },
|
||||
config: {
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
// this script will get current time
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCurrentTime() {
|
||||
return {
|
||||
data: {
|
||||
function getCurrentTime(ctx) {
|
||||
const output = {
|
||||
datetime: new Date().toISOString(),
|
||||
processId: "1234"
|
||||
}
|
||||
}
|
||||
processId: "1234",
|
||||
};
|
||||
return { output, context: { ...passContext(ctx), ...output } };
|
||||
}
|
||||
|
||||
getCurrentTime.meta = {
|
||||
description: "Return the current time as ctx.data.datetime",
|
||||
description: "Return the current time as output.datetime",
|
||||
config: {},
|
||||
input: {},
|
||||
output: {
|
||||
datetime: { type: "string", description: "ISO timestamp" },
|
||||
processId: { type: "string" },
|
||||
},
|
||||
context: {
|
||||
datetime: { type: "string", description: "ISO timestamp" },
|
||||
processId: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {},
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function getSecret(ctx) {
|
||||
const name = ctx.config?.name;
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
@@ -9,19 +23,17 @@ async function getSecret(ctx) {
|
||||
: name;
|
||||
|
||||
const value = await $secrets.get(name);
|
||||
const base =
|
||||
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {};
|
||||
const data =
|
||||
base.data != null && typeof base.data === "object" && !Array.isArray(base.data)
|
||||
? { ...base.data }
|
||||
: {};
|
||||
data[as] = value;
|
||||
return { ...base, data };
|
||||
const extra = { [as]: value };
|
||||
return {
|
||||
output: { ...mergeData(ctx.data), ...extra },
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
}
|
||||
|
||||
getSecret.meta = {
|
||||
description:
|
||||
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
|
||||
"Load a named secret for this workflow owner onto output and context. The value is wrapped and redacted in logs.",
|
||||
previewConfigKey: "name",
|
||||
config: {
|
||||
name: {
|
||||
type: "string",
|
||||
@@ -31,16 +43,12 @@ getSecret.meta = {
|
||||
as: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ctx.data field to write (defaults to name)",
|
||||
description: "Field name on output and context (defaults to name)",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
data: {
|
||||
type: "object",
|
||||
description: "Previous ctx.data plus the retrieved Secret at [as]",
|
||||
},
|
||||
},
|
||||
output: {},
|
||||
context: {},
|
||||
example: {
|
||||
data: {},
|
||||
config: { name: "ntfy_token", as: "ntfyToken" },
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function jsonataFn(ctx) {
|
||||
log.info({ctx}, "jsonata: context")
|
||||
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);
|
||||
log.info({ result }, "jsonata: expression result");
|
||||
return result;
|
||||
return {
|
||||
output: result,
|
||||
context:
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? ctx.context
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
jsonataFn.meta = {
|
||||
description: "Evaluate a JSONata expression against ctx.data and return the result as the next context",
|
||||
description:
|
||||
"Evaluate a JSONata expression against the full ctx (data, context, config); the result is the next data",
|
||||
previewConfigKey: "expression",
|
||||
reads: "ctx",
|
||||
config: {
|
||||
expression: { type: "string", required: true, description: "JSONata expression" },
|
||||
expression: { type: "string", required: true, description: "JSONata expression against ctx" },
|
||||
},
|
||||
input: {},
|
||||
output: {},
|
||||
example: {
|
||||
data: { title: "Hello", url: "https://example.com" },
|
||||
config: {
|
||||
expression: '{"data": {"title": title, "message": title, "attach": url}}',
|
||||
expression: '{"title": data.title, "message": data.title, "attach": data.url}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function ntfyHeaders(ctx) {
|
||||
const headers = {};
|
||||
|
||||
@@ -72,18 +79,21 @@ async function ntfy(ctx) {
|
||||
"ntfy: skipped, fingerprint unchanged",
|
||||
);
|
||||
return {
|
||||
output: {
|
||||
sent: "false",
|
||||
skipped: true,
|
||||
fingerprint: checked.hash,
|
||||
fingerprintAt: checked.previousAt,
|
||||
fingerprintAge: checked.ageMs,
|
||||
},
|
||||
context: passContext(ctx),
|
||||
};
|
||||
}
|
||||
fp = checked;
|
||||
}
|
||||
|
||||
const headers = ntfyHeaders(ctx);
|
||||
const ntfyUrl = ctx.config?.url || "https://ntfy.sh/scrunner";
|
||||
const ntfyUrl = ctx.config?.url || "https://ntfy.sh/jerapah-flow";
|
||||
|
||||
if (hasFile) {
|
||||
const filename = ctx.data?.filename || "attachment";
|
||||
@@ -114,7 +124,7 @@ async function ntfy(ctx) {
|
||||
log.info("ntfy sending message to %s", ntfyUrl);
|
||||
log.info("ntfy messsage: %s", truncatedMessage);
|
||||
|
||||
await $axios.post(ntfyUrl, ctx.data?.message || "Hello from scrunner", {
|
||||
await $axios.post(ntfyUrl, ctx.data?.message || "Hello from jerapah-flow", {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -126,15 +136,17 @@ async function ntfy(ctx) {
|
||||
sent.fingerprint = stored.hash;
|
||||
sent.fingerprintAt = stored.at;
|
||||
}
|
||||
return sent;
|
||||
return { output: sent, context: passContext(ctx) };
|
||||
}
|
||||
|
||||
ntfy.meta = {
|
||||
description: "Send a message or file to an ntfy topic",
|
||||
previewConfigKey: "url",
|
||||
tags: ["channel"],
|
||||
config: {
|
||||
url: {
|
||||
type: "string",
|
||||
default: "https://ntfy.sh/scrunner",
|
||||
default: "https://ntfy.sh/jerapah-flow",
|
||||
description: "ntfy topic URL",
|
||||
},
|
||||
fingerprint: {
|
||||
@@ -145,7 +157,7 @@ ntfy.meta = {
|
||||
fingerprintJsonata: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "JSONata against ctx; default hashes title, message, attach, filename, contentType",
|
||||
description: "JSONata against full ctx; default hashes data title, message, attach, filename, contentType",
|
||||
},
|
||||
fingerprintMaxAge: {
|
||||
type: "string",
|
||||
@@ -161,12 +173,13 @@ ntfy.meta = {
|
||||
filename: { type: "string", required: false },
|
||||
contentType: { type: "string", required: false },
|
||||
},
|
||||
reads: "ctx",
|
||||
output: {
|
||||
sent: { type: "string", description: "Replaces the workflow context with { sent: \"true\" }" },
|
||||
sent: { type: "string", description: '"true" when sent, "false" when fingerprint skipped' },
|
||||
},
|
||||
example: {
|
||||
data: { title: "Hello", message: "Hello from scrunner" },
|
||||
config: { url: "https://ntfy.sh/scrunner", fingerprint: true },
|
||||
data: { title: "Hello", message: "Hello from jerapah-flow" },
|
||||
config: { url: "https://ntfy.sh/jerapah-flow", fingerprint: true },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -70,15 +70,22 @@ async function renderTemplate(ctx) {
|
||||
const text = htmlToText(html);
|
||||
|
||||
return {
|
||||
output: {
|
||||
html,
|
||||
text,
|
||||
template: templateName,
|
||||
},
|
||||
context:
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? ctx.context
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
renderTemplate.meta = {
|
||||
description:
|
||||
"Render an HTML template from Responses (kind: template) with Mustache and return html + plain-text fallback",
|
||||
previewConfigKey: "template",
|
||||
config: {
|
||||
template: {
|
||||
type: "string",
|
||||
|
||||
@@ -218,6 +218,7 @@ async function sendEmail(ctx) {
|
||||
log.info({ messageId: info.messageId }, "send-email: message sent");
|
||||
|
||||
return {
|
||||
output: {
|
||||
sent: true,
|
||||
messageId: info.messageId ?? null,
|
||||
from,
|
||||
@@ -225,17 +226,24 @@ async function sendEmail(ctx) {
|
||||
cc: cc ?? null,
|
||||
bcc: bcc ?? null,
|
||||
subject,
|
||||
},
|
||||
context:
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? ctx.context
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
sendEmail.meta = {
|
||||
description: "Send an email via SMTP (nodemailer); plain text, HTML, or both",
|
||||
previewConfigKey: "from",
|
||||
tags: ["channel"],
|
||||
config: {
|
||||
service: {
|
||||
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",
|
||||
@@ -426,13 +434,11 @@ sendEmail.meta = {
|
||||
to: ["recipient@example.com", "other@example.com"],
|
||||
cc: "manager@example.com",
|
||||
bcc: "audit@example.com",
|
||||
subject: "Hello from scrunner",
|
||||
subject: "Hello from JerapahFlow",
|
||||
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",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function triggerWorkflow(ctx) {
|
||||
const name = ctx.config?.name;
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
@@ -9,25 +16,23 @@ async function triggerWorkflow(ctx) {
|
||||
let data = ctx.data;
|
||||
const expression = ctx.config?.expression;
|
||||
if (typeof expression === "string" && expression.length > 0) {
|
||||
const result = jsonata(expression).evaluate(ctx.data);
|
||||
const result = jsonata(expression).evaluate(ctx);
|
||||
data = await result;
|
||||
}
|
||||
|
||||
const started = await $workflows.trigger(name, data);
|
||||
const triggered = { name, runId: started?.runId ?? null };
|
||||
|
||||
const base =
|
||||
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {};
|
||||
|
||||
if (base.data != null && typeof base.data === "object" && !Array.isArray(base.data)) {
|
||||
return { ...base, data: { ...base.data, triggered } };
|
||||
}
|
||||
return { ...base, triggered };
|
||||
return {
|
||||
output: { name, runId: started?.runId ?? null },
|
||||
context: passContext(ctx),
|
||||
};
|
||||
}
|
||||
|
||||
triggerWorkflow.meta = {
|
||||
description:
|
||||
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
|
||||
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape the destination input with JSONata against full ctx.",
|
||||
previewConfigKey: "name",
|
||||
tags: ["trigger"],
|
||||
reads: "ctx",
|
||||
config: {
|
||||
name: {
|
||||
type: "string",
|
||||
@@ -38,15 +43,13 @@ triggerWorkflow.meta = {
|
||||
type: "string",
|
||||
required: false,
|
||||
description:
|
||||
"Optional JSONata expression evaluated against ctx.data; result becomes the destination run input",
|
||||
"Optional JSONata expression evaluated against ctx; result becomes the destination run input",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
triggered: {
|
||||
type: "object",
|
||||
description: "Record of the kicked-off run ({ name, runId }); under data when data is an object",
|
||||
},
|
||||
name: { type: "string", description: "Destination workflow name" },
|
||||
runId: { type: "string", description: "Started run id (null if the destination failed to start)" },
|
||||
},
|
||||
example: {
|
||||
data: {
|
||||
@@ -56,7 +59,7 @@ triggerWorkflow.meta = {
|
||||
config: {
|
||||
name: "notify-comic",
|
||||
expression:
|
||||
'{ "title": title, "message": title, "attach": url }',
|
||||
'{ "title": data.title, "message": data.title, "attach": data.url }',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,10 +11,11 @@ const AUTH_TAG_LEN = 16;
|
||||
*/
|
||||
export function resolveSecretsKeyMaterial() {
|
||||
const raw =
|
||||
process.env.JERAPAH_FLOW_SECRETS_KEY ??
|
||||
process.env.SCRUNNER_SECRETS_KEY ??
|
||||
(process.env.NODE_ENV === "production" ? "" : DEV_DEFAULT);
|
||||
if (!raw) {
|
||||
throw new Error("SCRUNNER_SECRETS_KEY is required in production");
|
||||
throw new Error("JERAPAH_FLOW_SECRETS_KEY is required in production");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as store from "../../store.js";
|
||||
|
||||
export const COOKIE = "scrunner_token";
|
||||
export const COOKIE = "jerapah_flow_token";
|
||||
export const OPEN_API_ROUTES = new Set([
|
||||
"GET /auth/bootstrap",
|
||||
"POST /auth/register",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "fs";
|
||||
import {
|
||||
clearScriptCache,
|
||||
inspectScriptSource,
|
||||
@@ -5,6 +6,8 @@ import {
|
||||
} from "../../script-sandbox.js";
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||
import { normalizeStepResult } from "../../step-result.js";
|
||||
import { resolveConfigRefs } from "../../config-refs.js";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
@@ -21,11 +24,27 @@ export default function scriptsPluginFactory(registry) {
|
||||
content == null
|
||||
? { meta: null, metaError: "script not found" }
|
||||
: inspectScriptSource(name, content);
|
||||
return { name, ...inspected };
|
||||
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
|
||||
});
|
||||
return { scripts };
|
||||
});
|
||||
|
||||
fastify.get("/scripts/:name/icon", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const icon = fsStore.resolveScriptIcon(name);
|
||||
if (icon == null) return reply.code(404).send({ error: "icon not found" });
|
||||
const body = fs.readFileSync(icon.filePath);
|
||||
return reply
|
||||
.type(icon.contentType)
|
||||
.header("Cache-Control", "private, max-age=60")
|
||||
.send(body);
|
||||
});
|
||||
|
||||
fastify.get("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
@@ -35,7 +54,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
const content = fsStore.readScript(name);
|
||||
if (content == null) return reply.code(404).send({ error: "script not found" });
|
||||
return { name, content, ...inspectScriptSource(name, content) };
|
||||
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
|
||||
});
|
||||
|
||||
fastify.put("/scripts/:name", async (req, reply) => {
|
||||
@@ -85,7 +104,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ (
|
||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
@@ -101,24 +120,37 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
config: body.config ?? null,
|
||||
};
|
||||
const incomingContext =
|
||||
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
|
||||
? body.context
|
||||
: {};
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(body.config ?? null, {
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
});
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
||||
log,
|
||||
workflowName: "dry-run",
|
||||
owner,
|
||||
});
|
||||
const output = await fn(ctx);
|
||||
const raw = await fn(ctx);
|
||||
const result = normalizeStepResult(raw, incomingContext, name);
|
||||
return {
|
||||
status: "success",
|
||||
output: safeSerialize(output),
|
||||
output: safeSerialize(result.output),
|
||||
context: safeSerialize(result.context),
|
||||
skipRemaining: result.skipRemaining,
|
||||
error: null,
|
||||
logs,
|
||||
durationMs: Date.now() - started,
|
||||
@@ -130,6 +162,8 @@ export default function scriptsPluginFactory(registry) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
context: null,
|
||||
skipRemaining: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
logs,
|
||||
durationMs: Date.now() - started,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertVariableName,
|
||||
assertVariableType,
|
||||
deleteVariable,
|
||||
getVariableById,
|
||||
listVariables,
|
||||
upsertVariable,
|
||||
} from "../../variables-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function variablesPlugin(fastify) {
|
||||
fastify.get("/variables", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const variables = await listVariables({ owner });
|
||||
return { variables };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.put("/variables", async (req, reply) => {
|
||||
const body = /** @type {{ owner?: string, name?: string, type?: unknown, value?: unknown }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertVariableName(String(body.name ?? ""));
|
||||
assertVariableType(body.type);
|
||||
const variable = await upsertVariable({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
type: body.type,
|
||||
value: body.value,
|
||||
});
|
||||
return reply.send({ variable });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/variables/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getVariableById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "variable not found" });
|
||||
}
|
||||
await deleteVariable(id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
authLabel,
|
||||
validateWorkflowHttpTriggers,
|
||||
} from "../../workflow-http-validate.js";
|
||||
import {
|
||||
duplicateWorkflowYaml,
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
} from "../../workflow-duplicate.js";
|
||||
|
||||
function triggerSummary(owner, workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return [];
|
||||
@@ -32,7 +37,7 @@ function scriptNames(workflow) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
names.push(parsed.kind === "set" ? `set:${parsed.as}` : parsed.script);
|
||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
@@ -259,6 +264,101 @@ export default function workflowsPluginFactory(registry) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/duplicate", 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 source = fsStore.readWorkflowYaml(owner, file);
|
||||
if (source == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
|
||||
const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {});
|
||||
let destOwner = owner;
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
if (typeof body.owner !== "string") {
|
||||
return reply.code(400).send({ error: "owner must be a string" });
|
||||
}
|
||||
try {
|
||||
destOwner = fsStore.assertOwner(body.owner);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let destFile;
|
||||
try {
|
||||
if (body.file == null || body.file === "") {
|
||||
destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner));
|
||||
} else if (typeof body.file !== "string") {
|
||||
return reply.code(400).send({ error: "file must be a string" });
|
||||
} else {
|
||||
destFile = ensureWorkflowFilename(body.file);
|
||||
}
|
||||
fsStore.assertWorkflowFile(destFile);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
if (destOwner === owner && destFile === file) {
|
||||
return reply.code(400).send({ error: "cannot duplicate onto itself" });
|
||||
}
|
||||
if (fsStore.readWorkflowYaml(destOwner, destFile) != null) {
|
||||
return reply.code(409).send({ error: "workflow already exists" });
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = duplicateWorkflowYaml(source, {
|
||||
sourceFile: file,
|
||||
destFile,
|
||||
rewriteHttpPaths: destOwner === owner,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
compileWorkflowScripts(parsed?.scripts);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await validateWorkflowHttpTriggers(parsed);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
fsStore.writeWorkflowYaml(destOwner, destFile, content);
|
||||
const registered = fsStore.readRegisters(destOwner);
|
||||
if (!registered.includes(destFile)) {
|
||||
registered.push(destFile);
|
||||
fsStore.writeRegisters(destOwner, registered);
|
||||
}
|
||||
registry.reregister();
|
||||
return reply.code(201).send({ owner: destOwner, file: destFile });
|
||||
});
|
||||
|
||||
fastify.post("/workflows/:owner/:file/run", async (req, reply) => {
|
||||
const { owner, file } = /** @type {{ owner: string, file: string }} */ (
|
||||
req.params
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function normalizeContext(value) {
|
||||
return isPlainObject(value) ? /** @type {Record<string, unknown>} */ (value) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* output: unknown,
|
||||
* context: Record<string, unknown>,
|
||||
* skipRemaining: boolean,
|
||||
* }} StepResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @param {unknown} incomingContext
|
||||
* @param {string} [label]
|
||||
* @returns {StepResult}
|
||||
*/
|
||||
export function normalizeStepResult(raw, incomingContext, label = "script") {
|
||||
const incoming = normalizeContext(incomingContext);
|
||||
if (!isPlainObject(raw)) {
|
||||
const got = raw == null ? String(raw) : typeof raw;
|
||||
throw new Error(`${label} must return { output, context }. Got ${got}`);
|
||||
}
|
||||
if (!("output" in raw) && ("data" in raw || "config" in raw)) {
|
||||
throw new Error(
|
||||
`${label} must return { output, context }. Returning the full ctx is no longer valid.`,
|
||||
);
|
||||
}
|
||||
|
||||
let context = incoming;
|
||||
if ("context" in raw) {
|
||||
if (raw.context == null) {
|
||||
context = incoming;
|
||||
} else if (!isPlainObject(raw.context)) {
|
||||
throw new Error(`${label} returned a non-object context`);
|
||||
} else {
|
||||
context = /** @type {Record<string, unknown>} */ (raw.context);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output: "output" in raw ? raw.output : null,
|
||||
context,
|
||||
skipRemaining: raw.skipRemaining === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistable envelope (omit skipRemaining unless set).
|
||||
* @param {StepResult} result
|
||||
*/
|
||||
export function storedEnvelope(result) {
|
||||
/** @type {{ output: unknown, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||
const out = { output: result.output, context: result.context };
|
||||
if (result.skipRemaining) out.skipRemaining = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next step ctx (without config).
|
||||
* @param {StepResult} result
|
||||
*/
|
||||
export function chainCtx(result) {
|
||||
return { data: result.output, context: result.context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merge sibling context diffs vs a shared snapshot.
|
||||
* Fails if two siblings change the same key.
|
||||
*
|
||||
* @param {unknown} snapshot
|
||||
* @param {Array<{ id: string, context: unknown }>} patches
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function mergeContextWave(snapshot, patches) {
|
||||
const base = normalizeContext(snapshot);
|
||||
/** @type {Map<string, string>} */
|
||||
const writers = new Map();
|
||||
/** @type {Map<string, { deleted: true } | { value: unknown }>} */
|
||||
const changes = new Map();
|
||||
|
||||
for (const patch of patches) {
|
||||
const who = patch.id;
|
||||
const next = normalizeContext(patch.context);
|
||||
const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
const inBase = Object.prototype.hasOwnProperty.call(base, key);
|
||||
const inNext = Object.prototype.hasOwnProperty.call(next, key);
|
||||
if (inBase && inNext && Object.is(base[key], next[key])) continue;
|
||||
if (!inBase && !inNext) continue;
|
||||
if (inBase && !inNext) {
|
||||
rememberChange(writers, changes, key, who, { deleted: true });
|
||||
continue;
|
||||
}
|
||||
if (!inBase || !Object.is(base[key], next[key])) {
|
||||
rememberChange(writers, changes, key, who, { value: next[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = { ...base };
|
||||
for (const [key, spec] of changes) {
|
||||
if ("deleted" in spec) delete merged[key];
|
||||
else merged[key] = spec.value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string, string>} writers
|
||||
* @param {Map<string, { deleted: true } | { value: unknown }>} changes
|
||||
* @param {string} key
|
||||
* @param {string} who
|
||||
* @param {{ deleted: true } | { value: unknown }} spec
|
||||
*/
|
||||
function rememberChange(writers, changes, key, who, spec) {
|
||||
const previous = writers.get(key);
|
||||
if (previous != null && previous !== who) {
|
||||
throw new Error(
|
||||
`DAG context key conflict: "${key}" written by "${previous}" and "${who}"`,
|
||||
);
|
||||
}
|
||||
writers.set(key, who);
|
||||
changes.set(key, spec);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { migrate, db } from "../db.js";
|
||||
import { upsertSecret, deleteSecret } from "../secrets-store.js";
|
||||
import { deleteVariable, upsertVariable } from "../variables-store.js";
|
||||
import { Secret } from "../secret-value.js";
|
||||
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function assertRejects(fn, match) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (match && !message.includes(match)) {
|
||||
throw new Error(`rejected with "${message}", expected to include "${match}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected to reject (${match ?? "any error"})`);
|
||||
}
|
||||
|
||||
const owner = "default";
|
||||
const workflowKey = "default/config-refs-smoke.yaml";
|
||||
const ctx = { owner, workflowKey, context: {} };
|
||||
|
||||
function assertParse(value, expected) {
|
||||
const got = parseConfigRef(value);
|
||||
if (expected == null) {
|
||||
assert(got == null, `expected null parse for ${JSON.stringify(value)}, got ${JSON.stringify(got)}`);
|
||||
return;
|
||||
}
|
||||
assert(got != null, `expected parse for ${JSON.stringify(value)}`);
|
||||
assert(got.kind === expected.kind, `kind ${got.kind} !== ${expected.kind}`);
|
||||
assert(got.name === expected.name, `name ${JSON.stringify(got.name)} !== ${JSON.stringify(expected.name)}`);
|
||||
}
|
||||
|
||||
assertParse("password123", null);
|
||||
assertParse("$FOO_bar", null);
|
||||
assertParse("$SECRET", null);
|
||||
assertParse(" password123 ", null);
|
||||
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
|
||||
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
|
||||
assertParse("Bearer $SECRET_x", null);
|
||||
assertParse("$KV_modem password", null);
|
||||
assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" });
|
||||
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
|
||||
assertParse("$SECRET_", { kind: "secret", name: "" });
|
||||
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
|
||||
|
||||
{
|
||||
const literal = await resolveConfigRefs("password123", ctx);
|
||||
assert(literal === "password123", "literal passthrough");
|
||||
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
|
||||
assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
|
||||
const kvLiteral = await resolveConfigRefs("$KV_modem_password", ctx);
|
||||
assert(kvLiteral === "$KV_modem_password", "$KV_ stays literal");
|
||||
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
|
||||
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
|
||||
const number = await resolveConfigRefs(42, ctx);
|
||||
assert(number === 42, "number passthrough");
|
||||
}
|
||||
|
||||
const secret = await upsertSecret({
|
||||
owner,
|
||||
name: "config_refs_smoke_token",
|
||||
value: "s3cret-ok",
|
||||
});
|
||||
const varUrl = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_url",
|
||||
type: "string",
|
||||
value: "https://example.test",
|
||||
});
|
||||
const varRetry = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_retry",
|
||||
type: "number",
|
||||
value: 3,
|
||||
});
|
||||
const varDebug = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_debug",
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
|
||||
try {
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve trimmed");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
|
||||
assert(resolved === "https://example.test", "var string");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx);
|
||||
assert(resolved === 3, "var number stays number");
|
||||
assert(typeof resolved === "number", "var number type");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx);
|
||||
assert(resolved === false, "var boolean stays false");
|
||||
assert(typeof resolved === "boolean", "var boolean type");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_token", {
|
||||
...ctx,
|
||||
context: { token: "ctx-token-ok" },
|
||||
});
|
||||
assert(resolved === "ctx-token-ok", "context string");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_n", {
|
||||
...ctx,
|
||||
context: { n: 7 },
|
||||
});
|
||||
assert(resolved === "7", "context number stringify");
|
||||
}
|
||||
{
|
||||
const wrapped = new Secret("wrapped-secret-ok");
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_tok", {
|
||||
...ctx,
|
||||
context: { tok: wrapped },
|
||||
});
|
||||
assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
|
||||
}
|
||||
|
||||
{
|
||||
const nested = await resolveConfigRefs(
|
||||
{
|
||||
url: "$VAR_config_refs_smoke_url",
|
||||
retry: "$VAR_config_refs_smoke_retry",
|
||||
debug: "$VAR_config_refs_smoke_debug",
|
||||
password: "$SECRET_config_refs_smoke_token",
|
||||
headers: { Authorization: "$KV_modem_password" },
|
||||
extra: ["$CONTEXT_token", "plain"],
|
||||
},
|
||||
{ ...ctx, context: { token: "ctx-token-ok" } },
|
||||
);
|
||||
assert(nested.url === "https://example.test", "nested var string");
|
||||
assert(nested.retry === 3, "nested var number");
|
||||
assert(nested.debug === false, "nested var boolean");
|
||||
assert(nested.password === "s3cret-ok", "nested secret");
|
||||
assert(nested.headers.Authorization === "$KV_modem_password", "nested $KV_ stays literal");
|
||||
assert(nested.extra[0] === "ctx-token-ok", "nested array context");
|
||||
assert(nested.extra[1] === "plain", "nested array literal");
|
||||
}
|
||||
|
||||
const data = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const config = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const resolvedConfig = await resolveConfigRefs(config, ctx);
|
||||
assert(resolvedConfig.password === "s3cret-ok", "config resolved");
|
||||
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked");
|
||||
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated");
|
||||
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx),
|
||||
'secret "does_not_exist_xyz" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_not valid", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_missing", ctx),
|
||||
'context "missing" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
resolveConfigRefs("$CONTEXT_obj", {
|
||||
...ctx,
|
||||
context: { obj: { a: 1 } },
|
||||
}),
|
||||
'context "obj" is not a scalar',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_", ctx),
|
||||
"empty context key",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx),
|
||||
'variable "does_not_exist_xyz" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_not valid", ctx),
|
||||
"invalid variable name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_", ctx),
|
||||
"empty variable name",
|
||||
);
|
||||
} finally {
|
||||
await deleteSecret(secret.id);
|
||||
await deleteVariable(varUrl.id);
|
||||
await deleteVariable(varRetry.id);
|
||||
await deleteVariable(varDebug.id);
|
||||
}
|
||||
|
||||
console.log("config-refs smoke test passed");
|
||||
await db.destroy();
|
||||
@@ -92,7 +92,7 @@ export default async function (ctx) {
|
||||
const scriptOut = await runScriptSource(
|
||||
"fingerprint-smoke.js",
|
||||
script,
|
||||
{ data: { n: 1 } },
|
||||
{ data: { n: 1 }, context: {} },
|
||||
{ log, workflowName: ns },
|
||||
);
|
||||
if (!scriptOut.changed) throw new Error("sandbox $fingerprint.claim should be new");
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { migrate, db } from "../db.js";
|
||||
import { runScriptSource } from "../script-sandbox.js";
|
||||
import { log } from "../logger.js";
|
||||
import {
|
||||
deleteVariable,
|
||||
encodeVariableValue,
|
||||
getVariablePlain,
|
||||
upsertVariable,
|
||||
} from "../variables-store.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function assertThrows(fn, match) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (match && !message.includes(match)) {
|
||||
throw new Error(`threw "${message}", expected to include "${match}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected to throw (${match ?? "any error"})`);
|
||||
}
|
||||
|
||||
const owner = "default";
|
||||
|
||||
await assertThrows(
|
||||
() => encodeVariableValue("number", "3"),
|
||||
"finite number",
|
||||
);
|
||||
await assertThrows(
|
||||
() => encodeVariableValue("number", NaN),
|
||||
"finite number",
|
||||
);
|
||||
await assertThrows(
|
||||
() => encodeVariableValue("boolean", "true"),
|
||||
"boolean",
|
||||
);
|
||||
await assertThrows(
|
||||
() => encodeVariableValue("string", 3),
|
||||
"string",
|
||||
);
|
||||
|
||||
const created = [];
|
||||
try {
|
||||
const str = await upsertVariable({
|
||||
owner,
|
||||
name: "variables_smoke_url",
|
||||
type: "string",
|
||||
value: "https://n.0dev.web.id/system",
|
||||
});
|
||||
created.push(str.id);
|
||||
assert(str.value === "https://n.0dev.web.id/system", "string stored");
|
||||
assert(
|
||||
(await getVariablePlain(owner, "variables_smoke_url")) === str.value,
|
||||
"string plain",
|
||||
);
|
||||
|
||||
const num = await upsertVariable({
|
||||
owner,
|
||||
name: "variables_smoke_retry",
|
||||
type: "number",
|
||||
value: 3,
|
||||
});
|
||||
created.push(num.id);
|
||||
assert(num.value === 3 && typeof num.value === "number", "number stored");
|
||||
|
||||
const flag = await upsertVariable({
|
||||
owner,
|
||||
name: "variables_smoke_debug",
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
created.push(flag.id);
|
||||
assert(flag.value === false, "boolean false stored");
|
||||
|
||||
const updated = await upsertVariable({
|
||||
owner,
|
||||
name: "variables_smoke_retry",
|
||||
type: "number",
|
||||
value: 9,
|
||||
});
|
||||
assert(updated.id === num.id, "upsert same id");
|
||||
assert(updated.value === 9, "upsert number");
|
||||
|
||||
await assertThrows(
|
||||
() =>
|
||||
upsertVariable({
|
||||
owner,
|
||||
name: "variables_smoke_bad",
|
||||
type: "number",
|
||||
value: "abc",
|
||||
}),
|
||||
"finite number",
|
||||
);
|
||||
|
||||
const scriptOut = await runScriptSource(
|
||||
"variables-smoke.js",
|
||||
`export default async function () {
|
||||
const url = await $vars.get("variables_smoke_url");
|
||||
const retry = await $vars.get("variables_smoke_retry");
|
||||
const debug = await $vars.get("variables_smoke_debug");
|
||||
return { url, retry, debug, retryType: typeof retry, debugType: typeof debug };
|
||||
}`,
|
||||
{ data: {} },
|
||||
{ log, workflowName: "default/variables-smoke.yaml", owner },
|
||||
);
|
||||
assert(scriptOut.url === "https://n.0dev.web.id/system", "script $vars string");
|
||||
assert(scriptOut.retry === 9 && scriptOut.retryType === "number", "script $vars number");
|
||||
assert(scriptOut.debug === false && scriptOut.debugType === "boolean", "script $vars boolean");
|
||||
|
||||
await assertThrows(
|
||||
() =>
|
||||
runScriptSource(
|
||||
"variables-smoke.js",
|
||||
`export default async function () {
|
||||
return $vars.get("does_not_exist_xyz");
|
||||
}`,
|
||||
{ data: {} },
|
||||
{ log, workflowName: "default/variables-smoke.yaml", owner },
|
||||
),
|
||||
'variable "does_not_exist_xyz" not found',
|
||||
);
|
||||
} finally {
|
||||
for (const id of created) {
|
||||
await deleteVariable(id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("variables smoke test passed");
|
||||
await db.destroy();
|
||||
@@ -0,0 +1,190 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_STRING_BYTES = 64 * 1024;
|
||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertVariableName(name) {
|
||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid variable name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"string" | "number" | "boolean"}
|
||||
*/
|
||||
export function assertVariableType(type) {
|
||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||
throw httpError("type must be string, number, or boolean");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeVariableValue(type, value) {
|
||||
if (type === "string") {
|
||||
if (typeof value !== "string") {
|
||||
throw httpError("value must be a string");
|
||||
}
|
||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw httpError("value must be a finite number");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw httpError("value must be a boolean");
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {string} stored
|
||||
* @returns {string | number | boolean}
|
||||
*/
|
||||
export function decodeVariableValue(type, stored) {
|
||||
if (type === "string") return stored;
|
||||
if (type === "number") {
|
||||
const n = Number(stored);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicVariable(row) {
|
||||
const type = assertVariableType(row.type);
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type,
|
||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listVariables(filters = {}) {
|
||||
let q = db("variables")
|
||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicVariable(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getVariableById(id) {
|
||||
const row = await db("variables").where({ id }).first();
|
||||
return row ? publicVariable(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||
*/
|
||||
export async function upsertVariable({ owner, name, type, value }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const variableType = assertVariableType(type);
|
||||
const encoded = encodeVariableValue(variableType, value);
|
||||
const now = nowIso();
|
||||
const existing = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("variables")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("variables").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: variableName,
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteVariable(id) {
|
||||
const n = await db("variables").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed primitive for an owner/name. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | number | boolean | null>}
|
||||
*/
|
||||
export async function getVariablePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const row = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import yaml from "yaml";
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
export function workflowFileStem(file) {
|
||||
return String(file).replace(/\.ya?ml$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused copy filename: `track.yaml` → `track-copy.yaml`,
|
||||
* `track-copy.yaml` → `track-copy-2.yaml`.
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
|
||||
export function nextCopyName(name) {
|
||||
const trimmed = String(name ?? "").trim();
|
||||
if (!trimmed) return "copy";
|
||||
const match = trimmed.match(/^(.*) \(copy(?: (\d+))?\)$/);
|
||||
if (!match) return `${trimmed} (copy)`;
|
||||
const n = match[2] ? Number(match[2]) + 1 : 2;
|
||||
return `${match[1]} (copy ${n})`;
|
||||
}
|
||||
|
||||
export function httpPathCopySuffix(sourceFile, destFile) {
|
||||
const src = workflowFileStem(sourceFile);
|
||||
const dest = workflowFileStem(destFile);
|
||||
if (dest.startsWith(`${src}-`) && dest.length > src.length + 1) {
|
||||
return dest.slice(src.length + 1);
|
||||
}
|
||||
if (dest === src) return "copy";
|
||||
return dest || "copy";
|
||||
}
|
||||
|
||||
function suffixHttpPath(path, suffix) {
|
||||
const trimmed = String(path).replace(/\/+$/, "");
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const safe = String(suffix).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "copy";
|
||||
return `${withSlash}-${safe}`;
|
||||
}
|
||||
|
||||
function rewriteHttpTriggerPaths(doc, suffix) {
|
||||
const triggers = doc.get("triggers");
|
||||
if (!yaml.isSeq(triggers)) return;
|
||||
for (const item of triggers.items) {
|
||||
if (!yaml.isMap(item)) continue;
|
||||
if (String(item.get("type") ?? "").toLowerCase() !== "http") continue;
|
||||
const path = item.get("path");
|
||||
if (typeof path !== "string" || !path.trim()) continue;
|
||||
item.set("path", suffixHttpPath(path, suffix));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy workflow YAML: append " (copy)" to name, disable, optionally rewrite HTTP paths.
|
||||
* Preserves comments via YAML CST.
|
||||
* @param {string} content
|
||||
* @param {{ sourceFile: string, destFile: string, rewriteHttpPaths?: boolean }} opts
|
||||
*/
|
||||
export function duplicateWorkflowYaml(content, opts) {
|
||||
const sourceFile = opts?.sourceFile ?? "";
|
||||
const destFile = opts?.destFile ?? "";
|
||||
const rewriteHttpPaths = opts?.rewriteHttpPaths !== false;
|
||||
|
||||
const doc = yaml.parseDocument(content);
|
||||
if (doc.errors?.length) {
|
||||
const err = new Error(doc.errors[0]?.message ?? "invalid yaml");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const parsed = doc.toJSON();
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const err = new Error("workflow yaml must be an object");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const currentName = doc.get("name");
|
||||
if (typeof currentName === "string" && currentName.trim()) {
|
||||
doc.set("name", nextCopyName(currentName));
|
||||
} else {
|
||||
doc.set("name", workflowFileStem(destFile) || "copy");
|
||||
}
|
||||
doc.set("enabled", false);
|
||||
|
||||
if (rewriteHttpPaths) {
|
||||
rewriteHttpTriggerPaths(doc, httpPathCopySuffix(sourceFile, destFile));
|
||||
}
|
||||
|
||||
return String(doc);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
const AS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const RESERVED_AS = new Set(["data", "config"]);
|
||||
|
||||
export const SET_STEP_SCRIPT = "set";
|
||||
|
||||
/**
|
||||
@@ -12,7 +9,6 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* script: string,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* as?: undefined,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
@@ -21,11 +17,10 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* @typedef {{
|
||||
* kind: "set",
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* config: { expression: string, as: string },
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* as: string,
|
||||
* id: string | null,
|
||||
* needsKind: "none",
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedSetStep
|
||||
@@ -163,9 +158,6 @@ export function compileWorkflowScripts(scripts) {
|
||||
if (dagMode) {
|
||||
for (const step of steps) {
|
||||
const who = step.id ?? String(step.index);
|
||||
if (step.kind === "set") {
|
||||
throw new Error(`set steps are not allowed in DAG workflows (step ${who})`);
|
||||
}
|
||||
if (step.when) {
|
||||
throw new Error(`when is not allowed in DAG workflows (step ${who})`);
|
||||
}
|
||||
@@ -227,13 +219,10 @@ export function compileWorkflowScripts(scripts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a script return's `.data`; otherwise treat the whole return as data.
|
||||
* Upstream DAG output stored in outputsById (already the pipe value).
|
||||
* @param {unknown} result
|
||||
*/
|
||||
export function extractStepData(result) {
|
||||
if (result && typeof result === "object" && !Array.isArray(result) && "data" in result) {
|
||||
return result.data;
|
||||
}
|
||||
export function extractStepOutput(result) {
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
@@ -248,12 +237,12 @@ export function mergeStepData(step, outputsById, triggerData) {
|
||||
return triggerData;
|
||||
}
|
||||
if (step.needsKind === "list" && step.needs.length === 1) {
|
||||
return extractStepData(outputsById.get(step.needs[0].from));
|
||||
return extractStepOutput(outputsById.get(step.needs[0].from));
|
||||
}
|
||||
/** @type {Record<string, unknown>} */
|
||||
const data = {};
|
||||
for (const { alias, from } of step.needs) {
|
||||
data[alias] = extractStepData(outputsById.get(from));
|
||||
data[alias] = extractStepOutput(outputsById.get(from));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -263,34 +252,24 @@ export function mergeStepData(step, outputsById, triggerData) {
|
||||
* @returns {ParsedSetStep}
|
||||
*/
|
||||
function parseSetStep(step) {
|
||||
if (step.needs != null) {
|
||||
throw new Error("needs is not allowed on set steps");
|
||||
}
|
||||
const spec = step.set;
|
||||
if (spec == null || typeof spec !== "object" || Array.isArray(spec)) {
|
||||
throw new Error(`Invalid set step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
const expression = spec.expression;
|
||||
const as = spec.as;
|
||||
if (typeof expression !== "string" || !expression.trim()) {
|
||||
throw new Error("set.expression is required");
|
||||
}
|
||||
if (typeof as !== "string" || !AS_IDENT.test(as)) {
|
||||
throw new Error(`Invalid set.as: ${JSON.stringify(as)}`);
|
||||
}
|
||||
if (RESERVED_AS.has(as)) {
|
||||
throw new Error(`set.as "${as}" is reserved`);
|
||||
}
|
||||
compileJsonata(expression, "set.expression");
|
||||
const { needsKind, needs } = parseNeeds(step.needs);
|
||||
return {
|
||||
kind: "set",
|
||||
script: SET_STEP_SCRIPT,
|
||||
config: { expression, as },
|
||||
config: { expression },
|
||||
expression,
|
||||
as,
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind: "none",
|
||||
needs: [],
|
||||
needsKind,
|
||||
needs,
|
||||
when: parseWhen(step.when),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,20 +2,23 @@ name: Comic - monkeyuser to ntfy
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
url: "https://www.monkeyuser.com/"
|
||||
outputVar: "httpResponse"
|
||||
selector: ".comic img"
|
||||
url: https://www.monkeyuser.com/
|
||||
outputVar: comic
|
||||
selector: .comic img
|
||||
jsonata: |
|
||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{"data": {"title": httpResponse.title, "message": httpResponse.title, "attach": httpResponse.url}}
|
||||
- fetch-binary.js
|
||||
{
|
||||
"title": data.comic.title,
|
||||
"message": data.comic.title,
|
||||
"attach": data.comic.url
|
||||
}
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/scrunner
|
||||
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -2,9 +2,13 @@ name: cron example
|
||||
description: |
|
||||
this workflow triggered by cron
|
||||
scripts:
|
||||
- script: get-current-time.js
|
||||
- set:
|
||||
expression: '{"message": context.datetime}'
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/scrunner
|
||||
url: $VAR_ntfy_channel
|
||||
title: $DATA
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: "*/20 * * * *"
|
||||
|
||||
@@ -6,4 +6,7 @@ scripts:
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- time-and-comic-to-ntfy.yaml
|
||||
- rss-selfhst-to-ntfy.yaml
|
||||
- detect-example-changes.yaml
|
||||
- send-gmail.yaml
|
||||
- test-send-gmail.yaml
|
||||
- track.yaml
|
||||
- rss-devto-to-ntfy.yaml
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: RSS - selfh.st first item to ntfy (copy)
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss-rss-devto-to-ntfy
|
||||
enabled: false
|
||||
@@ -2,7 +2,7 @@ name: RSS - selfh.st first item to ntfy
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: "https://selfh.st/rss/"
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
@@ -13,16 +13,13 @@ scripts:
|
||||
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)
|
||||
}
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/scrunner
|
||||
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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: |
|
||||
{
|
||||
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
|
||||
"from": data.from,
|
||||
"subject": data.subject,
|
||||
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
|
||||
"html": data.html,
|
||||
"cc": data.cc,
|
||||
"bcc": data.bcc,
|
||||
"replyTo": data.replyTo,
|
||||
"priority": data.priority,
|
||||
"headers": data.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
|
||||
auth: basic-auth
|
||||
@@ -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": "JerapahFlow test",
|
||||
"text": "Hello from test-send-gmail",
|
||||
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
|
||||
}
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /test-send-gmail
|
||||
@@ -3,7 +3,7 @@ scripts:
|
||||
- get-current-time.js
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"data": {"message": datetime & " " & processId}}'
|
||||
expression: '{"message": data.datetime & " " & data.processId}'
|
||||
- ntfy.js
|
||||
triggers:
|
||||
- type: HTTP
|
||||
|
||||
@@ -19,17 +19,17 @@ scripts:
|
||||
needs: [time, comic]
|
||||
config:
|
||||
expression: |
|
||||
{"data": {
|
||||
"title": comic.httpResponse.title,
|
||||
"message": time.datetime & " " & comic.httpResponse.title,
|
||||
"attach": comic.httpResponse.url
|
||||
}}
|
||||
{
|
||||
"title": data.comic.httpResponse.title,
|
||||
"message": data.time.datetime & " " & data.comic.httpResponse.title,
|
||||
"attach": data.comic.httpResponse.url
|
||||
}
|
||||
|
||||
- id: notify
|
||||
script: ntfy.js
|
||||
needs: [compose]
|
||||
config:
|
||||
url: https://ntfy.sh/scrunner
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
|
||||
triggers:
|
||||
- type: HTTP
|
||||
|
||||
@@ -2,10 +2,12 @@ name: time to ntfy example
|
||||
description: |
|
||||
this workflow will send a message to ntfy with the current time
|
||||
scripts:
|
||||
- get-current-time.js
|
||||
- script: get-current-time.js
|
||||
config:
|
||||
key: ""
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/scrunner
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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: |
|
||||
{
|
||||
"title": data.httpResponse.customer_name,
|
||||
"message": data.httpResponse.customer_name & " : " & data.httpResponse.status
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: "* * * * *"
|
||||
- type: HTTP
|
||||
method: GET
|
||||
path: /new
|
||||
enabled: false
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>scrunner</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>JerapahFlow</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@scrunner/web",
|
||||
"name": "@jerapah-flow/web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
@@ -9,9 +9,14 @@
|
||||
"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",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||
"axios": "^1.11.0",
|
||||
"cronstrue": "^3.24.0",
|
||||
"mermaid": "^11.9.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -17,6 +17,7 @@ import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
|
||||
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||
|
||||
export function App() {
|
||||
const qc = useQueryClient();
|
||||
@@ -27,8 +28,8 @@ export function App() {
|
||||
function onUnauthorized() {
|
||||
qc.setQueryData(["me"], null);
|
||||
}
|
||||
window.addEventListener("scrunner:unauthorized", onUnauthorized);
|
||||
return () => window.removeEventListener("scrunner:unauthorized", onUnauthorized);
|
||||
window.addEventListener("jerapah-flow:unauthorized", onUnauthorized);
|
||||
return () => window.removeEventListener("jerapah-flow:unauthorized", onUnauthorized);
|
||||
}, [qc]);
|
||||
|
||||
const user = me.data?.user;
|
||||
@@ -61,6 +62,7 @@ export function App() {
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||
<Route path="/kv" element={<KvPage />} />
|
||||
<Route path="/variables" element={<VariablesPage />} />
|
||||
<Route path="/auth" element={<AuthProfilesPage />} />
|
||||
<Route path="/responses" element={<ResponsesPage />} />
|
||||
{user.role === "admin" ? (
|
||||
|
||||
@@ -11,7 +11,7 @@ api.interceptors.response.use(
|
||||
const url = err.config?.url ?? "";
|
||||
const isAuthCall = url.includes("/auth/login") || url.includes("/auth/register") || url.includes("/auth/me") || url.includes("/auth/bootstrap");
|
||||
if (err.response?.status === 401 && !isAuthCall) {
|
||||
window.dispatchEvent(new Event("scrunner:unauthorized"));
|
||||
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
|
||||
@@ -107,11 +107,12 @@ export function useDeleteScript() {
|
||||
|
||||
export function useDryRunScript() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, content, data, config, owner }) =>
|
||||
mutationFn: async ({ name, content, data, context, config, owner }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
||||
content,
|
||||
data,
|
||||
context,
|
||||
config,
|
||||
owner,
|
||||
})
|
||||
@@ -198,6 +199,27 @@ export function useDeleteWorkflow() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
||||
(
|
||||
await api.post(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
||||
{
|
||||
...(destOwner ? { owner: destOwner } : {}),
|
||||
...(destFile ? { file: destFile } : {}),
|
||||
},
|
||||
)
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -314,6 +336,33 @@ export function useDeleteSecret() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVariables(owner) {
|
||||
return useQuery({
|
||||
queryKey: ["variables", owner ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = owner ? { owner } : {};
|
||||
return (await api.get("/variables", { params })).data.variables;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertVariable() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.put("/variables", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVariable() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useKvNamespaces() {
|
||||
return useQuery({
|
||||
queryKey: ["kv", "namespaces"],
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js";
|
||||
import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
const EMPTY_WORKFLOWS = [];
|
||||
|
||||
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
|
||||
const { notify } = useNotifications();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows();
|
||||
const duplicate = useDuplicateWorkflow();
|
||||
const [destOwner, setDestOwner] = useState(source.owner);
|
||||
const [destFile, setDestFile] = useState(() => suggestCopyFilename(source.file));
|
||||
const fileTouched = useRef(false);
|
||||
|
||||
const existingFiles = useMemo(
|
||||
() => workflows.filter((w) => w.owner === destOwner).map((w) => w.file),
|
||||
[workflows, destOwner],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTouched.current) return;
|
||||
setDestFile(suggestCopyFilename(source.file, existingFiles));
|
||||
}, [source.file, existingFiles]);
|
||||
|
||||
const yamlFile = ensureWorkflowFilename(destFile);
|
||||
const sameAsSource = destOwner === source.owner && yamlFile === source.file;
|
||||
const exists = existingFiles.includes(yamlFile);
|
||||
const canSubmit = Boolean(destOwner && yamlFile) && !sameAsSource && !exists && !duplicate.isPending;
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
duplicate.mutate(
|
||||
{
|
||||
owner: source.owner,
|
||||
file: source.file,
|
||||
destOwner,
|
||||
destFile: yamlFile,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
notify.success(`Duplicated to ${data.owner}/${data.file}`);
|
||||
onDuplicated?.(data);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold">Duplicate {source.key}?</h3>
|
||||
<p className="mt-2 text-sm opacity-70">
|
||||
The copy starts disabled. HTTP paths are rewritten when staying under the same owner so
|
||||
triggers do not collide.
|
||||
</p>
|
||||
{warnUnsaved ? (
|
||||
<p className="text-warning mt-2 text-sm">The copy uses the last saved YAML, not unsaved edits.</p>
|
||||
) : null}
|
||||
<form className="mt-4 space-y-3" onSubmit={onSubmit}>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Owner</span>
|
||||
<input
|
||||
className="input input-sm w-full"
|
||||
list="duplicate-workflow-owners"
|
||||
value={destOwner}
|
||||
onChange={(e) => setDestOwner(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<datalist id="duplicate-workflow-owners">
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">File</span>
|
||||
<input
|
||||
className="input input-sm w-full font-mono"
|
||||
placeholder="file.yaml"
|
||||
value={destFile}
|
||||
onChange={(e) => {
|
||||
fileTouched.current = true;
|
||||
setDestFile(e.target.value);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{sameAsSource ? (
|
||||
<p className="text-error text-sm">Choose a different owner or filename.</p>
|
||||
) : exists ? (
|
||||
<p className="text-error text-sm">{destOwner}/{yamlFile} already exists.</p>
|
||||
) : null}
|
||||
{duplicate.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
|
||||
{duplicate.isPending ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : null}
|
||||
Duplicate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import JsonView from "@uiw/react-json-view";
|
||||
import { darkTheme } from "@uiw/react-json-view/dark";
|
||||
import { lightTheme } from "@uiw/react-json-view/light";
|
||||
import { useTheme } from "../theme.jsx";
|
||||
|
||||
/**
|
||||
* Collapsible JSON tree using @uiw/react-json-view.
|
||||
* Docs: value (JSON), keyName (root label), collapsed (depth),
|
||||
* enableClipboard, displayObjectSize, displayDataTypes, style theme vars.
|
||||
*/
|
||||
export function JsonViewBlock({ title, value }) {
|
||||
const { theme } = useTheme() ?? { theme: "light" };
|
||||
if (value === undefined) return null;
|
||||
|
||||
const isTree = value != null && typeof value === "object";
|
||||
|
||||
return (
|
||||
<details open className="collapse collapse-arrow border border-base-300 bg-base-100">
|
||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
|
||||
<div className="collapse-content">
|
||||
<div className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 text-xs">
|
||||
<JsonView
|
||||
value={isTree ? value : { value }}
|
||||
keyName={title}
|
||||
collapsed={1}
|
||||
enableClipboard
|
||||
displayObjectSize
|
||||
displayDataTypes={false}
|
||||
highlightUpdates={false}
|
||||
shortenTextAfterLength={80}
|
||||
style={{
|
||||
...(theme === "dark" ? darkTheme : lightTheme),
|
||||
"--w-rjv-background-color": "transparent",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
LuMoon,
|
||||
LuShield,
|
||||
LuSun,
|
||||
LuTags,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { useLogout } from "../api/hooks.js";
|
||||
import { brandMark } from "../theme/brand.js";
|
||||
import { useTheme } from "../theme.jsx";
|
||||
|
||||
const links = [
|
||||
@@ -23,6 +25,7 @@ const links = [
|
||||
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
||||
{ to: "/events", label: "Events", icon: LuActivity },
|
||||
{ to: "/kv", label: "KV", icon: LuDatabase },
|
||||
{ to: "/variables", label: "Variables", icon: LuTags },
|
||||
{ to: "/auth", label: "Auth", icon: LuShield },
|
||||
{ to: "/responses", label: "Responses", icon: LuFileText },
|
||||
];
|
||||
@@ -57,7 +60,10 @@ export function Layout({ user, children }) {
|
||||
<LuMenu className="size-5" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 px-2 text-lg font-semibold">scrunner</div>
|
||||
<div className="flex flex-1 items-center gap-2 px-2 text-lg font-semibold">
|
||||
<img src={brandMark} alt="" className="size-8 object-contain" />
|
||||
JerapahFlow
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,7 +93,12 @@ export function Layout({ user, children }) {
|
||||
<div className="drawer-side z-20">
|
||||
<label htmlFor="nav-drawer" aria-label="Close menu" className="drawer-overlay" />
|
||||
<ul className="menu bg-base-200 min-h-full w-64 p-4">
|
||||
<li className="menu-title">scrunner</li>
|
||||
<li className="mb-2">
|
||||
<div className="pointer-events-none flex items-center gap-2">
|
||||
<img src={brandMark} alt="" className="size-8 object-contain" />
|
||||
<span className="text-base font-semibold text-base-content">JerapahFlow</span>
|
||||
</div>
|
||||
</li>
|
||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||
<li key={to}>
|
||||
<NavLink to={to} end={end} onClick={closeDrawer}>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbFileTypeJs } from "react-icons/tb";
|
||||
|
||||
export function scriptIconSrc(name) {
|
||||
return `/api/scripts/${encodeURIComponent(name)}/icon`;
|
||||
}
|
||||
|
||||
/** Same-name PNG/JPG next to the script, or a JS document icon. */
|
||||
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [name]);
|
||||
|
||||
const showFile = Boolean(name) && hasIcon !== false && !failed;
|
||||
|
||||
if (!showFile) {
|
||||
return <TbFileTypeJs className={`${className} text-warning`} aria-hidden />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={scriptIconSrc(name)}
|
||||
alt=""
|
||||
className={`${className} object-contain`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { scriptTags, scriptReadsCtx } from "../lib/script.js";
|
||||
import { TagBadge } from "./TagBadge.jsx";
|
||||
|
||||
function FieldTable({ title, fields }) {
|
||||
const entries = Object.entries(fields ?? {});
|
||||
if (entries.length === 0) return null;
|
||||
@@ -20,6 +23,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)
|
||||
@@ -51,13 +59,26 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
|
||||
);
|
||||
}
|
||||
|
||||
const tags = scriptTags(meta);
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{meta.description ? <p className="text-sm">{meta.description}</p> : null}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{(tags.length || scriptReadsCtx(meta)) ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} />
|
||||
))}
|
||||
{scriptReadsCtx(meta) ? (
|
||||
<span className="badge badge-outline badge-sm font-mono">reads: ctx</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<FieldTable title="Config" fields={meta.config} />
|
||||
<FieldTable title="Input (data)" fields={meta.input} />
|
||||
<FieldTable title="Output" fields={meta.output} />
|
||||
<FieldTable title="Context" fields={meta.context} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { tagColor } from "../lib/script.js";
|
||||
|
||||
export function TagBadge({ tag, className = "", onClick, title }) {
|
||||
const { bg, text } = tagColor(tag);
|
||||
const Comp = onClick ? "button" : "span";
|
||||
return (
|
||||
<Comp
|
||||
type={onClick ? "button" : undefined}
|
||||
className={`badge badge-sm border-0 font-medium ${className}`}
|
||||
style={{ backgroundColor: bg, color: text }}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{tag}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { brandWorkflowFile } from "../theme/brand.js";
|
||||
|
||||
export function WorkflowFileIcon({ className = "size-8" }) {
|
||||
return (
|
||||
<img
|
||||
src={brandWorkflowFile}
|
||||
alt=""
|
||||
className={`${className} object-contain`}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useScripts } from "../../api/hooks.js";
|
||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||
import { scriptTags } from "../../lib/script.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,
|
||||
hasIcon: typeof s === "string" ? undefined : s.hasIcon,
|
||||
tags: typeof s === "string" ? [] : scriptTags(s.meta),
|
||||
}));
|
||||
if (!term) return list;
|
||||
return list.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(term) ||
|
||||
s.description.toLowerCase().includes(term) ||
|
||||
s.tags.some((t) => t.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [scripts, q]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box max-w-lg">
|
||||
<h3 className="font-bold text-lg">Add script</h3>
|
||||
<input
|
||||
className="input input-sm w-full mt-3"
|
||||
placeholder="Search scripts"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick({ kind: "set" });
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">Set (JSONata)</span>
|
||||
<span className="text-xs opacity-70">
|
||||
JSONata over ctx; result becomes the next step’s data
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{isLoading ? (
|
||||
<li className="p-4">
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<li key={s.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick({ kind: "script", name: s.name, meta: s.meta });
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||
{s.metaError ? s.metaError : s.description || "No description"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
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"]);
|
||||
|
||||
const CONFIG_REF_PREFIXES = [
|
||||
{ prefix: "$SECRET_", label: "secret" },
|
||||
{ prefix: "$CONTEXT_", label: "context" },
|
||||
{ prefix: "$VAR_", label: "variable" },
|
||||
];
|
||||
|
||||
function describeConfigRef(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
||||
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
||||
return { label, name: trimmed.slice(prefix.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ConfigRefHint({ value }) {
|
||||
const ref = describeConfigRef(value);
|
||||
if (!ref) return null;
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-8 w-full truncate rounded-btn px-2 text-left hover:bg-base-200 ${className}`}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{(value ?? "") === "" ? (
|
||||
<span className="opacity-40">{placeholder}</span>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={`input input-sm w-full ${className}`}
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(e) => 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 (
|
||||
<WorkflowNamePicker
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<select
|
||||
className="select select-sm w-full"
|
||||
value={current === undefined || current === null ? "" : String(current)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (next === "") {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
const match = options.find((o) => String(o.value) === next);
|
||||
onChange(match ? match.value : next);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{spec?.required ? null : <option value="">(default)</option>}
|
||||
{options.map((o) => (
|
||||
<option key={String(o.value)} value={String(o.value)}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
{known || current === "" || current == null ? null : (
|
||||
<option value={String(current)}>{String(current)}</option>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "boolean") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-sm"
|
||||
checked={Boolean(value)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm w-full"
|
||||
value={value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
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 <ObjectFields value={obj} onChange={onChange} disabled={disabled} />;
|
||||
}
|
||||
|
||||
if (type === "any") {
|
||||
const text =
|
||||
typeof value === "string" ? value : value === undefined ? "" : prettyJson(value) || "";
|
||||
return (
|
||||
<JsonOrTextArea
|
||||
text={text}
|
||||
onCommit={(next) => {
|
||||
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 (
|
||||
<textarea
|
||||
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||
value={str}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <EditableText value={str} onCommit={onChange} className="font-mono text-sm" disabled={disabled} />;
|
||||
}
|
||||
|
||||
function JsonOrTextArea({ text, onCommit, disabled }) {
|
||||
const [draft, setDraft] = useState(text);
|
||||
useEffect(() => {
|
||||
setDraft(text);
|
||||
}, [text]);
|
||||
return (
|
||||
<textarea
|
||||
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => onCommit(draft)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectFields({ value, onChange }) {
|
||||
const entries = Object.entries(value ?? {});
|
||||
|
||||
function setKey(oldKey, nextKey) {
|
||||
if (!nextKey || nextKey === oldKey) return;
|
||||
const next = {};
|
||||
for (const [k, v] of Object.entries(value ?? {})) {
|
||||
next[k === oldKey ? nextKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function setVal(key, val) {
|
||||
onChange({ ...value, [key]: val });
|
||||
}
|
||||
|
||||
function remove(key) {
|
||||
const next = { ...value };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function add() {
|
||||
let key = "key";
|
||||
let i = 1;
|
||||
while (key in (value ?? {})) {
|
||||
key = `key${i}`;
|
||||
i += 1;
|
||||
}
|
||||
onChange({ ...value, [key]: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 rounded-box border border-base-300 p-2">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="flex items-center gap-1">
|
||||
<div className="w-36 shrink-0">
|
||||
<EditableText value={k} onCommit={(nk) => setKey(k, nk)} className="font-mono text-xs" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{v != null && typeof v === "object" ? (
|
||||
<ObjectFields
|
||||
value={v && !Array.isArray(v) ? v : {}}
|
||||
onChange={(nv) => setVal(k, nv)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<EditableText
|
||||
value={v == null ? "" : String(v)}
|
||||
onCommit={(nv) => setVal(k, nv)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<ConfigRefHint value={v} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square text-error"
|
||||
aria-label={`Remove ${k}`}
|
||||
onClick={() => remove(k)}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-xs" onClick={add}>
|
||||
<LuPlus className="size-3.5" />
|
||||
Add field
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowNamePicker({ value, onChange, workflows, owner, excludeFile, disabled }) {
|
||||
const dest = triggerDestinations(workflows, { owner, excludeFile });
|
||||
const names = dest.map((w) => w.name);
|
||||
const known = names.includes(value);
|
||||
const [custom, setCustom] = useState(!known && Boolean(value));
|
||||
const selectValue = custom || (!known && value) ? "__custom__" : value || "";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
className="select select-sm w-full"
|
||||
value={selectValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "__custom__") {
|
||||
setCustom(true);
|
||||
return;
|
||||
}
|
||||
setCustom(false);
|
||||
onChange(v);
|
||||
}}
|
||||
>
|
||||
<option value="">Select workflow</option>
|
||||
{dest.map((w) => (
|
||||
<option key={`${w.owner}/${w.file}`} value={w.name} disabled={w.enabled === false}>
|
||||
{w.name}
|
||||
{w.file !== w.name ? ` (${w.file})` : ""}
|
||||
{w.enabled === false ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
{selectValue === "__custom__" ? (
|
||||
<input
|
||||
className="input input-sm w-full font-mono"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="workflow name"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigFields({
|
||||
script,
|
||||
config,
|
||||
meta,
|
||||
onChange,
|
||||
workflows,
|
||||
owner,
|
||||
excludeFile,
|
||||
disabled,
|
||||
}) {
|
||||
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
||||
const metaKeys = Object.keys(meta?.config ?? {});
|
||||
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
|
||||
|
||||
function setField(key, value) {
|
||||
const spec = fieldSpec(meta, key);
|
||||
const next = { ...cfg };
|
||||
const required = Boolean(spec.required);
|
||||
if (value === undefined || (value === "" && !required)) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function renameExtra(oldKey, nextKey) {
|
||||
if (!nextKey || nextKey === oldKey) return;
|
||||
const next = {};
|
||||
for (const [k, v] of Object.entries(cfg)) {
|
||||
next[k === oldKey ? nextKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function addExtra() {
|
||||
let key = "key";
|
||||
let i = 1;
|
||||
while (key in cfg) {
|
||||
key = `key${i}`;
|
||||
i += 1;
|
||||
}
|
||||
onChange({ ...cfg, [key]: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{metaKeys.map((key) => {
|
||||
const spec = fieldSpec(meta, key);
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<FieldLabel name={key} required={Boolean(spec.required)} description={spec.description} />
|
||||
<ValueEditor
|
||||
value={cfg[key]}
|
||||
onChange={(v) => setField(key, v)}
|
||||
spec={spec}
|
||||
script={script}
|
||||
fieldKey={key}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{extraKeys.map((key) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<EditableText
|
||||
value={key}
|
||||
onCommit={(nk) => renameExtra(key, nk)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square text-error"
|
||||
aria-label={`Remove ${key}`}
|
||||
onClick={() => {
|
||||
const next = { ...cfg };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ValueEditor
|
||||
value={cfg[key]}
|
||||
onChange={(v) => setField(key, v)}
|
||||
spec={{ type: cfg[key] != null && typeof cfg[key] === "object" ? "object" : "string" }}
|
||||
script={script}
|
||||
fieldKey={key}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||
<LuPlus className="size-3.5" />
|
||||
Add field
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { LuCircleHelp } from "react-icons/lu";
|
||||
|
||||
export function FieldHelp({ description }) {
|
||||
if (!description) return null;
|
||||
const long = String(description).length > 80;
|
||||
return (
|
||||
<div className="tooltip tooltip-top z-50" {...(long ? {} : { "data-tip": description })}>
|
||||
{long ? (
|
||||
<div className="tooltip-content">
|
||||
<p className="max-w-xs text-left text-xs whitespace-pre-wrap">{description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-ghost btn-xs btn-circle" aria-label={description}>
|
||||
<LuCircleHelp className="size-3.5 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ name, required, description, children }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
{required ? <span className="text-error text-xs">*</span> : null}
|
||||
<FieldHelp description={description} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldNotes(field) {
|
||||
return [
|
||||
field.required ? "required" : null,
|
||||
field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null,
|
||||
Array.isArray(field.enum)
|
||||
? field.enum.map((x) => (x && typeof x === "object" ? x.value : x)).join(" | ")
|
||||
: Array.isArray(field.options)
|
||||
? field.options.map((x) => (x && typeof x === "object" ? x.value : x)).join(" | ")
|
||||
: null,
|
||||
field.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
const TITLE_PREVIEW_MAX = 40;
|
||||
const TOOLTIP_VALUE_MAX = 120;
|
||||
|
||||
function stringifyConfigValue(val) {
|
||||
if (typeof val === "string") return val.replace(/\s+/g, " ").trim();
|
||||
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
||||
try {
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateConfigValue(val, maxLen) {
|
||||
const text = stringifyConfigValue(val);
|
||||
if (!text) return "";
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||
}
|
||||
|
||||
/** Collapsed card title suffix from `meta.previewConfigKey`. */
|
||||
export function previewConfigValue(config, key, maxLen = TITLE_PREVIEW_MAX) {
|
||||
const text = configValueText(config, key);
|
||||
if (!text) return "";
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||
}
|
||||
|
||||
export function configValueText(config, key) {
|
||||
if (typeof key !== "string" || !key.trim()) return "";
|
||||
if (config == null || typeof config !== "object") return "";
|
||||
const val = config[key];
|
||||
if (val == null || val === "") return "";
|
||||
return stringifyConfigValue(val);
|
||||
}
|
||||
|
||||
/** Formatted DaisyUI tooltip for a step's current config. */
|
||||
export function ConfigTooltip({ config, label = "config" }) {
|
||||
const entries =
|
||||
config && typeof config === "object" && !Array.isArray(config) ? Object.entries(config) : [];
|
||||
|
||||
return (
|
||||
<div className="tooltip tooltip-bottom z-50">
|
||||
<div className="tooltip-content z-50 p-0">
|
||||
<div className="max-w-sm text-left">
|
||||
{entries.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs opacity-80">No config</p>
|
||||
) : (
|
||||
<table className="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([name, val]) => (
|
||||
<tr key={name}>
|
||||
<td className="font-mono align-top">{name}</td>
|
||||
<td className="max-w-56 break-all whitespace-normal font-mono opacity-90">
|
||||
{val == null || val === "" ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
truncateConfigValue(val, TOOLTIP_VALUE_MAX)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="badge badge-ghost badge-sm h-6 cursor-help font-normal" aria-label="Current config">
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formatted DaisyUI tooltip for script.meta.input (or similar field maps). */
|
||||
export function SchemaTooltip({ label = "Input", fields }) {
|
||||
const entries = Object.entries(fields ?? {});
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="tooltip tooltip-bottom z-50">
|
||||
<div className="tooltip-content z-50 p-0">
|
||||
<div className="max-w-sm text-left">
|
||||
<table className="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([name, spec]) => {
|
||||
const field = spec && typeof spec === "object" ? spec : { description: String(spec) };
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className="font-mono">
|
||||
{name}
|
||||
{field.required ? <span className="text-error">*</span> : null}
|
||||
</td>
|
||||
<td className="opacity-80">{field.type ?? ""}</td>
|
||||
<td className="max-w-48 whitespace-normal opacity-90">{fieldNotes(field)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost btn-xs" aria-label={`${label} schema`}>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { LuChevronDown, LuGripVertical, LuMaximize2, LuMinimize2, LuPlay, LuTrash2 } from "react-icons/lu";
|
||||
import { useDryRunScript, useScript } from "../../api/hooks.js";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { LogViewer } from "../LogViewer.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||
import { ConfigFields } from "./ConfigFields.jsx";
|
||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||
|
||||
export function ScriptCard({
|
||||
step,
|
||||
index,
|
||||
otherSteps,
|
||||
scriptsByName,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
workflows,
|
||||
owner,
|
||||
excludeFile,
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: step.uiId,
|
||||
disabled,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const [tryOpen, setTryOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const listed = scriptsByName?.get(step.script);
|
||||
const meta = listed?.meta ?? null;
|
||||
|
||||
const duplicateId =
|
||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||
const preview = previewConfigValue(step.config, meta?.previewConfigKey);
|
||||
const previewFull = configValueText(step.config, meta?.previewConfigKey);
|
||||
const baseName = step.kind === "set" ? "set" : step.script || "untitled";
|
||||
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||
const setConfig =
|
||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`card bg-base-100 border border-primary ${
|
||||
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
||||
}`}
|
||||
>
|
||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag step ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
{step.kind === "script" && step.script ? (
|
||||
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="card-title min-w-0 flex-1 text-base font-mono" title={titleFull}>
|
||||
<span className="min-w-0 truncate">{baseName}</span>
|
||||
{!expanded && preview ? (
|
||||
<span
|
||||
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
||||
title={previewFull}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
</h3>
|
||||
{step.id ? (
|
||||
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded && step.kind === "script" && meta?.description ? (
|
||||
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
||||
) : null}
|
||||
{expanded && step.kind === "set" ? (
|
||||
<p className="text-sm opacity-70 mt-1">
|
||||
JSONata over ctx; result becomes the next step’s data
|
||||
</p>
|
||||
) : null}
|
||||
</button>
|
||||
{step.kind === "script" ? (
|
||||
<SchemaTooltip label="Input" fields={meta?.input} />
|
||||
) : null}
|
||||
{!expanded ? (
|
||||
<ConfigTooltip config={step.kind === "set" ? setConfig : (step.config ?? {})} />
|
||||
) : null}
|
||||
<div className="card-actions shrink-0">
|
||||
{step.kind === "script" && step.script ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Try"
|
||||
disabled={disabled}
|
||||
onClick={() => setTryOpen(true)}
|
||||
>
|
||||
<LuPlay className="size-4" />
|
||||
Try
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Remove"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square"
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<>
|
||||
{step.kind === "set" ? (
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
name="expression"
|
||||
required
|
||||
description="JSONata evaluated against ctx (data, context, config). Result becomes the next step's data."
|
||||
/>
|
||||
<textarea
|
||||
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||
value={step.expression ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...step, expression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ConfigFields
|
||||
script={step.script}
|
||||
config={step.config}
|
||||
meta={meta}
|
||||
disabled={disabled}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
onChange={(config) => onChange({ ...step, config })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200">
|
||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">Advanced</summary>
|
||||
<div className="collapse-content space-y-2">
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">id</span>
|
||||
<input
|
||||
className={`input input-sm font-mono ${duplicateId ? "input-error" : ""}`}
|
||||
value={step.id ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...step, id: e.target.value })}
|
||||
placeholder="optional step id"
|
||||
/>
|
||||
{duplicateId ? (
|
||||
<span className="text-error text-xs">Duplicate step id</span>
|
||||
) : null}
|
||||
</label>
|
||||
<NeedsEditor
|
||||
step={step}
|
||||
otherSteps={otherSteps}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">when</span>
|
||||
<input
|
||||
className="input input-sm w-full font-mono"
|
||||
value={step.when ?? ""}
|
||||
disabled={disabled || Boolean(step.needs)}
|
||||
onChange={(e) => onChange({ ...step, when: e.target.value })}
|
||||
placeholder="JSONata; skip if false (linear only)"
|
||||
/>
|
||||
{step.needs ? (
|
||||
<span className="text-xs opacity-60">when is not allowed when needs is set</span>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{tryOpen ? (
|
||||
<ScriptTryDialog
|
||||
script={step.script}
|
||||
config={step.config}
|
||||
meta={meta}
|
||||
owner={owner}
|
||||
onClose={() => setTryOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
const mode = needsMode(step.needs);
|
||||
const ids = otherSteps.filter((s) => s.id && s.uiId !== step.uiId).map((s) => s.id);
|
||||
|
||||
function setMode(next) {
|
||||
if (next === "none") onChange({ ...step, needs: null });
|
||||
else if (next === "list") onChange({ ...step, needs: [], when: "" });
|
||||
else onChange({ ...step, needs: {}, when: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="label py-0 text-sm">needs</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
>
|
||||
<option value="none">None (trigger data / previous output)</option>
|
||||
<option value="list">List of step ids</option>
|
||||
<option value="map">Map alias → step id</option>
|
||||
</select>
|
||||
{mode === "list" ? (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{ids.length === 0 ? (
|
||||
<span className="text-xs opacity-60">Give other steps an id first</span>
|
||||
) : (
|
||||
ids.map((id) => {
|
||||
const checked = Array.isArray(step.needs) && step.needs.includes(id);
|
||||
return (
|
||||
<label key={id} className="label cursor-pointer gap-1 py-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(step.needs) ? [...step.needs] : [];
|
||||
onChange({
|
||||
...step,
|
||||
needs: e.target.checked
|
||||
? [...current, id]
|
||||
: current.filter((x) => x !== id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{id}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mode === "map" ? (
|
||||
<NeedsMap
|
||||
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
||||
ids={ids}
|
||||
disabled={disabled}
|
||||
onChange={(needs) => onChange({ ...step, needs })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||
const entries = Object.entries(needs ?? {});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.map(([alias, from]) => (
|
||||
<div key={alias} className="flex gap-1">
|
||||
<input
|
||||
className="input input-sm font-mono w-28"
|
||||
value={alias}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = {};
|
||||
for (const [k, v] of Object.entries(needs)) {
|
||||
next[k === alias ? e.target.value : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="select select-sm flex-1"
|
||||
value={from}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
||||
>
|
||||
{from && !ids.includes(from) ? <option value={from}>{from}</option> : null}
|
||||
{ids.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = { ...needs };
|
||||
delete next[alias];
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
disabled={disabled || ids.length === 0}
|
||||
onClick={() => {
|
||||
const used = new Set(Object.keys(needs));
|
||||
let alias = ids[0] || "step";
|
||||
let i = 1;
|
||||
while (used.has(alias)) {
|
||||
alias = `${ids[0] || "step"}${i}`;
|
||||
i += 1;
|
||||
}
|
||||
onChange({ ...needs, [alias]: ids[0] });
|
||||
}}
|
||||
>
|
||||
Add mapping
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function needsMode(needs) {
|
||||
if (needs == null) return "none";
|
||||
if (Array.isArray(needs)) return "list";
|
||||
return "map";
|
||||
}
|
||||
|
||||
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||
const existing = useScript(script);
|
||||
const dryRun = useDryRunScript();
|
||||
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
|
||||
const defaultData = useMemo(() => prettyJson(defaults.data ?? {}), [defaults]);
|
||||
const defaultContext = useMemo(() => prettyJson(defaults.context ?? {}), [defaults]);
|
||||
const [dataJson, setDataJson] = useState(defaultData);
|
||||
const [contextJson, setContextJson] = useState(defaultContext);
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDataJson(defaultData);
|
||||
setContextJson(defaultContext);
|
||||
}, [defaultData, defaultContext]);
|
||||
|
||||
const resultJson = dryRun.data
|
||||
? prettyJson(
|
||||
dryRun.data.status === "success"
|
||||
? {
|
||||
output: dryRun.data.output,
|
||||
context: dryRun.data.context,
|
||||
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
|
||||
}
|
||||
: { error: dryRun.data.error },
|
||||
)
|
||||
: "";
|
||||
|
||||
function onRun() {
|
||||
setParseError(null);
|
||||
let data;
|
||||
let context;
|
||||
try {
|
||||
data = JSON.parse(dataJson || "null");
|
||||
context = JSON.parse(contextJson || "{}");
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
if (context != null && (typeof context !== "object" || Array.isArray(context))) {
|
||||
setParseError("context must be a JSON object");
|
||||
return;
|
||||
}
|
||||
if (!existing.data?.content) {
|
||||
setParseError("could not load script source");
|
||||
return;
|
||||
}
|
||||
dryRun.mutate({
|
||||
name: script,
|
||||
content: existing.data.content,
|
||||
data,
|
||||
context: context ?? {},
|
||||
config: config ?? {},
|
||||
owner,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div
|
||||
className={
|
||||
expanded
|
||||
? "modal-box flex h-dvh max-h-dvh w-dvw max-w-none flex-col rounded-none"
|
||||
: "modal-box flex max-w-4xl flex-col"
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold font-mono">{script}</h3>
|
||||
<p className="text-sm opacity-70">
|
||||
Dry-run this script with the card config. Does not create an event.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
title={expanded ? "Exit full screen" : "Full screen"}
|
||||
aria-label={expanded ? "Exit full screen" : "Full screen"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null}
|
||||
{dryRun.isError ? (
|
||||
<p className="text-error text-sm mt-2 shrink-0">{errorMessage(dryRun.error)}</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
|
||||
>
|
||||
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
|
||||
<p className="text-xs opacity-60 mb-1">data</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
|
||||
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
|
||||
<p className="text-xs opacity-60 mb-1">context</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
|
||||
<CodeEditor language="json" value={contextJson} onChange={setContextJson} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`md:col-span-2 ${expanded ? "min-h-0 flex-1 h-full" : "h-48"}`}>
|
||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||
result (output + context)
|
||||
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
|
||||
</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
|
||||
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0">
|
||||
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary>
|
||||
<div className="collapse-content">
|
||||
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre>
|
||||
</div>
|
||||
</details>
|
||||
{dryRun.data?.logs?.length ? (
|
||||
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
|
||||
) : null}
|
||||
<div className="modal-action shrink-0">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={dryRun.isPending || existing.isLoading}
|
||||
onClick={onRun}
|
||||
>
|
||||
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { defaultConfigFromMeta } from "../../lib/script.js";
|
||||
import { newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
|
||||
import { AddScriptDialog } from "./AddScriptDialog.jsx";
|
||||
import { ScriptCard } from "./ScriptCard.jsx";
|
||||
|
||||
export function ScriptsTab({
|
||||
doc,
|
||||
onPatch,
|
||||
disabled,
|
||||
scripts = [],
|
||||
workflows = [],
|
||||
owner,
|
||||
excludeFile,
|
||||
}) {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const steps = doc?.scripts ?? [];
|
||||
const ids = steps.map((s) => s.uiId);
|
||||
|
||||
const scriptsByName = new Map();
|
||||
for (const s of scripts) {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
scriptsByName.set(name, s);
|
||||
}
|
||||
|
||||
function patchSteps(next) {
|
||||
onPatch((d) => {
|
||||
d.scripts = next;
|
||||
});
|
||||
}
|
||||
|
||||
function onDragEnd(event) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = ids.indexOf(active.id);
|
||||
const newIndex = ids.indexOf(over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
patchSteps(arrayMove(steps, oldIndex, newIndex));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto">
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<p className="text-sm opacity-70">
|
||||
Steps run top to bottom. In DAG mode, <span className="font-mono">needs</span> selects
|
||||
upstream outputs as this step’s data; context is shared per wave.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={disabled}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add script
|
||||
</button>
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
<p className="text-sm opacity-50">No scripts yet. Add one to get started.</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-3 pb-6">
|
||||
{steps.map((step, index) => (
|
||||
<ScriptCard
|
||||
key={step.uiId}
|
||||
step={step}
|
||||
index={index}
|
||||
otherSteps={steps}
|
||||
scriptsByName={scriptsByName}
|
||||
disabled={disabled}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
onChange={(next) => {
|
||||
const copy = [...steps];
|
||||
copy[index] = next;
|
||||
patchSteps(copy);
|
||||
}}
|
||||
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
<AddScriptDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onPick={(picked) => {
|
||||
if (picked.kind === "set") {
|
||||
patchSteps([...steps, newSetStep()]);
|
||||
} else {
|
||||
patchSteps([
|
||||
...steps,
|
||||
newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)),
|
||||
]);
|
||||
}
|
||||
setAddOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState } from "react";
|
||||
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import cronstrue from "cronstrue";
|
||||
import { namespacedPath, HTTP_METHODS } from "../../lib/workflow-doc.js";
|
||||
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
||||
|
||||
export function TriggerCard({
|
||||
trigger,
|
||||
index,
|
||||
owner,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
auths = [],
|
||||
pages = [],
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: trigger.uiId,
|
||||
disabled,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const type = trigger.type;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`card bg-base-100 border border-primary ${
|
||||
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
||||
}`}
|
||||
>
|
||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag trigger ${index + 1}`}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<h3 className="card-title text-base inline">{typeLabel(type)}</h3>
|
||||
<span className="ml-2 font-mono text-xs opacity-70">{triggerSummary(trigger, owner)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Remove"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs btn-square"
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<>
|
||||
{type === "HTTP" ? (
|
||||
<HttpFields
|
||||
trigger={trigger}
|
||||
owner={owner}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
/>
|
||||
) : null}
|
||||
{type === "cron" ? (
|
||||
<CronFields trigger={trigger} disabled={disabled} onChange={onChange} />
|
||||
) : null}
|
||||
{type === "workflow" ? (
|
||||
<p className="text-sm opacity-80">
|
||||
Other workflows can call this one with <span className="font-mono">trigger-workflow.js</span>.
|
||||
No extra fields.
|
||||
</p>
|
||||
) : null}
|
||||
{type !== "HTTP" && type !== "cron" && type !== "workflow" ? (
|
||||
<p className="text-sm opacity-70">Unknown trigger type. Edit it in the YAML tab.</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function triggerSummary(trigger, owner) {
|
||||
const type = trigger?.type;
|
||||
if (type === "HTTP") {
|
||||
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}`;
|
||||
}
|
||||
if (type === "cron") return trigger.schedule || "";
|
||||
if (type === "workflow") return "callable";
|
||||
return "";
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
if (type === "HTTP") return "HTTP";
|
||||
if (type === "cron") return "Cron";
|
||||
if (type === "workflow") return "Workflow";
|
||||
return type || "Trigger";
|
||||
}
|
||||
|
||||
function HttpFields({ trigger, owner, disabled, onChange, auths, pages }) {
|
||||
const path = trigger.path || "/";
|
||||
const url = namespacedPath(owner || "owner", path);
|
||||
const authIsInline = trigger.auth != null && typeof trigger.auth === "object";
|
||||
const authSelect = authIsInline
|
||||
? "__inline__"
|
||||
: typeof trigger.auth === "string" && trigger.auth
|
||||
? trigger.auth
|
||||
: "";
|
||||
|
||||
function copyUrl() {
|
||||
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||
navigator.clipboard.writeText(url);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Method</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={trigger.method || "POST"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...trigger, method: e.target.value })}
|
||||
>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
{HTTP_METHODS.includes(trigger.method || "POST") ? null : (
|
||||
<option value={trigger.method}>{trigger.method}</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Path</span>
|
||||
<input
|
||||
className="input input-sm font-mono"
|
||||
value={trigger.path ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...trigger, path: e.target.value })}
|
||||
placeholder="/hook"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs font-mono opacity-80">
|
||||
<span className="truncate">{url}</span>
|
||||
<button type="button" className="btn btn-ghost btn-xs btn-square" title="Copy URL" onClick={copyUrl}>
|
||||
<LuCopy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Auth</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={authSelect}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "__inline__") {
|
||||
onChange({ ...trigger, auth: v === "__inline__" ? trigger.auth : null });
|
||||
} else {
|
||||
onChange({ ...trigger, auth: v });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{auths.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
{authIsInline ? <option value="__inline__">Inline (edit in YAML)</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Response page</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={trigger.response ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...trigger, response: e.target.value })}
|
||||
>
|
||||
<option value="">JSON default</option>
|
||||
{pages.map((p) => (
|
||||
<option key={p.name ?? p.id} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
{trigger.response && !pages.some((p) => p.name === trigger.response) ? (
|
||||
<option value={trigger.response}>{trigger.response}</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CronFields({ trigger, disabled, onChange }) {
|
||||
const preset = matchCronPreset(trigger.schedule);
|
||||
let human = "";
|
||||
try {
|
||||
human = cronstrue.toString(trigger.schedule || "", { throwExceptionOnParseError: true });
|
||||
} catch {
|
||||
human = "Invalid cron expression";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Preset</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={preset}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (id === CRON_CUSTOM) return;
|
||||
const schedule = scheduleForPreset(id);
|
||||
if (schedule) onChange({ ...trigger, schedule });
|
||||
}}
|
||||
>
|
||||
{CRON_PRESETS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
<option value={CRON_CUSTOM}>Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Schedule</span>
|
||||
<input
|
||||
className="input input-sm font-mono"
|
||||
value={trigger.schedule ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...trigger, schedule: e.target.value })}
|
||||
placeholder="* * * * *"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs opacity-70">{human}</p>
|
||||
<p className="text-xs opacity-60">
|
||||
Cron runs use this workflow's top-level <span className="font-mono">data</span> as the payload.
|
||||
Set it in YAML or the Test panel prefill.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTriggerDialog({ open, onClose, onPick }) {
|
||||
const [kind, setKind] = useState("HTTP");
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">Add trigger</h3>
|
||||
<div className="form-control mt-3">
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="trig-kind"
|
||||
className="radio radio-sm"
|
||||
checked={kind === "HTTP"}
|
||||
onChange={() => setKind("HTTP")}
|
||||
/>
|
||||
<span>HTTP — webhook at <span className="font-mono">/u/owner/path</span></span>
|
||||
</label>
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="trig-kind"
|
||||
className="radio radio-sm"
|
||||
checked={kind === "cron"}
|
||||
onChange={() => setKind("cron")}
|
||||
/>
|
||||
<span>Cron — run on a schedule</span>
|
||||
</label>
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="trig-kind"
|
||||
className="radio radio-sm"
|
||||
checked={kind === "workflow"}
|
||||
onChange={() => setKind("workflow")}
|
||||
/>
|
||||
<span>Workflow — callable by other workflows</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
onPick(kind);
|
||||
setKind("HTTP");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { newCronTrigger, newHttpTrigger, newWorkflowTrigger } from "../../lib/workflow-doc.js";
|
||||
import { AddTriggerDialog, TriggerCard } from "./TriggerCard.jsx";
|
||||
|
||||
export function TriggersTab({
|
||||
doc,
|
||||
onPatch,
|
||||
disabled,
|
||||
owner,
|
||||
auths = [],
|
||||
pages = [],
|
||||
}) {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const triggers = doc?.triggers ?? [];
|
||||
const ids = triggers.map((t) => t.uiId);
|
||||
|
||||
function patchTriggers(next) {
|
||||
onPatch((d) => {
|
||||
d.triggers = next;
|
||||
});
|
||||
}
|
||||
|
||||
function onDragEnd(event) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = ids.indexOf(active.id);
|
||||
const newIndex = ids.indexOf(over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
patchTriggers(arrayMove(triggers, oldIndex, newIndex));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto">
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<p className="text-sm opacity-70">How this workflow is started. Manual Run from the Test panel is always available.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={disabled}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add trigger
|
||||
</button>
|
||||
</div>
|
||||
{triggers.length === 0 ? (
|
||||
<p className="text-sm opacity-50">No triggers. The Test panel can still run a saved workflow.</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-3 pb-6">
|
||||
{triggers.map((trigger, index) => (
|
||||
<TriggerCard
|
||||
key={trigger.uiId}
|
||||
trigger={trigger}
|
||||
index={index}
|
||||
owner={owner}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
const copy = [...triggers];
|
||||
copy[index] = next;
|
||||
patchTriggers(copy);
|
||||
}}
|
||||
onRemove={() => patchTriggers(triggers.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
<AddTriggerDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onPick={(kind) => {
|
||||
const next =
|
||||
kind === "cron"
|
||||
? newCronTrigger()
|
||||
: kind === "workflow"
|
||||
? newWorkflowTrigger()
|
||||
: newHttpTrigger();
|
||||
patchTriggers([...triggers, next]);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
import { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import {
|
||||
overlayWorkflowTestData,
|
||||
readWorkflowTestData,
|
||||
writeWorkflowTestData,
|
||||
} from "../../lib/workflow-test-storage.js";
|
||||
import { SchemaTooltip } from "./FieldHelp.jsx";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
function initialDataJson(owner, file, defaultData) {
|
||||
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
|
||||
}
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
owner,
|
||||
file,
|
||||
defaultData,
|
||||
inputFields,
|
||||
disabled,
|
||||
disabledReason,
|
||||
open,
|
||||
onClose,
|
||||
}) {
|
||||
const run = useRunWorkflow();
|
||||
const [dataJson, setDataJson] = useState(() => initialDataJson(owner, file, defaultData));
|
||||
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [last, setLast] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const saveTimer = useRef(null);
|
||||
|
||||
const seedJson = prettyJson(
|
||||
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputTouched) return;
|
||||
setDataJson(seedJson);
|
||||
}, [seedJson, inputTouched]);
|
||||
|
||||
function persist(data) {
|
||||
writeWorkflowTestData(owner, file, data);
|
||||
}
|
||||
|
||||
function onDataChange(value) {
|
||||
setDataJson(value);
|
||||
setInputTouched(true);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
try {
|
||||
persist(JSON.parse(value || "null"));
|
||||
} catch {
|
||||
// skip invalid JSON
|
||||
}
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onRun() {
|
||||
setParseError(null);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(dataJson || "null");
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
persist(data);
|
||||
run.mutate(
|
||||
{ owner, file, data },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setLast({
|
||||
status: res.status ?? "success",
|
||||
runId: res.runId ?? null,
|
||||
result: res.result,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const runId = err?.response?.data?.runId;
|
||||
setLast({
|
||||
status: "failed",
|
||||
runId: runId ?? null,
|
||||
result: null,
|
||||
error: errorMessage(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const resultJson = last
|
||||
? prettyJson(last.error ? { error: last.error } : last.result)
|
||||
: "";
|
||||
|
||||
function close() {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
try {
|
||||
persist(JSON.parse(dataJson || "null"));
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
setExpanded(false);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className={`modal ${open ? "modal-open" : ""}`}>
|
||||
<div
|
||||
className={
|
||||
expanded
|
||||
? "modal-box flex h-dvh max-h-dvh w-dvw max-w-none flex-col rounded-none"
|
||||
: "modal-box flex max-w-5xl flex-col"
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold text-lg">Test workflow</h3>
|
||||
<p className="text-sm opacity-70">
|
||||
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when
|
||||
the workflow is disabled. Save before testing unsaved edits.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
title={expanded ? "Exit full screen" : "Full screen"}
|
||||
aria-label={expanded ? "Exit full screen" : "Full screen"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{disabledReason ? (
|
||||
<p className="text-warning text-xs mt-2 shrink-0">{disabledReason}</p>
|
||||
) : null}
|
||||
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null}
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
|
||||
>
|
||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||
data
|
||||
<SchemaTooltip label="Input" fields={inputFields} />
|
||||
</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||
<CodeEditor language="json" value={dataJson} onChange={onDataChange} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||
result
|
||||
{last?.status ? <StatusBadge status={last.status} /> : null}
|
||||
{last?.runId ? (
|
||||
<Link className="link text-xs" to={`/events/${last.runId}`}>
|
||||
{last.runId}
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={resultJson}
|
||||
onChange={() => {}}
|
||||
readOnly
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action shrink-0">
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={disabled || run.isPending}
|
||||
onClick={onRun}
|
||||
>
|
||||
{run.isPending ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : (
|
||||
<LuPlay className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={close}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
||||
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
||||
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||
import { ScriptsTab } from "./ScriptsTab.jsx";
|
||||
import { TriggersTab } from "./TriggersTab.jsx";
|
||||
import { YamlTab } from "./YamlTab.jsx";
|
||||
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
|
||||
|
||||
export function useYamlPreview(content) {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
const parsedYaml = parseYaml(content);
|
||||
return {
|
||||
parsed: parsedYaml,
|
||||
parseError: null,
|
||||
mermaid: workflowToFlowchart(parsedYaml),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
parsed: null,
|
||||
parseError: err instanceof Error ? err.message : String(err),
|
||||
mermaid: { chart: "", scriptIds: {} },
|
||||
};
|
||||
}
|
||||
}, [content]);
|
||||
}
|
||||
|
||||
export function WorkflowVisualEditor({
|
||||
yaml,
|
||||
onYamlChange,
|
||||
owner,
|
||||
file,
|
||||
extraChrome,
|
||||
showTest,
|
||||
savedYaml,
|
||||
testOpen,
|
||||
onTestClose,
|
||||
}) {
|
||||
const [tab, setTab] = useState("scripts");
|
||||
const [lastEdited, setLastEdited] = useState("yaml");
|
||||
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
|
||||
|
||||
const { parsed, parseError, mermaid } = useYamlPreview(yaml);
|
||||
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
|
||||
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
||||
const visualDisabled = !displayDoc;
|
||||
|
||||
const { data: scripts = [] } = useScripts();
|
||||
const { data: workflows = [] } = useWorkflows(owner || undefined);
|
||||
const { data: auths = [] } = useHttpAuths();
|
||||
const { data: allPages = [] } = useHttpPages();
|
||||
const pages = allPages.filter((p) => p.kind !== "template");
|
||||
|
||||
const scriptCount = displayDoc?.scripts?.length ?? 0;
|
||||
const triggerCount = displayDoc?.triggers?.length ?? 0;
|
||||
const unsaved = savedYaml != null && yaml !== savedYaml;
|
||||
|
||||
function patchDoc(mutator) {
|
||||
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
||||
if (!base) return;
|
||||
const next = structuredClone(base);
|
||||
mutator(next);
|
||||
setDoc(next);
|
||||
onYamlChange(stringifyWorkflowDoc(next));
|
||||
setLastEdited("visual");
|
||||
}
|
||||
|
||||
function onYamlTabChange(value) {
|
||||
setLastEdited("yaml");
|
||||
onYamlChange(value);
|
||||
}
|
||||
|
||||
function selectTab(next) {
|
||||
if (next !== "yaml" && lastEdited === "yaml") {
|
||||
const { doc: d } = parseWorkflowYaml(yaml);
|
||||
if (d) setDoc(d);
|
||||
}
|
||||
setTab(next);
|
||||
}
|
||||
|
||||
const scriptsByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const s of scripts) {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
if (name) map.set(name, s);
|
||||
}
|
||||
return map;
|
||||
}, [scripts]);
|
||||
|
||||
const inputMeta = useMemo(
|
||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
|
||||
[displayDoc?.scripts, scriptsByName],
|
||||
);
|
||||
|
||||
const defaultData = useMemo(() => {
|
||||
const yamlData = displayDoc?.extra?.data;
|
||||
return dataFromInputMeta(inputMeta, yamlData);
|
||||
}, [displayDoc?.extra?.data, inputMeta]);
|
||||
|
||||
const inputFields = inputMeta?.input;
|
||||
|
||||
let testDisabledReason = null;
|
||||
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
||||
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{extraChrome}
|
||||
<input
|
||||
className="input input-sm sm:max-w-xs"
|
||||
placeholder="name"
|
||||
value={displayDoc?.name ?? ""}
|
||||
disabled={visualDisabled}
|
||||
onChange={(e) =>
|
||||
patchDoc((d) => {
|
||||
d.name = e.target.value;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<textarea
|
||||
className="textarea textarea-sm min-h-10 w-full sm:max-w-md"
|
||||
placeholder="description"
|
||||
rows={1}
|
||||
value={displayDoc?.description ?? ""}
|
||||
disabled={visualDisabled}
|
||||
onChange={(e) =>
|
||||
patchDoc((d) => {
|
||||
d.description = e.target.value;
|
||||
})
|
||||
}
|
||||
/>
|
||||
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
|
||||
</div>
|
||||
|
||||
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
|
||||
onClick={() => selectTab("scripts")}
|
||||
>
|
||||
Scripts ({scriptCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={`tab ${tab === "triggers" ? "tab-active" : ""}`}
|
||||
onClick={() => selectTab("triggers")}
|
||||
>
|
||||
Triggers ({triggerCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={`tab ${tab === "yaml" ? "tab-active" : ""}`}
|
||||
onClick={() => selectTab("yaml")}
|
||||
>
|
||||
YAML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parseError && tab !== "yaml" ? (
|
||||
<p className="text-error text-sm shrink-0">
|
||||
YAML is invalid — switch to the YAML tab to fix it. Visual edits are disabled.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tab === "scripts" ? (
|
||||
<ScriptsTab
|
||||
doc={displayDoc}
|
||||
onPatch={patchDoc}
|
||||
disabled={visualDisabled}
|
||||
scripts={scripts}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={file}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "triggers" ? (
|
||||
<TriggersTab
|
||||
doc={displayDoc}
|
||||
onPatch={patchDoc}
|
||||
disabled={visualDisabled}
|
||||
owner={owner}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "yaml" ? (
|
||||
<YamlTab
|
||||
content={yaml}
|
||||
onChange={onYamlTabChange}
|
||||
parseError={parseError}
|
||||
mermaid={mermaid}
|
||||
parsed={parsed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showTest && owner && file ? (
|
||||
<WorkflowTestPanel
|
||||
key={`${owner}/${file}`}
|
||||
owner={owner}
|
||||
file={file}
|
||||
defaultData={defaultData}
|
||||
inputFields={inputFields}
|
||||
disabled={Boolean(testDisabledReason)}
|
||||
disabledReason={testDisabledReason}
|
||||
open={testOpen}
|
||||
onClose={onTestClose}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { MermaidDiagram } from "../MermaidDiagram.jsx";
|
||||
|
||||
export function YamlTab({ content, onChange, parseError, mermaid, parsed }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
||||
<div className="min-h-0">
|
||||
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
||||
</div>
|
||||
<div className="min-h-0 overflow-auto">
|
||||
{parseError ? (
|
||||
<p className="text-error text-sm">{parseError}</p>
|
||||
) : (
|
||||
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
|
||||
)}
|
||||
{parsed?.name ? <p className="text-sm opacity-70 mt-2">{parsed.name}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const CRON_PRESETS = [
|
||||
{ id: "every-minute", label: "Every minute", schedule: "* * * * *" },
|
||||
{ id: "every-5-minutes", label: "Every 5 minutes", schedule: "*/5 * * * *" },
|
||||
{ id: "every-15-minutes", label: "Every 15 minutes", schedule: "*/15 * * * *" },
|
||||
{ id: "every-30-minutes", label: "Every 30 minutes", schedule: "*/30 * * * *" },
|
||||
{ id: "every-hour", label: "Every hour", schedule: "0 * * * *" },
|
||||
{ id: "every-2-hours", label: "Every 2 hours", schedule: "0 */2 * * *" },
|
||||
{ id: "every-6-hours", label: "Every 6 hours", schedule: "0 */6 * * *" },
|
||||
{ id: "daily-midnight", label: "Every day at midnight", schedule: "0 0 * * *" },
|
||||
{ id: "daily-1am", label: "Every day at 1:00 AM", schedule: "0 1 * * *" },
|
||||
{ id: "weekdays-9am", label: "Weekdays at 9:00 AM", schedule: "0 9 * * 1-5" },
|
||||
];
|
||||
|
||||
export const CRON_CUSTOM = "custom";
|
||||
|
||||
export function matchCronPreset(schedule) {
|
||||
const value = String(schedule ?? "").trim();
|
||||
const found = CRON_PRESETS.find((p) => p.schedule === value);
|
||||
return found?.id ?? CRON_CUSTOM;
|
||||
}
|
||||
|
||||
export function scheduleForPreset(id) {
|
||||
return CRON_PRESETS.find((p) => p.id === id)?.schedule ?? null;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
|
||||
return ctx;
|
||||
return { output: ctx.data ?? null, context: ctx.context ?? {} };
|
||||
}
|
||||
|
||||
main.meta = {
|
||||
description: "",
|
||||
previewConfigKey: "",
|
||||
tags: [],
|
||||
config: {},
|
||||
input: {},
|
||||
output: {},
|
||||
context: {},
|
||||
example: {
|
||||
data: {},
|
||||
context: {},
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
@@ -18,6 +22,7 @@ export default main;
|
||||
|
||||
export const DEFAULT_INPUT_CONTEXT = `{
|
||||
"data": {},
|
||||
"context": {},
|
||||
"config": {}
|
||||
}
|
||||
`;
|
||||
@@ -28,6 +33,42 @@ export function normalizeScriptName(name) {
|
||||
return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`;
|
||||
}
|
||||
|
||||
export function scriptTags(meta) {
|
||||
if (!Array.isArray(meta?.tags)) return [];
|
||||
return meta.tags.map((t) => String(t).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function scriptReadsCtx(meta) {
|
||||
return meta?.reads === "ctx";
|
||||
}
|
||||
|
||||
const TAG_PALETTE = [
|
||||
{ bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" },
|
||||
{ bg: "color-mix(in oklab, #059669 22%, transparent)", text: "#059669" },
|
||||
{ bg: "color-mix(in oklab, #d97706 22%, transparent)", text: "#b45309" },
|
||||
{ bg: "color-mix(in oklab, #db2777 22%, transparent)", text: "#db2777" },
|
||||
{ bg: "color-mix(in oklab, #7c3aed 22%, transparent)", text: "#7c3aed" },
|
||||
{ bg: "color-mix(in oklab, #0d9488 22%, transparent)", text: "#0f766e" },
|
||||
{ bg: "color-mix(in oklab, #ea580c 22%, transparent)", text: "#c2410c" },
|
||||
{ bg: "color-mix(in oklab, #4f46e5 22%, transparent)", text: "#4338ca" },
|
||||
{ bg: "color-mix(in oklab, #dc2626 22%, transparent)", text: "#b91c1c" },
|
||||
{ bg: "color-mix(in oklab, #65a30d 22%, transparent)", text: "#4d7c0f" },
|
||||
];
|
||||
|
||||
function hashTag(tag) {
|
||||
let h = 2166136261;
|
||||
const s = String(tag).toLowerCase();
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function tagColor(tag) {
|
||||
return TAG_PALETTE[hashTag(tag) % TAG_PALETTE.length];
|
||||
}
|
||||
|
||||
export function prettyJson(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
@@ -37,8 +78,12 @@ export function prettyJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function defaultsFromFields(fields) {
|
||||
if (fields == null || typeof fields !== "object" || Array.isArray(fields)) return {};
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
@@ -49,15 +94,75 @@ function defaultsFromFields(fields) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function placeholderForField(spec) {
|
||||
const type = spec?.type;
|
||||
if (type === "object") return {};
|
||||
if (type === "array") return [];
|
||||
if (type === "number" || type === "integer") return 0;
|
||||
if (type === "boolean") return false;
|
||||
if (type === "string") return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
function seedFromInputFields(fields) {
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
const field = spec && typeof spec === "object" ? spec : {};
|
||||
if ("default" in field) out[key] = field.default;
|
||||
else if (field.required) out[key] = placeholderForField(field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inputHasFields(meta) {
|
||||
return isPlainObject(meta?.input) && Object.keys(meta.input).length > 0;
|
||||
}
|
||||
|
||||
/** First script step with a non-empty `meta.input`; else first script meta. */
|
||||
export function firstInputMeta(steps, scriptsByName) {
|
||||
let fallback = null;
|
||||
for (const step of steps ?? []) {
|
||||
if (step?.kind === "set") continue;
|
||||
const name = typeof step === "string" ? step : step?.script;
|
||||
if (!name) continue;
|
||||
const listed = scriptsByName?.get(name);
|
||||
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
|
||||
if (!fallback) fallback = meta;
|
||||
if (inputHasFields(meta)) return meta;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Seed workflow test `data` from required/default fields, example, then YAML `data`. */
|
||||
export function dataFromInputMeta(meta, yamlData) {
|
||||
const fromExample = contextFromMeta(meta).data;
|
||||
return {
|
||||
...seedFromInputFields(meta?.input),
|
||||
...(isPlainObject(fromExample) ? fromExample : {}),
|
||||
...(isPlainObject(yamlData) ? yamlData : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigFromMeta(meta) {
|
||||
if (meta?.example?.config && typeof meta.example.config === "object" && !Array.isArray(meta.example.config)) {
|
||||
return { ...meta.example.config };
|
||||
}
|
||||
return defaultsFromFields(meta?.config);
|
||||
}
|
||||
|
||||
export function contextFromMeta(meta) {
|
||||
if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
|
||||
return {
|
||||
data: "data" in meta.example ? meta.example.data : {},
|
||||
context: "context" in meta.example ? meta.example.context ?? {} : {},
|
||||
config: "config" in meta.example ? meta.example.config ?? {} : {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: defaultsFromFields(meta?.input),
|
||||
context: defaultsFromFields(meta?.context),
|
||||
config: defaultsFromFields(meta?.config),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "triggers"]);
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
export const NEW_WORKFLOW_YAML = `name: new workflow
|
||||
scripts:
|
||||
- get-current-time.js
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
`;
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused copy filename: `track.yaml` → `track-copy.yaml`,
|
||||
* `track-copy.yaml` → `track-copy-2.yaml`.
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
|
||||
let uidSeq = 0;
|
||||
|
||||
export function nextUiId(prefix = "ui") {
|
||||
uidSeq += 1;
|
||||
return `${prefix}-${uidSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath ?? "")
|
||||
.replace(/^\/+/, "")
|
||||
.trim();
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {{ doc: WorkflowDoc | null, parseError: string | null }}
|
||||
*/
|
||||
export function parseWorkflowYaml(text) {
|
||||
let raw;
|
||||
try {
|
||||
raw = parseYaml(text ?? "");
|
||||
} catch (err) {
|
||||
return {
|
||||
doc: null,
|
||||
parseError: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { doc: null, parseError: "workflow yaml must be an object" };
|
||||
}
|
||||
return { doc: yamlObjectToDoc(raw), parseError: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} raw
|
||||
*/
|
||||
export function yamlObjectToDoc(raw) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!KNOWN_TOP.has(key)) extra[key] = value;
|
||||
}
|
||||
return {
|
||||
name: typeof raw.name === "string" ? raw.name : "",
|
||||
description: raw.description == null ? "" : String(raw.description),
|
||||
enabled: raw.enabled !== false,
|
||||
scripts: Array.isArray(raw.scripts) ? raw.scripts.map((step) => normalizeStep(step)) : [],
|
||||
triggers: Array.isArray(raw.triggers)
|
||||
? raw.triggers.map((t) => normalizeTrigger(t))
|
||||
: [],
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyWorkflowDoc(doc) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (doc?.name) out.name = doc.name;
|
||||
if (doc?.description) out.description = doc.description;
|
||||
if (doc?.enabled === false) out.enabled = false;
|
||||
Object.assign(out, doc?.extra ?? {});
|
||||
out.scripts = (doc?.scripts ?? []).map(dumpStep);
|
||||
out.triggers = (doc?.triggers ?? []).map(dumpTrigger);
|
||||
return stringifyYaml(out, { indent: 2, lineWidth: 0 });
|
||||
}
|
||||
|
||||
export function newScriptStep(script, config = {}) {
|
||||
return {
|
||||
uiId: nextUiId("step"),
|
||||
kind: "script",
|
||||
script,
|
||||
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function newSetStep() {
|
||||
return {
|
||||
uiId: nextUiId("step"),
|
||||
kind: "set",
|
||||
script: "set",
|
||||
expression: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function newHttpTrigger() {
|
||||
return {
|
||||
uiId: nextUiId("trig"),
|
||||
type: "HTTP",
|
||||
method: "POST",
|
||||
path: "/new",
|
||||
schedule: "",
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function newCronTrigger() {
|
||||
return {
|
||||
uiId: nextUiId("trig"),
|
||||
type: "cron",
|
||||
method: "POST",
|
||||
path: "",
|
||||
schedule: "* * * * *",
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function newWorkflowTrigger() {
|
||||
return {
|
||||
uiId: nextUiId("trig"),
|
||||
type: "workflow",
|
||||
method: "POST",
|
||||
path: "",
|
||||
schedule: "",
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasWorkflowTrigger(workflow) {
|
||||
return (workflow?.triggers ?? []).some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
|
||||
}
|
||||
|
||||
export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
|
||||
return (workflows ?? []).filter((w) => {
|
||||
if (owner && w.owner !== owner) return false;
|
||||
if (excludeFile && w.file === excludeFile && w.owner === owner) return false;
|
||||
return hasWorkflowTrigger(w);
|
||||
});
|
||||
}
|
||||
|
||||
export { HTTP_METHODS };
|
||||
|
||||
function normalizeStep(step) {
|
||||
const uiId = nextUiId("step");
|
||||
if (typeof step === "string") {
|
||||
return {
|
||||
uiId,
|
||||
kind: "script",
|
||||
script: step,
|
||||
config: {},
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
};
|
||||
}
|
||||
if (step == null || typeof step !== "object" || Array.isArray(step)) {
|
||||
return {
|
||||
uiId,
|
||||
kind: "script",
|
||||
script: "",
|
||||
config: {},
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
};
|
||||
}
|
||||
if (step.set != null) {
|
||||
const spec = step.set && typeof step.set === "object" ? step.set : {};
|
||||
return {
|
||||
uiId,
|
||||
kind: "set",
|
||||
script: "set",
|
||||
expression: typeof spec.expression === "string" ? spec.expression : "",
|
||||
id: typeof step.id === "string" ? step.id : "",
|
||||
when: typeof step.when === "string" ? step.when : "",
|
||||
needs: step.needs ?? null,
|
||||
};
|
||||
}
|
||||
const known = new Set(["script", "config", "id", "when", "needs", "set"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
for (const [key, value] of Object.entries(step)) {
|
||||
if (!known.has(key)) extra[key] = value;
|
||||
}
|
||||
const config =
|
||||
step.config && typeof step.config === "object" && !Array.isArray(step.config)
|
||||
? { ...step.config }
|
||||
: {};
|
||||
return {
|
||||
uiId,
|
||||
kind: "script",
|
||||
script: typeof step.script === "string" ? step.script : "",
|
||||
config,
|
||||
id: typeof step.id === "string" ? step.id : "",
|
||||
when: typeof step.when === "string" ? step.when : "",
|
||||
needs: step.needs ?? null,
|
||||
extra: Object.keys(extra).length ? extra : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTrigger(raw) {
|
||||
const uiId = nextUiId("trig");
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return newHttpTrigger();
|
||||
}
|
||||
let type = raw.type ?? "HTTP";
|
||||
if (String(type).toLowerCase() === "http") type = "HTTP";
|
||||
const known =
|
||||
type === "HTTP"
|
||||
? new Set(["type", "method", "path", "auth", "response", "unauthorized"])
|
||||
: type === "cron"
|
||||
? new Set(["type", "schedule"])
|
||||
: new Set(["type"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!known.has(key)) extra[key] = value;
|
||||
}
|
||||
return {
|
||||
uiId,
|
||||
type,
|
||||
method: typeof raw.method === "string" && raw.method ? raw.method : "POST",
|
||||
path: typeof raw.path === "string" ? raw.path : "",
|
||||
schedule: typeof raw.schedule === "string" ? raw.schedule : "",
|
||||
auth: raw.auth ?? null,
|
||||
response: typeof raw.response === "string" ? raw.response : "",
|
||||
unauthorized: raw.unauthorized ?? null,
|
||||
extra: Object.keys(extra).length ? extra : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function dumpStep(step) {
|
||||
if (step.kind === "set") {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (step.id) out.id = step.id;
|
||||
out.set = {
|
||||
expression: step.expression ?? "",
|
||||
};
|
||||
if (step.when) out.when = step.when;
|
||||
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
|
||||
return out;
|
||||
}
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (step.id) out.id = step.id;
|
||||
out.script = step.script ?? "";
|
||||
const config = step.config;
|
||||
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
|
||||
out.config = config;
|
||||
}
|
||||
if (step.when) out.when = step.when;
|
||||
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
|
||||
Object.assign(out, step.extra ?? {});
|
||||
return out;
|
||||
}
|
||||
|
||||
function dumpTrigger(t) {
|
||||
const type = String(t?.type ?? "").toLowerCase() === "http" ? "HTTP" : t?.type;
|
||||
if (type === "HTTP") {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {
|
||||
type: "HTTP",
|
||||
method: t.method || "POST",
|
||||
path: t.path || "/",
|
||||
};
|
||||
if (t.auth != null && t.auth !== false && t.auth !== "") out.auth = t.auth;
|
||||
if (t.response) out.response = t.response;
|
||||
if (t.unauthorized != null && t.unauthorized !== "") out.unauthorized = t.unauthorized;
|
||||
Object.assign(out, t.extra ?? {});
|
||||
return out;
|
||||
}
|
||||
if (type === "cron") {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = { type: "cron", schedule: t.schedule || "" };
|
||||
Object.assign(out, t.extra ?? {});
|
||||
return out;
|
||||
}
|
||||
if (type === "workflow") {
|
||||
return { type: "workflow", ...(t.extra ?? {}) };
|
||||
}
|
||||
return { type: t?.type ?? "HTTP", ...(t.extra ?? {}) };
|
||||
}
|
||||
|
||||
function isEmptyNeeds(needs) {
|
||||
if (needs == null) return true;
|
||||
if (Array.isArray(needs)) return needs.length === 0;
|
||||
if (typeof needs === "object") return Object.keys(needs).length === 0;
|
||||
return false;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
function parseStep(step) {
|
||||
if (typeof step === "string") {
|
||||
return { kind: "script", script: step, id: null, needs: null, when: null, as: null };
|
||||
return { kind: "script", script: step, id: null, needs: null, when: null };
|
||||
}
|
||||
if (step?.set) {
|
||||
const as = typeof step.set?.as === "string" && step.set.as ? step.set.as : "set";
|
||||
return {
|
||||
kind: "set",
|
||||
script: null,
|
||||
as,
|
||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||
needs: null,
|
||||
needs: step.needs ?? null,
|
||||
when: typeof step.when === "string" && step.when ? step.when : null,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +15,6 @@ function parseStep(step) {
|
||||
return {
|
||||
kind: "script",
|
||||
script: step.script,
|
||||
as: null,
|
||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||
needs: step.needs ?? null,
|
||||
when: typeof step.when === "string" && step.when ? step.when : null,
|
||||
@@ -55,7 +52,7 @@ function triggerLabel(t) {
|
||||
|
||||
function stepLabel(s) {
|
||||
if (s.kind === "set") {
|
||||
const base = s.id ? `${s.id}: set ${s.as}` : `set ${s.as}`;
|
||||
const base = s.id ? `${s.id}: set` : "set";
|
||||
return s.when ? `${base} when: ${s.when}` : base;
|
||||
}
|
||||
const base = s.id ? `${s.id}: ${s.script}` : s.script;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const PREFIX = "scrunner.workflowTestData:";
|
||||
|
||||
export function workflowTestStorageKey(owner, file) {
|
||||
return `${PREFIX}${owner}/${file}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Overlay saved test JSON on the schema/YAML seed. Stored object keys win. */
|
||||
export function overlayWorkflowTestData(seed, stored) {
|
||||
const base = isPlainObject(seed) ? seed : {};
|
||||
if (stored == null) return base;
|
||||
if (isPlainObject(stored)) return { ...base, ...stored };
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function readWorkflowTestData(owner, file) {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(workflowTestStorageKey(owner, file));
|
||||
if (raw == null || raw === "") return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowTestData(owner, file, data) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(workflowTestStorageKey(owner, file), JSON.stringify(data));
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "./theme.jsx";
|
||||
import { NotificationProvider } from "./notifications.jsx";
|
||||
import { App } from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
@@ -19,9 +20,11 @@ createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<NotificationProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</NotificationProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
|
||||
const NotificationContext = createContext(null);
|
||||
|
||||
const TYPES = new Set(["info", "success", "warning", "error"]);
|
||||
const ALERT_CLASS = {
|
||||
info: "alert-info",
|
||||
success: "alert-success",
|
||||
warning: "alert-warning",
|
||||
error: "alert-error",
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
let seq = 0;
|
||||
function nextId() {
|
||||
seq += 1;
|
||||
return `toast-${seq}`;
|
||||
}
|
||||
|
||||
function isSticky(duration) {
|
||||
return duration === 0 || duration === Infinity;
|
||||
}
|
||||
|
||||
function parseNotify(input) {
|
||||
const raw = typeof input === "string" ? { message: input } : (input ?? {});
|
||||
const type = TYPES.has(raw.type) ? raw.type : "info";
|
||||
const message = String(raw.message ?? "");
|
||||
const duration = raw.duration ?? DEFAULT_DURATION;
|
||||
return { type, message, duration };
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const timers = useRef(new Map());
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const dismissRef = useRef(dismiss);
|
||||
dismissRef.current = dismiss;
|
||||
|
||||
const notify = useMemo(() => {
|
||||
function notifyFn(input) {
|
||||
const parsed = parseNotify(input);
|
||||
if (!parsed.message) return null;
|
||||
|
||||
const id = nextId();
|
||||
const item = { id, ...parsed };
|
||||
|
||||
setItems((prev) => {
|
||||
let kept = prev;
|
||||
if (prev.length >= MAX_ITEMS) {
|
||||
const overflow = prev.slice(0, prev.length - MAX_ITEMS + 1);
|
||||
for (const old of overflow) {
|
||||
const timer = timers.current.get(old.id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(old.id);
|
||||
}
|
||||
}
|
||||
kept = prev.slice(prev.length - MAX_ITEMS + 1);
|
||||
}
|
||||
return [...kept, item];
|
||||
});
|
||||
|
||||
if (!isSticky(parsed.duration)) {
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismissRef.current(id), parsed.duration),
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
notifyFn.success = (message, opts) => notifyFn({ type: "success", message, ...opts });
|
||||
notifyFn.error = (message, opts) => notifyFn({ type: "error", message, ...opts });
|
||||
notifyFn.warning = (message, opts) => notifyFn({ type: "warning", message, ...opts });
|
||||
notifyFn.info = (message, opts) => notifyFn({ type: "info", message, ...opts });
|
||||
return notifyFn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = timers.current;
|
||||
return () => {
|
||||
for (const timer of map.values()) clearTimeout(timer);
|
||||
map.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ items, notify, dismiss }), [items, notify, dismiss]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastHost items={items} onDismiss={dismiss} />
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastHost({ items, onDismiss }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="toast toast-end toast-bottom z-[100]">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role={item.type === "warning" || item.type === "error" ? "alert" : "status"}
|
||||
className={`alert ${ALERT_CLASS[item.type] ?? "alert-info"}`}
|
||||
>
|
||||
<span>{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => onDismiss(item.id)}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNotifications must be used within NotificationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { LuLogIn, LuUserPlus } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useLogin, useRegister } from "../api/hooks.js";
|
||||
import { brandBanner } from "../theme/brand.js";
|
||||
|
||||
export function AuthPage({ needsSetup }) {
|
||||
const login = useLogin();
|
||||
@@ -25,6 +26,11 @@ export function AuthPage({ needsSetup }) {
|
||||
<legend className="fieldset-legend text-lg">
|
||||
{needsSetup ? "Create admin" : "Sign in"}
|
||||
</legend>
|
||||
<img
|
||||
src={brandBanner}
|
||||
alt="JerapahFlow"
|
||||
className="mb-2 w-full rounded-box bg-neutral object-contain"
|
||||
/>
|
||||
<label className="label" htmlFor="username">
|
||||
Username
|
||||
</label>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMemo, useState, Fragment } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
||||
import { useRun } from "../api/hooks.js";
|
||||
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||
|
||||
function stepLabel(s) {
|
||||
if (s.script === "set") {
|
||||
return s.config?.as ? `set:${s.config.as}` : "set";
|
||||
}
|
||||
if (s.script === "set") return "set";
|
||||
return s.script;
|
||||
}
|
||||
|
||||
@@ -20,10 +19,32 @@ function isEditableScript(s) {
|
||||
return Boolean(s.script) && s.script !== "set";
|
||||
}
|
||||
|
||||
function envelopeParts(raw) {
|
||||
if (raw != null && typeof raw === "object" && !Array.isArray(raw) && ("output" in raw || "context" in raw)) {
|
||||
return {
|
||||
output: "output" in raw ? raw.output : undefined,
|
||||
context: "context" in raw ? raw.context : undefined,
|
||||
skipRemaining: raw.skipRemaining === true,
|
||||
};
|
||||
}
|
||||
return { output: raw, context: undefined, skipRemaining: false };
|
||||
}
|
||||
|
||||
export function EventDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: run, isLoading, error } = useRun(id);
|
||||
const [stepFilters, setStepFilters] = useState([]);
|
||||
const [openStep, setOpenStep] = useState(null);
|
||||
|
||||
function goBack() {
|
||||
const idx = window.history.state?.idx;
|
||||
if (typeof idx === "number" && idx > 0) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate("/events");
|
||||
}
|
||||
|
||||
const steps = run?.steps ?? [];
|
||||
const logs = run?.logs ?? [];
|
||||
@@ -50,12 +71,14 @@ export function EventDetailPage() {
|
||||
if (isLoading) return <span className="loading loading-spinner loading-lg" />;
|
||||
if (error || !run) return <p className="text-error">Event not found</p>;
|
||||
|
||||
const runParts = envelopeParts(run.output);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/events" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||
<button type="button" className="btn btn-ghost btn-sm btn-square" aria-label="Back" onClick={goBack}>
|
||||
<LuArrowLeft className="size-4" />
|
||||
</Link>
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold truncate">{run.workflow_name || run.workflow}</h1>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
@@ -88,6 +111,15 @@ export function EventDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<JsonViewBlock title="Run input (data)" value={run.input} />
|
||||
<JsonViewBlock title="Run output" value={runParts.output} />
|
||||
<JsonViewBlock title="Run context" value={runParts.context} />
|
||||
</div>
|
||||
{runParts.skipRemaining ? (
|
||||
<p className="text-sm opacity-70">This run stopped early (<span className="font-mono">skipRemaining</span>).</p>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<h2 className="font-semibold mb-2">Steps</h2>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -103,8 +135,12 @@ export function EventDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((s) => (
|
||||
<tr key={s.id}>
|
||||
{steps.map((s) => {
|
||||
const parts = envelopeParts(s.output);
|
||||
const open = openStep === s.id;
|
||||
return (
|
||||
<Fragment key={s.id}>
|
||||
<tr>
|
||||
<td>{s.step_index}</td>
|
||||
<td className="font-mono">
|
||||
{isEditableScript(s) ? (
|
||||
@@ -124,9 +160,16 @@ export function EventDetailPage() {
|
||||
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
|
||||
}
|
||||
>
|
||||
{s.error || ""}
|
||||
{s.error || (parts.skipRemaining ? "skipRemaining" : "")}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => setOpenStep(open ? null : s.id)}
|
||||
>
|
||||
I/O
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-ghost btn-xs btn-square ${stepFilters.includes(s.id) ? "btn-active" : ""}`}
|
||||
@@ -138,7 +181,19 @@ export function EventDetailPage() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{open ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-base-200">
|
||||
<div className="grid gap-2 p-2 sm:grid-cols-2">
|
||||
<JsonViewBlock title="output" value={parts.output} />
|
||||
<JsonViewBlock title="context" value={parts.context} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../api/hooks.js";
|
||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { StatusBadge } from "../lib/format.jsx";
|
||||
import {
|
||||
@@ -18,8 +19,10 @@ import {
|
||||
contextFromMeta,
|
||||
prettyJson,
|
||||
} from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptDryRunPage() {
|
||||
const { notify } = useNotifications();
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const location = useLocation();
|
||||
@@ -75,7 +78,9 @@ export function ScriptDryRunPage() {
|
||||
setRunStatus(lastRun.status);
|
||||
setLogs(lastRun.logs ?? []);
|
||||
if (lastRun.status === "success") {
|
||||
setOutputJson(prettyJson(lastRun.output));
|
||||
const envelope = { output: lastRun.output, context: lastRun.context };
|
||||
if (lastRun.skipRemaining) envelope.skipRemaining = true;
|
||||
setOutputJson(prettyJson(envelope));
|
||||
} else {
|
||||
setOutputJson(prettyJson({ error: lastRun.error ?? "run failed" }));
|
||||
}
|
||||
@@ -96,10 +101,11 @@ export function ScriptDryRunPage() {
|
||||
throw new Error(err instanceof Error ? err.message : "invalid JSON");
|
||||
}
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error('input context must be a JSON object with "data" and/or "config"');
|
||||
throw new Error('input must be a JSON object with "data", "context", and/or "config"');
|
||||
}
|
||||
return {
|
||||
data: "data" in parsed ? parsed.data : null,
|
||||
context: "context" in parsed ? parsed.context : {},
|
||||
config: "config" in parsed ? parsed.config : null,
|
||||
};
|
||||
}
|
||||
@@ -118,13 +124,17 @@ export function ScriptDryRunPage() {
|
||||
name,
|
||||
content,
|
||||
data: ctx.data,
|
||||
context: ctx.context,
|
||||
config: ctx.config,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -133,6 +143,7 @@ export function ScriptDryRunPage() {
|
||||
<Link to={backHref} className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||
<LuArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
||||
<h1 className="truncate font-mono text-lg font-semibold">{name}</h1>
|
||||
<span className="badge badge-ghost badge-sm">dry run</span>
|
||||
{runStatus ? <StatusBadge status={runStatus} /> : null}
|
||||
@@ -188,9 +199,6 @@ export function ScriptDryRunPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col gap-1">
|
||||
@@ -227,7 +235,7 @@ export function ScriptDryRunPage() {
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-1">
|
||||
<h2 className="shrink-0 text-sm font-semibold opacity-70">Output</h2>
|
||||
<h2 className="shrink-0 text-sm font-semibold opacity-70">Result (output + context)</h2>
|
||||
<div className="min-h-0 flex-1">
|
||||
<CodeEditor
|
||||
language="json"
|
||||
|
||||
@@ -4,8 +4,10 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useSaveScript, useScript } from "../api/hooks.js";
|
||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptNewPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -87,6 +89,7 @@ export function ScriptEditPage() {
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const existing = useScript(name);
|
||||
const save = useSaveScript();
|
||||
const [content, setContent] = useState("");
|
||||
@@ -102,7 +105,10 @@ export function ScriptEditPage() {
|
||||
|
||||
function onSave(e) {
|
||||
e.preventDefault();
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
function openDryRun() {
|
||||
@@ -137,6 +143,7 @@ export function ScriptEditPage() {
|
||||
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||
<LuArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
||||
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
|
||||
<div className="flex-1" />
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
|
||||
@@ -170,9 +177,6 @@ export function ScriptEditPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { LuPencil, LuPlay, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDeleteScript, useScripts } from "../api/hooks.js";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { TagBadge } from "../components/TagBadge.jsx";
|
||||
import { scriptTags } from "../lib/script.js";
|
||||
|
||||
export function ScriptsPage() {
|
||||
const [params] = useSearchParams();
|
||||
@@ -10,61 +13,108 @@ export function ScriptsPage() {
|
||||
const editName = params.get("edit");
|
||||
const { data: scripts = [], isLoading } = useScripts();
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const del = useDeleteScript();
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return scripts;
|
||||
return scripts.filter((s) => {
|
||||
const name = typeof s === "string" ? s : s.name ?? "";
|
||||
const description = typeof s === "string" ? "" : s.meta?.description ?? "";
|
||||
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||
return (
|
||||
name.toLowerCase().includes(term) ||
|
||||
description.toLowerCase().includes(term) ||
|
||||
tags.some((t) => t.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
}, [scripts, query]);
|
||||
|
||||
if (editName) {
|
||||
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Scripts</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="input input-sm w-48 sm:w-64">
|
||||
<LuSearch className="size-4 opacity-60" />
|
||||
<input
|
||||
type="search"
|
||||
className="grow"
|
||||
placeholder="Search scripts"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner" />
|
||||
) : scripts.length === 0 ? (
|
||||
<p className="text-sm opacity-50">No scripts yet.</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-sm opacity-50">No scripts match “{query.trim()}”.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th className="w-28" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scripts.map((s) => {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{visible.map((s) => {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
const description = typeof s === "string" ? "" : s.meta?.description;
|
||||
const metaError = typeof s === "string" ? null : s.metaError;
|
||||
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
|
||||
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||
return (
|
||||
<tr key={name} className="hover">
|
||||
<td className="font-mono">
|
||||
<Link className="link" to={`/scripts/${encodeURIComponent(name)}/edit`}>
|
||||
<article
|
||||
key={name}
|
||||
className="card bg-base-100 border border-base-300 w-60"
|
||||
>
|
||||
<div className="card-body p-4 gap-2">
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="flex flex-col items-center gap-2 min-h-0"
|
||||
>
|
||||
<div className="flex h-20 w-full items-center justify-center rounded-box bg-base-200">
|
||||
<ScriptIcon name={name} hasIcon={hasIcon} className="size-14" />
|
||||
</div>
|
||||
<h2 className="card-title font-mono text-sm justify-center text-center w-full">
|
||||
<span className="truncate" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</h2>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="max-w-xl truncate text-sm opacity-80">
|
||||
<p className="text-xs opacity-80 line-clamp-2 min-h-8">
|
||||
{metaError ? (
|
||||
<span className="text-error">{metaError}</span>
|
||||
) : (
|
||||
description || "—"
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
</p>
|
||||
{tags.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<TagBadge
|
||||
key={tag}
|
||||
tag={tag}
|
||||
title={`Search ${tag}`}
|
||||
onClick={() => setQuery(tag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card-actions justify-end mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Dry run"
|
||||
onClick={() =>
|
||||
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
|
||||
}
|
||||
onClick={() => navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)}
|
||||
>
|
||||
<LuPlay className="size-4" />
|
||||
</button>
|
||||
@@ -83,12 +133,11 @@ export function ScriptsPage() {
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState } from "react";
|
||||
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteVariable,
|
||||
useOwners,
|
||||
useUpsertVariable,
|
||||
useVariables,
|
||||
} from "../api/hooks.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
|
||||
const TYPES = ["string", "number", "boolean"];
|
||||
|
||||
function defaultValue(type) {
|
||||
if (type === "boolean") return false;
|
||||
if (type === "number") return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function displayValue(value) {
|
||||
if (typeof value === "string") return value === "" ? '""' : value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function VariablesPage() {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState("");
|
||||
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
|
||||
const upsert = useUpsertVariable();
|
||||
const del = useDeleteVariable();
|
||||
const [mode, setMode] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
owner: "",
|
||||
name: "",
|
||||
type: "string",
|
||||
value: "",
|
||||
});
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
function openAdd() {
|
||||
setMode("add");
|
||||
setFormError(null);
|
||||
setForm({
|
||||
owner: ownerFilter || owners[0] || "default",
|
||||
name: "",
|
||||
type: "string",
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
setMode("edit");
|
||||
setFormError(null);
|
||||
setForm({
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
value: row.type === "number" ? String(row.value) : row.value,
|
||||
});
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setMode(null);
|
||||
setFormError(null);
|
||||
setForm({ owner: "", name: "", type: "string", value: "" });
|
||||
}
|
||||
|
||||
function onTypeChange(type) {
|
||||
setForm({ ...form, type, value: defaultValue(type) });
|
||||
}
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
let value = form.value;
|
||||
if (form.type === "number") {
|
||||
value = Number(form.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
setFormError("value must be a finite number");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (form.type === "boolean") {
|
||||
value = form.value === true;
|
||||
}
|
||||
setFormError(null);
|
||||
upsert.mutate(
|
||||
{ owner: form.owner, name: form.name, type: form.type, value },
|
||||
{ onSuccess: closeForm },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Variables</h1>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={ownerFilter}
|
||||
onChange={(e) => setOwnerFilter(e.target.value)}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner" />
|
||||
) : variables.length === 0 ? (
|
||||
<p className="text-sm opacity-60">No variables yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{variables.map((row) => (
|
||||
<tr key={row.id} className="hover">
|
||||
<td className="font-mono">{row.owner}</td>
|
||||
<td className="font-mono">{row.name}</td>
|
||||
<td className="font-mono text-xs">{row.type}</td>
|
||||
<td className="font-mono text-xs max-w-xs truncate" title={displayValue(row.value)}>
|
||||
{displayValue(row.value)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap">{formatTime(row.updated_at)}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDelete(row)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode ? (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="fieldset bg-base-100 border-base-300 rounded-box max-w-md border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="fieldset-legend">
|
||||
{mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
onClick={closeForm}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="label">Owner</label>
|
||||
{owners.length > 0 ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<label className="label">Name</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Letters, numbers, dots, underscores, hyphens"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<label className="label">Type</label>
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.type}
|
||||
onChange={(e) => onTypeChange(e.target.value)}
|
||||
>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">Value</label>
|
||||
{form.type === "boolean" ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.value === true ? "true" : "false"}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value === "true" })}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : form.type === "number" ? (
|
||||
<input
|
||||
type="number"
|
||||
className="input w-full font-mono"
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
required
|
||||
step="any"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="textarea w-full font-mono min-h-24"
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Stored in plaintext. Use Secrets for credentials. In workflows use{" "}
|
||||
<span className="font-mono">$VAR_name</span> as a whole field.
|
||||
</p>
|
||||
{formError ? <p className="text-error text-sm">{formError}</p> : null}
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{confirmDelete ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold">
|
||||
Delete {confirmDelete.owner}/{confirmDelete.name}?
|
||||
</h3>
|
||||
<p className="text-sm mt-2">
|
||||
This cannot be undone. Workflows that reference $VAR_{confirmDelete.name} will fail.
|
||||
</p>
|
||||
{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(confirmDelete.id, { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.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";
|
||||
|
||||
const NEW_YAML = `name: new workflow
|
||||
scripts:
|
||||
- get-current-time.js
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
`;
|
||||
|
||||
function useYamlPreview(content) {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
const parsedYaml = parseYaml(content);
|
||||
return {
|
||||
parsed: parsedYaml,
|
||||
parseError: null,
|
||||
mermaid: workflowToFlowchart(parsedYaml),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
parsed: null,
|
||||
parseError: err instanceof Error ? err.message : String(err),
|
||||
mermaid: { chart: "", scriptIds: {} },
|
||||
};
|
||||
}
|
||||
}, [content]);
|
||||
}
|
||||
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
|
||||
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
function WorkflowEditorLayout({
|
||||
title,
|
||||
@@ -48,12 +20,9 @@ function WorkflowEditorLayout({
|
||||
savePending,
|
||||
saveDisabled,
|
||||
saveError,
|
||||
saveSuccess,
|
||||
formId,
|
||||
onRun,
|
||||
runPending,
|
||||
runDisabled,
|
||||
runError,
|
||||
onSave,
|
||||
onTest,
|
||||
onDuplicate,
|
||||
onToggleEnabled,
|
||||
enabled,
|
||||
enablePending,
|
||||
@@ -67,6 +36,7 @@ function WorkflowEditorLayout({
|
||||
<Link to={backTo} className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||
<LuArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<WorkflowFileIcon className="size-8 shrink-0" />
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
<div className="flex-1" />
|
||||
{onToggleEnabled ? (
|
||||
@@ -86,64 +56,31 @@ function WorkflowEditorLayout({
|
||||
{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" />
|
||||
) : (
|
||||
{onTest ? (
|
||||
<button type="button" className="btn btn-sm" onClick={onTest}>
|
||||
<LuPlay className="size-4" />
|
||||
)}
|
||||
Run
|
||||
Test
|
||||
</button>
|
||||
) : null}
|
||||
{onDuplicate ? (
|
||||
<button type="button" className="btn btn-sm" onClick={onDuplicate}>
|
||||
<LuCopy className="size-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={savePending || saveDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
<LuSave className="size-4" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
{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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowYamlAndDiagram({ content, onChange, parseError, mermaid, parsed }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
||||
<div className="min-h-0">
|
||||
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
||||
</div>
|
||||
<div className="min-h-0 overflow-auto">
|
||||
{parseError ? (
|
||||
<p className="text-error text-sm">{parseError}</p>
|
||||
) : (
|
||||
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
|
||||
)}
|
||||
{parsed?.name ? (
|
||||
<p className="text-sm opacity-70 mt-2">{parsed.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{saveError ? <p className="text-error text-sm shrink-0">{saveError}</p> : null}
|
||||
{enableError ? <p className="text-error text-sm shrink-0">{enableError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,16 +90,15 @@ export function WorkflowNewPage() {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [owner, setOwner] = useState("");
|
||||
const [file, setFile] = useState("");
|
||||
const [content, setContent] = useState(NEW_YAML);
|
||||
const [content, setContent] = useState(NEW_WORKFLOW_YAML);
|
||||
const [savedYaml] = useState(NEW_WORKFLOW_YAML);
|
||||
const save = useSaveWorkflow();
|
||||
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
||||
|
||||
useEffect(() => {
|
||||
if (!owner && owners[0]) setOwner(owners[0]);
|
||||
}, [owner, owners]);
|
||||
|
||||
function onSave(e) {
|
||||
e.preventDefault();
|
||||
function onSave() {
|
||||
const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`;
|
||||
save.mutate(
|
||||
{ owner, file: yamlFile, content },
|
||||
@@ -178,17 +114,21 @@ export function WorkflowNewPage() {
|
||||
return (
|
||||
<WorkflowEditorLayout
|
||||
title="New workflow"
|
||||
formId="workflow-edit-form"
|
||||
onSave={onSave}
|
||||
savePending={save.isPending}
|
||||
saveDisabled={!owner || !file}
|
||||
saveError={save.isError ? errorMessage(save.error) : null}
|
||||
>
|
||||
<form
|
||||
id="workflow-edit-form"
|
||||
onSubmit={onSave}
|
||||
className="flex min-h-0 flex-1 flex-col gap-3"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col sm:flex-row gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<WorkflowVisualEditor
|
||||
yaml={content}
|
||||
onYamlChange={setContent}
|
||||
owner={owner}
|
||||
file={file}
|
||||
savedYaml={savedYaml}
|
||||
showTest={false}
|
||||
extraChrome={
|
||||
<>
|
||||
<input
|
||||
className="input input-sm w-full sm:max-w-xs"
|
||||
placeholder="owner"
|
||||
@@ -203,58 +143,59 @@ export function WorkflowNewPage() {
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<WorkflowYamlAndDiagram
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
parseError={parseError}
|
||||
mermaid={mermaid}
|
||||
parsed={parsed}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</WorkflowEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
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 [savedYaml, setSavedYaml] = useState("");
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const routeKey = `${owner}/${file}`;
|
||||
const [activeKey, setActiveKey] = useState(routeKey);
|
||||
|
||||
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}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (activeKey !== routeKey) {
|
||||
setActiveKey(routeKey);
|
||||
setContent("");
|
||||
setSavedYaml("");
|
||||
setContentReady(false);
|
||||
setTestOpen(false);
|
||||
setDuplicateOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.isLoading) return;
|
||||
if (existing.data?.content != null) {
|
||||
if (!contentReady && existing.data?.content != null) {
|
||||
setContent(existing.data.content);
|
||||
setSavedYaml(existing.data.content);
|
||||
setContentReady(true);
|
||||
}
|
||||
}, [existing.data, existing.isLoading]);
|
||||
}, [existing.data, existing.isLoading, contentReady]);
|
||||
|
||||
function onSave(e) {
|
||||
e.preventDefault();
|
||||
save.mutate({ owner, file, content });
|
||||
function onSave() {
|
||||
save.mutate(
|
||||
{ owner, file, content },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSavedYaml(content);
|
||||
notify.success("Workflow saved");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isLoading) {
|
||||
@@ -277,18 +218,21 @@ export function WorkflowEditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const parsedDoc = parseWorkflowYaml(content);
|
||||
const yamlOk = !parsedDoc.parseError;
|
||||
const workflowName = parsedDoc.doc?.name?.trim();
|
||||
const pageTitle = workflowName ? `${workflowName} (${file})` : file;
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowEditorLayout
|
||||
title={`${owner}/${file}`}
|
||||
formId="workflow-edit-form"
|
||||
title={pageTitle}
|
||||
savePending={save.isPending}
|
||||
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}
|
||||
onSave={onSave}
|
||||
onTest={() => setTestOpen(true)}
|
||||
onDuplicate={() => setDuplicateOpen(true)}
|
||||
onToggleEnabled={() =>
|
||||
setEnabled.mutate({
|
||||
owner,
|
||||
@@ -298,18 +242,35 @@ export function WorkflowEditPage() {
|
||||
}
|
||||
enabled={existing.data?.parsed?.enabled !== false}
|
||||
enablePending={setEnabled.isPending}
|
||||
enableDisabled={!contentReady || Boolean(existing.data?.parseError)}
|
||||
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
|
||||
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
||||
>
|
||||
<form id="workflow-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col">
|
||||
<WorkflowYamlAndDiagram
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
parseError={parseError}
|
||||
mermaid={mermaid}
|
||||
parsed={parsed}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<WorkflowVisualEditor
|
||||
yaml={content}
|
||||
onYamlChange={setContent}
|
||||
owner={owner}
|
||||
file={file}
|
||||
savedYaml={savedYaml}
|
||||
showTest
|
||||
testOpen={testOpen}
|
||||
onTestClose={() => setTestOpen(false)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</WorkflowEditorLayout>
|
||||
{duplicateOpen ? (
|
||||
<DuplicateWorkflowDialog
|
||||
source={{ owner, file, key: `${owner}/${file}` }}
|
||||
warnUnsaved={contentReady && content !== savedYaml}
|
||||
onClose={() => setDuplicateOpen(false)}
|
||||
onDuplicated={(data) => {
|
||||
setDuplicateOpen(false);
|
||||
navigate(
|
||||
`/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteWorkflow,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
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() {
|
||||
@@ -17,6 +19,7 @@ export function WorkflowsPage() {
|
||||
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();
|
||||
@@ -120,9 +123,10 @@ export function WorkflowsPage() {
|
||||
<tr key={w.key} className="hover">
|
||||
<td>
|
||||
<Link
|
||||
className="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 ? (
|
||||
@@ -186,6 +190,15 @@ export function WorkflowsPage() {
|
||||
>
|
||||
<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"
|
||||
@@ -202,6 +215,19 @@ export function WorkflowsPage() {
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import mark from "./brand/mark.png";
|
||||
import wordmark from "./brand/wordmark.png";
|
||||
import icon from "./brand/icon.png";
|
||||
import fileYaml from "./brand/file-yaml.png";
|
||||
|
||||
/** Giraffe mark only. */
|
||||
export const brandMark = mark;
|
||||
/** Mark + JerapahFlow wordmark. */
|
||||
export const brandBanner = wordmark;
|
||||
/** App icon (giraffe + JF). */
|
||||
export const brandAppIcon = icon;
|
||||
/** Workflow YAML file icon. */
|
||||
export const brandWorkflowFile = fileYaml;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Generated
+91
@@ -68,15 +68,30 @@ importers:
|
||||
|
||||
packages/web:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.8)
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.84.0
|
||||
version: 5.101.4(react@19.2.8)
|
||||
'@uiw/react-json-view':
|
||||
specifier: 2.0.0-alpha.43
|
||||
version: 2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
axios:
|
||||
specifier: ^1.11.0
|
||||
version: 1.19.0
|
||||
cronstrue:
|
||||
specifier: ^3.24.0
|
||||
version: 3.24.0
|
||||
mermaid:
|
||||
specifier: ^11.9.0
|
||||
version: 11.16.1
|
||||
@@ -188,6 +203,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@8.0.0':
|
||||
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -206,6 +224,28 @@ packages:
|
||||
'@chevrotain/types@11.1.2':
|
||||
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -790,6 +830,13 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43':
|
||||
resolution: {integrity: sha512-PMj+6xRDCPbVGccfLUDhx7yGrmVBSeFx3py6bqDds4QIYScXo9GEgtTbWXn3gXpoehsmOu//6dveYTf872wu3Q==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.10.0'
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
|
||||
|
||||
@@ -912,6 +959,10 @@ packages:
|
||||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
cronstrue@3.24.0:
|
||||
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1902,6 +1953,9 @@ packages:
|
||||
resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
update-browserslist-db@1.3.1:
|
||||
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
||||
hasBin: true
|
||||
@@ -2073,6 +2127,8 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/runtime@8.0.0': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -2100,6 +2156,31 @@ snapshots:
|
||||
|
||||
'@chevrotain/types@11.1.2': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.8)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
|
||||
react: 19.2.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.8)':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
@@ -2586,6 +2667,12 @@ snapshots:
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
optionalDependencies:
|
||||
d3-selection: 3.0.0
|
||||
@@ -2711,6 +2798,8 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
cronstrue@3.24.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -3674,6 +3763,8 @@ snapshots:
|
||||
|
||||
ts-dedent@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
||||
dependencies:
|
||||
browserslist: 4.28.8
|
||||
|
||||
Reference in New Issue
Block a user