Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707e2c0f1e | ||
|
|
6c82ff20eb | ||
|
|
67ed3eecca | ||
|
|
80e9c91b88 | ||
|
|
88d3692116 | ||
|
|
a20b26b4c7 | ||
|
|
f1cdb7ac68 | ||
|
|
3f0774813d | ||
|
|
5615d989fa | ||
|
|
e2e70a5aad | ||
|
|
dd98421b08 | ||
|
|
69312c9080 | ||
|
|
c8697532f9 | ||
|
|
be8122f9e5 | ||
|
|
0a8463d8f4 | ||
|
|
41cca86e6e | ||
|
|
71cc705cd2 | ||
|
|
721f15e900 | ||
|
|
877ea9e3f8 | ||
|
|
93b51dcaaa | ||
|
|
eb7c318c13 | ||
|
|
864427b45c | ||
|
|
46aa8ca327 |
@@ -0,0 +1,33 @@
|
||||
name: Deploy to Raspberry Pi
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: home # must match a label on your act_runner
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
|
||||
export NVM_DIR="/home/nsrb/.nvm"
|
||||
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
|
||||
# shellcheck disable=SC1091
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
export PATH="$PNPM_HOME/bin:$PATH"
|
||||
|
||||
APP_DIR=/home/nsrb/apps/jerapah-flow
|
||||
cd "$APP_DIR"
|
||||
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||
pm2 save
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
# Instance data (SQLite, live workflows, control-state, backups)
|
||||
data/
|
||||
# Process logs
|
||||
logs/
|
||||
*.db
|
||||
*.db-*
|
||||
@@ -10,7 +12,7 @@ packages/web/dist
|
||||
# Personal/local scripts and workflows (not for the repo)
|
||||
debug-*.js
|
||||
|
||||
# Legacy live workflow tree (migrated to packages/server/data/workflows/)
|
||||
# Legacy live workflow trees
|
||||
packages/server/workflows/
|
||||
|
||||
# Plugin install staging and per-plugin deps
|
||||
|
||||
@@ -4,14 +4,14 @@ This file tells agents how to add a **user plugin**. Do not put personal or site
|
||||
|
||||
## Workflows (instance data)
|
||||
|
||||
Live workflows are **not** product source. They live under `packages/server/data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
|
||||
Live workflows are **not** product source. They live under `data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
|
||||
|
||||
| Kind | In git? | Path |
|
||||
|---|---|---|
|
||||
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` |
|
||||
| Live / personal YAML | No | `data/workflows/<owner>/` |
|
||||
| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) |
|
||||
|
||||
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
|
||||
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or `data/workflows/`. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
|
||||
|
||||
## Where things live
|
||||
|
||||
@@ -78,7 +78,7 @@ Copy the layout from `plugins/joplin-api`, `plugins/send-sms`, or `examples/plug
|
||||
Add `dependencies` only if the script `require()`s extra npm packages. Then:
|
||||
|
||||
```bash
|
||||
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline
|
||||
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline --ignore-workspace
|
||||
```
|
||||
|
||||
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
|
||||
@@ -120,7 +120,7 @@ Return `{ output, context?, skipRemaining? }`. Do not return `ctx`. Mutations of
|
||||
|---|---|
|
||||
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
|
||||
| `ctx.context` | Run clipboard (plain object) |
|
||||
| `ctx.config` | YAML `config` (secrets already unwrapped from `$SECRET_name`) |
|
||||
| `ctx.config` | YAML `config` (mustache refs like `{{ secrets.name }}` already resolved) |
|
||||
| `output` | Next step’s `data` |
|
||||
| `context` | Next clipboard. Omit to keep incoming |
|
||||
|
||||
@@ -138,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
- script: plugin/my-plugin
|
||||
- name: Notify to channel
|
||||
script: plugin/my-plugin
|
||||
config:
|
||||
url: http://10.8.0.6:3030/notes
|
||||
token: $SECRET_joplin_api_token
|
||||
token: "{{ secrets.joplin_api_token }}"
|
||||
```
|
||||
|
||||
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||
|
||||
## Do not
|
||||
|
||||
@@ -152,4 +153,4 @@ Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
|
||||
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
|
||||
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
|
||||
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`.
|
||||
- Put secrets in `script.js`; use YAML `{{ secrets.name }}` / `{{ vars.name }}` (quote if the value starts with `{`).
|
||||
|
||||
@@ -20,7 +20,13 @@ pnpm dev
|
||||
- UI (dev): http://localhost:8500
|
||||
- API: http://localhost:8700
|
||||
|
||||
The first account created becomes **admin**. Later accounts are created from Users.
|
||||
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
|
||||
|
||||
Reset or create the admin login from the host:
|
||||
|
||||
```bash
|
||||
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||
```
|
||||
|
||||
### Process modes
|
||||
|
||||
@@ -49,16 +55,16 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
|
||||
| Kind | Loaded by runner? | Location |
|
||||
|---|---|---|
|
||||
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
|
||||
| **Live workflows** | Yes | `data/workflows/<owner>/` (gitignored) |
|
||||
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
|
||||
|
||||
- Live YAML is **instance data**, same as SQLite and secrets — not product source. Prefer owner `local` for personal workflows.
|
||||
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
|
||||
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
|
||||
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
|
||||
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
|
||||
|
||||
```bash
|
||||
# Smoke
|
||||
# Smoke (isolated under packages/server/data — not the live instance tree)
|
||||
JFLOW_DATA_DIR=packages/server/data \
|
||||
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
node packages/server/test/plugins-smoke.js
|
||||
@@ -77,13 +83,34 @@ Each script is `async function main(ctx)` and **must** return:
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `ctx.config` | This step’s YAML config (mustache refs already resolved) |
|
||||
| `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.
|
||||
|
||||
### Config interpolation
|
||||
|
||||
YAML `config` strings may use mustache paths. Quote values that start with `{`.
|
||||
|
||||
```yaml
|
||||
url: "{{ vars.ntfy_channel }}"
|
||||
token: "{{ secrets.joplin_api_token }}"
|
||||
id: "{{ context.user.id }}"
|
||||
title: "{{ data.httpResponse.data.date }}"
|
||||
topic: "{{ vars.ntfy_prefix }}/{{ data.channel }}"
|
||||
```
|
||||
|
||||
| Root | Meaning |
|
||||
|---|---|
|
||||
| `vars` | Owner variable; remaining segments are the flat name (`{{ vars.foo.bar }}` → variable `foo.bar`) |
|
||||
| `secrets` | Same for secrets |
|
||||
| `context` | Run clipboard (nested) |
|
||||
| `data` | This step’s input (nested; numeric segments index arrays) |
|
||||
|
||||
A string that is exactly one `{{ path }}` keeps the native type (object/array/number/boolean). Mixed strings concatenate as text. Bare name fields such as `passwordSecret: gmail_app_password` stay names for `$secrets.get` — do not wrap them in `{{ secrets.… }}`. Script APIs `$vars.get` / `$secrets.get` are unchanged.
|
||||
|
||||
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.
|
||||
@@ -99,8 +126,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
||||
| `pnpm dev:server` | Monolith API/runner only |
|
||||
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||
| `pnpm build` | Production UI build |
|
||||
| `pnpm start` | Monolith: API + worker + built UI |
|
||||
| `pnpm start` | Monolith: API + worker + built UI (serves `dist` on :8700) |
|
||||
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
|
||||
| `pnpm start:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) |
|
||||
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||
| `pnpm start:worker` | BullMQ worker only |
|
||||
| `pnpm migrate` | Apply SQLite migrations |
|
||||
@@ -117,7 +145,7 @@ Admin UI route **Ops** (`/ops`) talks to the control process.
|
||||
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
|
||||
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) |
|
||||
|
||||
Desired state is stored in `packages/server/data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
|
||||
Desired state is stored in `data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -125,8 +153,10 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
||||
|---|---|---|
|
||||
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
|
||||
| `JFLOW_SECRETS_KEY` | `jflow-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. |
|
||||
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
|
||||
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). |
|
||||
| `JFLOW_DATA_DIR` | `data/` | Instance data root (SQLite, workflows, control-state, backups, trash). Falls back to `packages/server/data` if that tree still has the db or workflows. |
|
||||
| `JFLOW_DB_PATH` | `data/jerapah-flow.db` | SQLite file. |
|
||||
| `JFLOW_WORKFLOWS_DIR` | `data/workflows` | Live workflow YAML (instance data). |
|
||||
| `JFLOW_LOGS_DIR` | `logs/` | Rolling process logs. |
|
||||
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
||||
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
||||
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||
@@ -134,24 +164,45 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
||||
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. |
|
||||
| `JFLOW_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
||||
| `JFLOW_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. |
|
||||
| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). |
|
||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
||||
| `PORT` | `8700` | HTTP API port |
|
||||
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
|
||||
| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
|
||||
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
|
||||
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
|
||||
|
||||
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
|
||||
|
||||
## Production
|
||||
|
||||
Control-plane topology (same ports as `pnpm dev:pm2`):
|
||||
|
||||
| Process | Port | Role |
|
||||
|---|---|---|
|
||||
| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 |
|
||||
| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers |
|
||||
| `jflow-http` | **8700** | API + cron enqueue (managed by control) |
|
||||
| `jflow-worker` | — | BullMQ workers (managed by control) |
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
||||
# Or monolith (dev-style):
|
||||
# ... pnpm start
|
||||
# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
|
||||
# Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
|
||||
# against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
|
||||
pnpm start:pm2
|
||||
# UI: http://localhost:8500
|
||||
# If you already mixed versions: pnpm pm2 -- kill && pnpm start:pm2
|
||||
```
|
||||
|
||||
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process.
|
||||
Or without the ecosystem file:
|
||||
|
||||
```bash
|
||||
NODE_ENV=production pnpm start:control # :8600 + PM2 children
|
||||
NODE_ENV=production pnpm start:web # :8500
|
||||
```
|
||||
|
||||
Monolith (no Ops stop/scale): `pnpm build && pnpm start` serves the UI from the API process on :8700. Optional `JFLOW_SERVE_UI=1` on `start:api` does the same when you run HTTP alone — do **not** use that under control-plane mode (stopping HTTP would take down the UI).
|
||||
|
||||
+35
-8
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Production PM2 ecosystem (monolith runner).
|
||||
* Use for deployed/single-process starts. For local multi-process Ops UI, use
|
||||
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead.
|
||||
* Production PM2 ecosystem (control plane + UI).
|
||||
* Starts always-on processes only; HTTP (:8700) and workers are owned by
|
||||
* control.js via PM2 (same as `pnpm dev:pm2`).
|
||||
*
|
||||
* Use `pnpm start:pm2` (in-tree PM2 6.x). Do not use a global `pm2` 7.x —
|
||||
* a CLI/daemon version mismatch pegs CPU even with instances: 1.
|
||||
*
|
||||
* Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -28,20 +33,42 @@ function loadEnv(file) {
|
||||
}
|
||||
|
||||
const root = __dirname;
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
...loadEnv(path.join(root, ".env")),
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "jerapah-flow",
|
||||
name: "jflow-control",
|
||||
cwd: root,
|
||||
script: "packages/server/runner.js",
|
||||
interpreter: "node",
|
||||
script: "packages/server/control.js",
|
||||
interpreter: process.execPath,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 8,
|
||||
restart_delay: 2000,
|
||||
env: {
|
||||
...env,
|
||||
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "jflow-web",
|
||||
cwd: root,
|
||||
script: "packages/server/web-server.js",
|
||||
interpreter: process.execPath,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 20,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
...loadEnv(path.join(root, ".env")),
|
||||
...env,
|
||||
JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500",
|
||||
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||
JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Comic - monkeyuser to ntfy
|
||||
description: |
|
||||
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel).
|
||||
Scrape the latest MonkeyUser comic and send it to ntfy (requires {{ vars.ntfy_channel }}).
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
@@ -20,7 +20,7 @@ scripts:
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
url: "{{ vars.ntfy_channel }}"
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||
"start:web": "pnpm --filter @jerapah-flow/server start:web",
|
||||
"pm2": "node scripts/pm2.mjs",
|
||||
"start:pm2": "node scripts/pm2.mjs start ecosystem.config.cjs",
|
||||
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
||||
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
||||
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
import { REPO_ROOT } from "./paths.js";
|
||||
|
||||
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
||||
const ROOT_PKG = path.join(REPO_ROOT, "package.json");
|
||||
|
||||
/**
|
||||
* JerapahFlow app version from the monorepo root package.json.
|
||||
|
||||
+156
-72
@@ -3,36 +3,24 @@ 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_" },
|
||||
];
|
||||
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
const MUSTACHE_TOKEN_RE =
|
||||
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
|
||||
const WHOLE_MUSTACHE_RE =
|
||||
/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}$/;
|
||||
|
||||
/**
|
||||
* @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef
|
||||
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
|
||||
* @typedef {{
|
||||
* owner: string,
|
||||
* workflowKey?: string,
|
||||
* context?: unknown,
|
||||
* data?: 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.
|
||||
* Walk config (objects/arrays) and interpolate `{{ path }}` strings.
|
||||
* Does not walk trigger data.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
@@ -68,83 +56,179 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async function resolveStringRef(value, ctx) {
|
||||
const ref = parseConfigRef(value);
|
||||
if (!ref) return value;
|
||||
const whole = WHOLE_MUSTACHE_RE.exec(value);
|
||||
if (whole && whole[0] === value) {
|
||||
return resolvePath(whole[1], ctx, { raw: value, allowObject: true });
|
||||
}
|
||||
|
||||
if (ref.kind === "secret") {
|
||||
return resolveSecretRef(ref, ctx);
|
||||
if (!value.includes("{{")) {
|
||||
return value;
|
||||
}
|
||||
if (ref.kind === "var") {
|
||||
return resolveVarRef(ref, ctx);
|
||||
|
||||
MUSTACHE_TOKEN_RE.lastIndex = 0;
|
||||
let out = "";
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = MUSTACHE_TOKEN_RE.exec(value)) != null) {
|
||||
out += value.slice(lastIndex, match.index);
|
||||
const resolved = await resolvePath(match[1], ctx, {
|
||||
raw: match[0],
|
||||
allowObject: false,
|
||||
});
|
||||
out += stringifyScalar(resolved, match[0]);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
return resolveContextRef(ref, ctx);
|
||||
out += value.slice(lastIndex);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {string} pathExpr
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string>}
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
async function resolveSecretRef(ref, ctx) {
|
||||
try {
|
||||
assertSecretName(ref.name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid secret name`);
|
||||
async function resolvePath(pathExpr, ctx, opts) {
|
||||
const segments = pathExpr.split(".");
|
||||
if (segments.length === 0 || segments.some((s) => !s)) {
|
||||
throw new Error(`config ref ${opts.raw}: empty path`);
|
||||
}
|
||||
const plaintext = await getSecretPlaintext(ctx.owner, ref.name);
|
||||
if (plaintext == null) {
|
||||
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`);
|
||||
for (const seg of segments) {
|
||||
if (FORBIDDEN_SEGMENTS.has(seg)) {
|
||||
throw new Error(`config ref ${opts.raw}: forbidden path segment "${seg}"`);
|
||||
}
|
||||
}
|
||||
return plaintext;
|
||||
|
||||
const root = segments[0];
|
||||
const rest = segments.slice(1);
|
||||
|
||||
if (root === "vars") {
|
||||
return resolveNamedStore("var", rest, ctx, opts);
|
||||
}
|
||||
if (root === "secrets") {
|
||||
return resolveNamedStore("secret", rest, ctx, opts);
|
||||
}
|
||||
if (root === "context") {
|
||||
return walkObject(ctx.context, rest, opts);
|
||||
}
|
||||
if (root === "data") {
|
||||
return walkObject(ctx.data, rest, opts);
|
||||
}
|
||||
throw new Error(
|
||||
`config ref ${opts.raw}: unknown root "${root}" (use vars, secrets, context, or data)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {"var" | "secret"} kind
|
||||
* @param {string[]} rest
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
async function resolveVarRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty variable name`);
|
||||
async function resolveNamedStore(kind, rest, ctx, opts) {
|
||||
if (rest.length === 0) {
|
||||
throw new Error(`config ref ${opts.raw}: empty ${kind} name`);
|
||||
}
|
||||
const name = rest.join(".");
|
||||
try {
|
||||
assertVariableName(ref.name);
|
||||
if (kind === "secret") assertSecretName(name);
|
||||
else assertVariableName(name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid variable name`);
|
||||
throw new Error(`config ref ${opts.raw}: invalid ${kind} name`);
|
||||
}
|
||||
const value = await getVariablePlain(ctx.owner, ref.name);
|
||||
|
||||
if (kind === "secret") {
|
||||
const plaintext = await getSecretPlaintext(ctx.owner, name);
|
||||
if (plaintext == null) {
|
||||
throw new Error(`config ref ${opts.raw}: secret "${name}" not found`);
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
const value = await getVariablePlain(ctx.owner, name);
|
||||
if (value == null) {
|
||||
throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`);
|
||||
throw new Error(`config ref ${opts.raw}: variable "${name}" not found`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {string}
|
||||
* @param {unknown} root
|
||||
* @param {string[]} rest
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
function resolveContextRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty context key`);
|
||||
function walkObject(root, rest, opts) {
|
||||
if (rest.length === 0) {
|
||||
return unwrapValue(root, opts);
|
||||
}
|
||||
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`);
|
||||
|
||||
let cur = root;
|
||||
for (const seg of rest) {
|
||||
if (cur == null || typeof cur !== "object") {
|
||||
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||
}
|
||||
if (Array.isArray(cur)) {
|
||||
if (!/^\d+$/.test(seg)) {
|
||||
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||
}
|
||||
const idx = Number(seg);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
|
||||
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||
}
|
||||
cur = cur[idx];
|
||||
continue;
|
||||
}
|
||||
const bag = /** @type {Record<string, unknown>} */ (cur);
|
||||
if (!Object.prototype.hasOwnProperty.call(bag, seg)) {
|
||||
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||
}
|
||||
cur = bag[seg];
|
||||
}
|
||||
const raw = bag[ref.name];
|
||||
if (isSecret(raw)) {
|
||||
return raw.reveal();
|
||||
return unwrapValue(cur, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
function unwrapValue(value, opts) {
|
||||
if (isSecret(value)) {
|
||||
return value.reveal();
|
||||
}
|
||||
const coerced = coerceCredentialString(raw);
|
||||
if (coerced == null) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`);
|
||||
if (!opts.allowObject && value != null && typeof value === "object") {
|
||||
throw new Error(`config ref ${opts.raw}: value is not a scalar`);
|
||||
}
|
||||
return coerced;
|
||||
// Whole-value context/data may be any JSON type; mixed strings need scalars only.
|
||||
if (opts.allowObject) {
|
||||
if (value != null && typeof value === "object") return value;
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
// Prefer credential coercion for odd primitives (e.g. bigint) when whole-value.
|
||||
const coerced = coerceCredentialString(value);
|
||||
if (coerced != null) return coerced;
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} raw
|
||||
*/
|
||||
function stringifyScalar(value, raw) {
|
||||
if (value == null) {
|
||||
throw new Error(`config ref ${raw}: value is null`);
|
||||
}
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
throw new Error(`config ref ${raw}: value is not a scalar`);
|
||||
}
|
||||
|
||||
+21
-18
@@ -64,13 +64,6 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function applyDesiredState() {
|
||||
const state = readControlState();
|
||||
await ensureHttp({
|
||||
@@ -98,8 +91,6 @@ async function applyDesiredState() {
|
||||
);
|
||||
}
|
||||
|
||||
await applyDesiredState();
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
await server.register(cookie);
|
||||
await server.register(jwt, {
|
||||
@@ -484,12 +475,24 @@ process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
||||
server
|
||||
.listen({ host: "0.0.0.0", port })
|
||||
.then(() => {
|
||||
log.info(`Control is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start control");
|
||||
process.exit(1);
|
||||
});
|
||||
try {
|
||||
await server.listen({ host: "0.0.0.0", port });
|
||||
log.info(`Control is running on port ${port}`);
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to start control");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await applyDesiredState();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to apply desired state");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function readScript(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
|
||||
* @returns {{ filePath: string, contentType: string } | null}
|
||||
*/
|
||||
export function resolveScriptIcon(name) {
|
||||
@@ -59,6 +59,7 @@ export function resolveScriptIcon(name) {
|
||||
for (const { ext, contentType } of [
|
||||
{ ext: "png", contentType: "image/png" },
|
||||
{ ext: "jpg", contentType: "image/jpeg" },
|
||||
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||
]) {
|
||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.text("job_id");
|
||||
t.text("queued_at");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_job_id_idx");
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.dropColumn("job_id");
|
||||
t.dropColumn("queued_at");
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.integer("workflow_revision");
|
||||
});
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_workflow_revision_idx");
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.dropColumn("workflow_revision");
|
||||
});
|
||||
}
|
||||
+5
-38
@@ -17,6 +17,9 @@ export async function up(knex) {
|
||||
t.text("output");
|
||||
t.text("error");
|
||||
t.text("parent_run_id").references("id").inTable("workflow_runs");
|
||||
t.text("job_id");
|
||||
t.text("queued_at");
|
||||
t.integer("workflow_revision");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
@@ -28,45 +31,11 @@ export async function up(knex) {
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)",
|
||||
);
|
||||
|
||||
await knex.schema.createTable("step_runs", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("run_id")
|
||||
.notNullable()
|
||||
.references("id")
|
||||
.inTable("workflow_runs")
|
||||
.onDelete("CASCADE");
|
||||
t.integer("step_index").notNullable();
|
||||
t.text("script").notNullable();
|
||||
t.text("config");
|
||||
t.text("status").notNullable();
|
||||
t.text("started_at").notNullable();
|
||||
t.text("finished_at");
|
||||
t.integer("duration_ms");
|
||||
t.text("output");
|
||||
t.text("error");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
|
||||
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
|
||||
);
|
||||
|
||||
await knex.schema.createTable("logs", (t) => {
|
||||
t.increments("id").primary();
|
||||
t.text("run_id")
|
||||
.notNullable()
|
||||
.references("id")
|
||||
.inTable("workflow_runs")
|
||||
.onDelete("CASCADE");
|
||||
t.text("step_id");
|
||||
t.text("ts").notNullable();
|
||||
t.integer("level").notNullable();
|
||||
t.text("msg");
|
||||
t.text("payload");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
|
||||
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +43,5 @@ export async function up(knex) {
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("logs");
|
||||
await knex.schema.dropTableIfExists("step_runs");
|
||||
await knex.schema.dropTableIfExists("workflow_runs");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("step_runs", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("run_id")
|
||||
.notNullable()
|
||||
.references("id")
|
||||
.inTable("workflow_runs")
|
||||
.onDelete("CASCADE");
|
||||
t.integer("step_index").notNullable();
|
||||
t.text("script").notNullable();
|
||||
t.text("config");
|
||||
t.text("status").notNullable();
|
||||
t.text("started_at").notNullable();
|
||||
t.text("finished_at");
|
||||
t.integer("duration_ms");
|
||||
t.text("output");
|
||||
t.text("error");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("step_runs");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("logs", (t) => {
|
||||
t.increments("id").primary();
|
||||
t.text("run_id")
|
||||
.notNullable()
|
||||
.references("id")
|
||||
.inTable("workflow_runs")
|
||||
.onDelete("CASCADE");
|
||||
t.text("step_id");
|
||||
t.text("ts").notNullable();
|
||||
t.integer("level").notNullable();
|
||||
t.text("msg");
|
||||
t.text("payload");
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("logs");
|
||||
}
|
||||
+20
-20
@@ -29,35 +29,35 @@ const DEFAULT_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.alterTable("http_pages", (t) => {
|
||||
await knex.schema.createTable("http_pages", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("name").notNullable().unique();
|
||||
t.text("content").notNullable();
|
||||
t.text("mime").notNullable();
|
||||
t.integer("status").notNullable().defaultTo(200);
|
||||
t.text("kind").notNullable().defaultTo("response");
|
||||
t.integer("system").notNullable().defaultTo(0);
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = await knex("http_pages").where({ name: "email-default" }).first();
|
||||
if (!existing) {
|
||||
await knex("http_pages").insert({
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "email-default",
|
||||
content: DEFAULT_EMAIL_TEMPLATE,
|
||||
mime: "html",
|
||||
status: 200,
|
||||
kind: "template",
|
||||
system: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
await knex("http_pages").insert({
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "email-default",
|
||||
content: DEFAULT_EMAIL_TEMPLATE,
|
||||
mime: "html",
|
||||
status: 200,
|
||||
kind: "template",
|
||||
system: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex("http_pages").where({ name: "email-default", system: 1 }).del();
|
||||
await knex.schema.alterTable("http_pages", (t) => {
|
||||
t.dropColumn("kind");
|
||||
t.dropColumn("system");
|
||||
});
|
||||
await knex.schema.dropTableIfExists("http_pages");
|
||||
}
|
||||
+2
-13
@@ -2,21 +2,11 @@
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("http_pages", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("name").notNullable().unique();
|
||||
t.text("content").notNullable();
|
||||
t.text("mime").notNullable(); // html | json
|
||||
t.integer("status").notNullable().defaultTo(200);
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
});
|
||||
|
||||
await knex.schema.createTable("http_auths", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("name").notNullable().unique();
|
||||
t.text("type").notNullable(); // bearer | basic | header
|
||||
t.text("config").notNullable(); // JSON
|
||||
t.text("type").notNullable();
|
||||
t.text("config").notNullable();
|
||||
t.integer("unauthorized_status").nullable();
|
||||
t.text("unauthorized_response").nullable();
|
||||
t.text("created_at").notNullable();
|
||||
@@ -29,5 +19,4 @@ export async function up(knex) {
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("http_auths");
|
||||
await knex.schema.dropTableIfExists("http_pages");
|
||||
}
|
||||
-18
@@ -21,29 +21,11 @@ export async function up(knex) {
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)",
|
||||
);
|
||||
|
||||
await knex.schema.createTable("workflow_trash", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("workflow_id").notNullable();
|
||||
t.text("owner").notNullable();
|
||||
t.text("file").notNullable();
|
||||
t.text("name");
|
||||
t.text("deleted_at").notNullable();
|
||||
t.text("trash_path").notNullable();
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
|
||||
);
|
||||
await knex.schema.raw(
|
||||
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("workflow_trash");
|
||||
await knex.schema.dropTableIfExists("workflow_revisions");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("workflow_trash", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("workflow_id").notNullable();
|
||||
t.text("owner").notNullable();
|
||||
t.text("file").notNullable();
|
||||
t.text("name");
|
||||
t.text("deleted_at").notNullable();
|
||||
t.text("trash_path").notNullable();
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
|
||||
);
|
||||
await knex.schema.raw(
|
||||
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("workflow_trash");
|
||||
}
|
||||
+3
-1
@@ -14,7 +14,9 @@ export async function up(knex) {
|
||||
t.unique(["owner", "name"]);
|
||||
});
|
||||
|
||||
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)");
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,97 @@
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
]);
|
||||
|
||||
const REPLY_SKIP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Control origin used when the UI or HTTP process proxies to control.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function controlOrigin() {
|
||||
const explicit = process.env.JFLOW_CONTROL_URL?.trim();
|
||||
if (explicit) return explicit.replace(/\/$/, "");
|
||||
const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600);
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP API origin (workflow triggers + REST).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function httpOrigin() {
|
||||
const explicit = process.env.JFLOW_HTTP_URL?.trim();
|
||||
if (explicit) return explicit.replace(/\/$/, "");
|
||||
const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700);
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the incoming request to `origin`, preserving path + query.
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
* @param {string} origin
|
||||
* @param {{ unreachableMessage?: string }} [opts]
|
||||
*/
|
||||
export async function proxyToOrigin(req, reply, origin, opts = {}) {
|
||||
const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`;
|
||||
const headers = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue;
|
||||
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const hasBody = method !== "GET" && method !== "HEAD";
|
||||
let body;
|
||||
if (hasBody) {
|
||||
if (Buffer.isBuffer(req.body)) body = req.body;
|
||||
else if (typeof req.body === "string") body = req.body;
|
||||
else if (req.body != null) {
|
||||
body = JSON.stringify(req.body);
|
||||
if (!headers["content-type"]) headers["content-type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(target, { method, headers, body });
|
||||
} catch (err) {
|
||||
const message = opts.unreachableMessage ?? "upstream unreachable";
|
||||
req.log.warn({ err, target }, `proxy: ${message}`);
|
||||
return reply.code(502).send({ error: message });
|
||||
}
|
||||
|
||||
reply.code(res.status);
|
||||
res.headers.forEach((value, key) => {
|
||||
if (REPLY_SKIP.has(key.toLowerCase())) return;
|
||||
reply.header(key, value);
|
||||
});
|
||||
return reply.send(Buffer.from(await res.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward `/ops/*` to the control plane (same-origin UI in production).
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
export async function proxyOpsToControl(req, reply) {
|
||||
return proxyToOrigin(req, reply, controlOrigin(), {
|
||||
unreachableMessage: "control plane unreachable",
|
||||
});
|
||||
}
|
||||
@@ -11,11 +11,14 @@
|
||||
"start:api": "node server.js",
|
||||
"start:worker": "node worker.js",
|
||||
"start:control": "node control.js",
|
||||
"start:web": "node web-server.js",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||
"test:plugins": "JFLOW_DATA_DIR=./data JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||
"test:workflow-history": "JFLOW_DATA_DIR=./data JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||
"reset-admin": "node reset-admin.js",
|
||||
"test:profiles": "node test/profiles-smoke.js",
|
||||
"test:set-dry-run": "node test/set-dry-run-smoke.js"
|
||||
"test:set-dry-run": "node test/set-dry-run-smoke.js",
|
||||
"test:config-refs": "node test/config-refs-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
@@ -40,7 +43,7 @@
|
||||
"nodemailer": "^9.0.5",
|
||||
"pino": "^10.3.1",
|
||||
"pino-roll": "^4.0.0",
|
||||
"pm2": "^6.0.13",
|
||||
"pm2": "6.0.14",
|
||||
"rss-parser": "^3.13.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"webdav": "^5.10.0",
|
||||
|
||||
+39
-17
@@ -1,27 +1,49 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||
|
||||
/** Prefer `preferred` unless only `legacy` already has files. */
|
||||
function existingDir(preferred, legacy, probe) {
|
||||
const has = (dir) =>
|
||||
probe ? probe(dir) : fs.existsSync(dir);
|
||||
if (has(preferred) || !has(legacy)) return preferred;
|
||||
return legacy;
|
||||
}
|
||||
|
||||
function hasInstanceData(dir) {
|
||||
return (
|
||||
fs.existsSync(path.join(dir, "jerapah-flow.db")) ||
|
||||
fs.existsSync(path.join(dir, "workflows"))
|
||||
);
|
||||
}
|
||||
|
||||
/** Instance data (SQLite, live workflows, control-state). Not product source. */
|
||||
export const DATA_DIR = path.resolve(
|
||||
process.env.JFLOW_DATA_DIR ??
|
||||
existingDir(
|
||||
path.join(REPO_ROOT, "data"),
|
||||
path.join(SERVER_ROOT, "data"),
|
||||
hasInstanceData,
|
||||
),
|
||||
);
|
||||
/** Live instance workflows (not shipped in git). Override for tests. */
|
||||
export const WORKFLOWS_DIR =
|
||||
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows");
|
||||
/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */
|
||||
export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||
export const WORKFLOWS_DIR = path.resolve(
|
||||
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"),
|
||||
);
|
||||
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||
export const PLUGINS_DIR =
|
||||
process.env.JFLOW_PLUGINS_DIR ??
|
||||
path.resolve(SERVER_ROOT, "../../plugins");
|
||||
export const PLUGINS_DIR = path.resolve(
|
||||
process.env.JFLOW_PLUGINS_DIR ?? path.join(REPO_ROOT, "plugins"),
|
||||
);
|
||||
/** Example plugin sources shipped with the repo. */
|
||||
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/plugins",
|
||||
);
|
||||
export const EXAMPLE_PLUGINS_DIR = path.join(REPO_ROOT, "examples/plugins");
|
||||
/** Example workflow YAML presets (not loaded by the runner). */
|
||||
export const EXAMPLE_WORKFLOWS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/workflows",
|
||||
export const EXAMPLE_WORKFLOWS_DIR = path.join(REPO_ROOT, "examples/workflows");
|
||||
export const LOGS_DIR = path.resolve(
|
||||
process.env.JFLOW_LOGS_DIR ??
|
||||
existingDir(path.join(REPO_ROOT, "logs"), path.join(SERVER_ROOT, "logs")),
|
||||
);
|
||||
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
||||
export const WEB_DIST = path.join(REPO_ROOT, "packages/web/dist");
|
||||
|
||||
@@ -51,9 +51,18 @@ export async function pnpmInstallPlugin(dir) {
|
||||
}
|
||||
if (!hasDeps) return { skipped: true };
|
||||
|
||||
// --ignore-workspace: plugins live under the app tree but are not workspace
|
||||
// packages; without this, pnpm install --dir can no-op against the root monorepo.
|
||||
await execFileAsync(
|
||||
"pnpm",
|
||||
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"],
|
||||
[
|
||||
"install",
|
||||
"--dir",
|
||||
dir,
|
||||
"--ignore-scripts",
|
||||
"--prefer-offline",
|
||||
"--ignore-workspace",
|
||||
],
|
||||
{
|
||||
cwd: dir,
|
||||
env: { ...process.env, CI: "1" },
|
||||
|
||||
@@ -380,6 +380,96 @@ export function forkCoreScript(coreName, newId, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an installed plugin to a new plugin id.
|
||||
*
|
||||
* @param {string} sourceId
|
||||
* @param {string} newId
|
||||
* @param {{ description?: string }} [opts]
|
||||
*/
|
||||
export function duplicatePlugin(sourceId, newId, opts = {}) {
|
||||
const fromId = assertPluginId(sourceId);
|
||||
const id = assertPluginId(newId);
|
||||
if (id === fromId) {
|
||||
const err = new Error("cannot duplicate onto itself");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (coreBareNames().has(id)) {
|
||||
const err = new Error(`plugin id collides with core script: ${id}`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
if (fs.existsSync(pluginDir(id))) {
|
||||
const err = new Error(`plugin already exists: ${id}`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const source = getInstalledPlugin(fromId);
|
||||
if (!source) {
|
||||
const err = new Error(`plugin not found: ${fromId}`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
if (!source.manifest) {
|
||||
const err = new Error(
|
||||
source.compatError || `plugin has no valid manifest: ${fromId}`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const staging = path.join(PLUGINS_DIR, `.staging-dup-${id}-${Date.now()}`);
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
try {
|
||||
fs.cpSync(source.dir, staging, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
const base = path.basename(src);
|
||||
return base !== "node_modules" && base !== ".disabled";
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = buildManifest({
|
||||
id,
|
||||
name: id,
|
||||
version: source.manifest.version,
|
||||
jerapah: source.manifest.jerapah,
|
||||
main: source.manifest.main,
|
||||
description:
|
||||
opts.description ?? source.manifest.description ?? null,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(staging, PLUGIN_MANIFEST),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const pkgPath = path.join(staging, "package.json");
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
let pkg = {};
|
||||
try {
|
||||
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
} catch {
|
||||
pkg = {};
|
||||
}
|
||||
if (pkg == null || typeof pkg !== "object" || Array.isArray(pkg)) {
|
||||
pkg = {};
|
||||
}
|
||||
pkg.name = `jflow-plugin-${id}`;
|
||||
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
return installPluginFromDirectory(staging, {
|
||||
overwrite: false,
|
||||
reason: `plugin:${id} duplicated from ${fromId}`,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginDirectory
|
||||
* @returns {((id: string) => unknown) | null}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import path from "path";
|
||||
import pm2 from "pm2";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||
import { REPO_ROOT, SERVER_ROOT } from "./paths.js";
|
||||
|
||||
export const PM2_HTTP_NAME = "jflow-http";
|
||||
export const PM2_WORKER_NAME = "jflow-worker";
|
||||
@@ -164,13 +162,57 @@ export async function restartPm2Process(pmId) {
|
||||
return { name: proc.name, pmId: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* PM2 injects these into process.env of a managed app. Spreading them into
|
||||
* `pm2.start({ env })` overwrites `name` / `pm_exec_path` so God restarts
|
||||
* jflow-control instead of launching http/worker (EADDRINUSE :8600 loop).
|
||||
*/
|
||||
const PM2_META_KEYS = new Set([
|
||||
"name",
|
||||
"namespace",
|
||||
"exec_mode",
|
||||
"exec_interpreter",
|
||||
"instances",
|
||||
"instance_var",
|
||||
"node_app_instance",
|
||||
"unique_id",
|
||||
"status",
|
||||
"username",
|
||||
"windowsHide",
|
||||
"merge_logs",
|
||||
"vizion",
|
||||
"vizion_running",
|
||||
"autostart",
|
||||
"autorestart",
|
||||
"automation",
|
||||
"km_link",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env
|
||||
* @returns {NodeJS.ProcessEnv}
|
||||
*/
|
||||
export function withoutPm2Meta(env) {
|
||||
/** @type {NodeJS.ProcessEnv} */
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(env)) {
|
||||
if (val == null) continue;
|
||||
if (PM2_META_KEYS.has(key)) continue;
|
||||
if (key.startsWith("pm_") || key.startsWith("axm_") || key.startsWith("PM2_")) {
|
||||
continue;
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared env for child processes.
|
||||
* @param {{ generation: number }} opts
|
||||
*/
|
||||
export function childEnv(opts) {
|
||||
return {
|
||||
...process.env,
|
||||
...withoutPm2Meta(process.env),
|
||||
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
||||
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
resolveFailureTriggerConfig,
|
||||
} from "./trigger-failure.js";
|
||||
import { enqueueWorkflowJob } from "./workflow-queue.js";
|
||||
import { ensureInitialRevision } from "./workflow-history.js";
|
||||
import { ensureInitialRevision, recordRevision } from "./workflow-history.js";
|
||||
import { workflowIdFromFile } from "./workflow-normalize.js";
|
||||
import { publishReload } from "./control-bus.js";
|
||||
|
||||
/**
|
||||
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
|
||||
@@ -509,6 +511,7 @@ export function createRegistry(server, opts = {}) {
|
||||
owner,
|
||||
workflowKey: key,
|
||||
context: incomingContext,
|
||||
data: ctx.data,
|
||||
});
|
||||
const stepCtx = {
|
||||
data: ctx.data,
|
||||
@@ -553,6 +556,45 @@ export function createRegistry(server, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `enabled: false` for a workflow and reload registries across processes.
|
||||
* @param {string} owner
|
||||
* @param {string} file
|
||||
* @param {string} key
|
||||
*/
|
||||
async function disableWorkflowForConsecutiveFailures(owner, file, key) {
|
||||
const content = fsStore.readWorkflowYaml(owner, file);
|
||||
if (content == null) {
|
||||
throw new Error(`workflow file missing for ${key}`);
|
||||
}
|
||||
const doc = yaml.parseDocument(content);
|
||||
if (doc.errors?.length) {
|
||||
throw new Error(doc.errors[0]?.message ?? "invalid yaml");
|
||||
}
|
||||
const parsed = doc.toJSON();
|
||||
if (parsed?.enabled === false) {
|
||||
log.debug({ workflow: key }, "workflow already disabled");
|
||||
return;
|
||||
}
|
||||
doc.set("enabled", false);
|
||||
const nextContent = String(doc);
|
||||
fsStore.writeWorkflowYaml(owner, file, nextContent);
|
||||
await recordRevision({
|
||||
workflowId: workflowIdFromFile(file),
|
||||
owner,
|
||||
file,
|
||||
content: nextContent,
|
||||
reason: "disable-on-consecutive-failures",
|
||||
});
|
||||
reregister();
|
||||
try {
|
||||
await publishReload({ type: "workflows" });
|
||||
} catch {
|
||||
// Redis may be briefly unavailable; local reload already applied.
|
||||
}
|
||||
log.warn({ workflow: key }, "disabled workflow after consecutive failures");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* key: string,
|
||||
@@ -589,6 +631,17 @@ export function createRegistry(server, opts = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (failureConfig.disableOnConsecutiveFailures) {
|
||||
const entry = workflows.get(opts.key);
|
||||
if (entry) {
|
||||
await disableWorkflowForConsecutiveFailures(entry.owner, entry.file, opts.key);
|
||||
} else {
|
||||
log.warn({ workflow: opts.key }, "cannot disable missing workflow entry");
|
||||
}
|
||||
}
|
||||
|
||||
if (!failureConfig.workflowName) return;
|
||||
|
||||
const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
|
||||
const alertData = buildFailureAlertData({
|
||||
sourceKey: opts.key,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Reset (or create) the admin username and password.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||
*
|
||||
* Uses JFLOW_DB_PATH like the app. Never prints the password.
|
||||
*/
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, migrate } from "./db.js";
|
||||
import * as store from "./store.js";
|
||||
import { validateCredentials } from "./src/api/auth.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ username?: string, password?: string }} */
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--username" || arg === "-u") {
|
||||
out.username = argv[++i];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--password" || arg === "-p") {
|
||||
out.password = argv[++i];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
out.help = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
pnpm --dir packages/server reset-admin -- --username <name> --password <secret>
|
||||
|
||||
Creates an admin if none exist; otherwise updates the oldest admin's
|
||||
username and password. Credentials must match login rules
|
||||
(username 3-32 [A-Za-z0-9_], password at least 8 characters).`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const username = typeof args.username === "string" ? args.username.trim() : "";
|
||||
const password = typeof args.password === "string" ? args.password : "";
|
||||
if (!username || !password) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const credErr = validateCredentials(username, password);
|
||||
if (credErr) {
|
||||
console.error(credErr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate();
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const admins = await db("users")
|
||||
.where({ role: "admin" })
|
||||
.orderBy("created_at", "asc")
|
||||
.select("id", "username");
|
||||
|
||||
if (admins.length === 0) {
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
console.log(`Created admin user "${user.username}" (${user.id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = admins[0];
|
||||
const taken = await store.getUserAuthByUsername(username);
|
||||
if (taken && taken.id !== admin.id) {
|
||||
console.error(`username "${username}" is already taken by another user`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = await store.updateUser(admin.id, {
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
console.log(
|
||||
`Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||
|
||||
const hostRequire = createRequire(import.meta.url);
|
||||
|
||||
@@ -21,7 +22,9 @@ const ALLOWED_MODULES = new Set([
|
||||
"basic-ftp",
|
||||
"jsonata",
|
||||
"mustache",
|
||||
"net",
|
||||
"node-html-parser",
|
||||
"node:net",
|
||||
"node:stream",
|
||||
"nodemailer",
|
||||
"rss-parser",
|
||||
@@ -443,7 +446,7 @@ function createScriptSandbox({
|
||||
log,
|
||||
script,
|
||||
workflowName,
|
||||
owner = "default",
|
||||
owner = DEFAULT_OWNER,
|
||||
$workflows = $workflowsStub,
|
||||
pluginDir = null,
|
||||
}) {
|
||||
@@ -556,16 +559,23 @@ function instantiateCompiled(
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} [opts]
|
||||
*
|
||||
* When `pluginDir` is omitted, it is resolved from `script` so inspect/dry-run
|
||||
* of `plugin/<id>` can `require()` extra packages from that plugin's node_modules.
|
||||
*/
|
||||
export function instantiateScriptSource(script, source, opts = {}) {
|
||||
const compiled = compileScriptSource(source, script);
|
||||
const pluginDir =
|
||||
"pluginDir" in opts
|
||||
? opts.pluginDir ?? null
|
||||
: (resolveScriptRef(script).pluginDir ?? null);
|
||||
const fn = instantiateCompiled(compiled, {
|
||||
log: opts.log ?? inspectLog,
|
||||
script,
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
owner: opts.owner ?? DEFAULT_OWNER,
|
||||
$workflows: opts.$workflows,
|
||||
pluginDir: opts.pluginDir ?? null,
|
||||
pluginDir,
|
||||
});
|
||||
return { fn, ...extractScriptMeta(fn) };
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -15,9 +15,24 @@ function ntfyHeaders(ctx) {
|
||||
headers.Title = ctx.data.title;
|
||||
}
|
||||
|
||||
if (isMarkdownEnabled(ctx.config?.markdown)) {
|
||||
// ntfy accepts X-Markdown / Markdown / md with true | 1 | yes
|
||||
headers.Markdown = "yes";
|
||||
log.info("ntfy: Markdown enabled");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function isMarkdownEnabled(value) {
|
||||
if (value === true || value === 1) return true;
|
||||
if (typeof value === "string") {
|
||||
const v = value.trim().toLowerCase();
|
||||
return v === "true" || v === "yes" || v === "1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveFingerprintKey(config) {
|
||||
const fingerprint = config?.fingerprint;
|
||||
if (fingerprint === true) return "fingerprint:ntfy";
|
||||
@@ -149,6 +164,12 @@ ntfy.meta = {
|
||||
default: "https://ntfy.sh/jerapah-flow",
|
||||
description: "ntfy topic URL",
|
||||
},
|
||||
markdown: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Render body as Markdown (sets Markdown: yes; aliases X-Markdown / md also accepted by ntfy)",
|
||||
},
|
||||
fingerprint: {
|
||||
type: "string",
|
||||
required: false,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -8,6 +8,7 @@ import {
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
duplicatePlugin,
|
||||
listCoreScriptNames,
|
||||
listInstalledPlugins,
|
||||
resolveScriptRef,
|
||||
@@ -29,8 +30,9 @@ import { normalizeStepResult } from "../../step-result.js";
|
||||
import { resolveConfigRefs } from "../../config-refs.js";
|
||||
import { getAppVersion } from "../../app-version.js";
|
||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
||||
import { parsePluginScriptRef, pluginScriptRef } from "../../plugin-manifest.js";
|
||||
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
@@ -250,6 +252,40 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/scripts/:name/duplicate", async (req, reply) => {
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
const body = /** @type {{ id?: string, description?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.id !== "string" || !body.id.trim()) {
|
||||
return reply.code(400).send({ error: "id is required" });
|
||||
}
|
||||
const parsed = parsePluginScriptRef(rawName);
|
||||
if (!parsed) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "name must be a plugin ref (plugin/<id>)" });
|
||||
}
|
||||
try {
|
||||
const installed = duplicatePlugin(parsed.id, body.id.trim(), {
|
||||
description: body.description,
|
||||
});
|
||||
clearScriptCache();
|
||||
return reply.code(201).send({
|
||||
...installed,
|
||||
restartNeeded: true,
|
||||
warning:
|
||||
"Plugins run as the JerapahFlow process user. Review code before install.",
|
||||
});
|
||||
} catch (err) {
|
||||
return reply
|
||||
.code(/** @type {any} */ (err).statusCode ?? 500)
|
||||
.send({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
@@ -258,7 +294,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
req.body ?? {}
|
||||
);
|
||||
|
||||
let owner = "default";
|
||||
let owner = DEFAULT_OWNER;
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
try {
|
||||
owner = fsStore.assertOwner(String(body.owner));
|
||||
@@ -304,6 +340,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
data: incomingData,
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
@@ -365,6 +402,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
data: incomingData,
|
||||
});
|
||||
const ctx = {
|
||||
data: incomingData,
|
||||
@@ -428,7 +466,6 @@ export default function scriptsPluginFactory(registry) {
|
||||
|
||||
fastify.post(
|
||||
"/plugins/create",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
|
||||
req.body ?? {}
|
||||
@@ -460,7 +497,6 @@ export default function scriptsPluginFactory(registry) {
|
||||
|
||||
fastify.post(
|
||||
"/plugins/install",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
source?: string,
|
||||
@@ -535,7 +571,6 @@ export default function scriptsPluginFactory(registry) {
|
||||
|
||||
fastify.delete(
|
||||
"/plugins/:id",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
|
||||
@@ -76,6 +76,7 @@ function triggerSummary(owner, workflow, nameById) {
|
||||
schedule: t?.schedule ?? null,
|
||||
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
|
||||
onFailureWorkflow: t?.onFailureWorkflow ?? null,
|
||||
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
|
||||
auth: isHttp ? authLabel(t?.auth, nameById) : null,
|
||||
};
|
||||
});
|
||||
@@ -705,15 +706,14 @@ export default function workflowsPluginFactory(registry) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||
if (raw == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
let name = null;
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
name = parsed?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
if (raw != null) {
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
name = parsed?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
const item = await moveWorkflowToTrash({
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
getRedisUrlForLog,
|
||||
} from "./workflow-queue.js";
|
||||
import { purgeExpiredTrash } from "./workflow-trash.js";
|
||||
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
|
||||
import {
|
||||
getConfigGeneration,
|
||||
startHeartbeatLoop,
|
||||
@@ -129,12 +128,6 @@ export async function startApp(opts = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
migrateLegacyWorkflowsIfNeeded();
|
||||
} catch (err) {
|
||||
log.warn({ err }, "legacy workflow migrate failed");
|
||||
}
|
||||
|
||||
const registry = createRegistry(server, {
|
||||
queue: workflowQueue,
|
||||
// Cron + HTTP triggers enqueue jobs; only the API process may own them.
|
||||
|
||||
@@ -582,12 +582,13 @@ export async function listUsers() {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ passwordHash?: string, role?: string }} patch
|
||||
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||
*/
|
||||
export async function updateUser(id, patch) {
|
||||
const update = { updated_at: nowIso() };
|
||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||
if (patch.role) update.role = patch.role;
|
||||
if (patch.username) update.username = patch.username;
|
||||
await db("users").where({ id }).update(update);
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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";
|
||||
import { resolveConfigRefs } from "../config-refs.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
@@ -23,126 +23,138 @@ async function assertRejects(fn, match) {
|
||||
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 owner = "config_refs_smoke_owner";
|
||||
const ctx = { owner, workflowKey: `${owner}/config-refs-smoke.yaml`, context: {}, data: {} };
|
||||
|
||||
{
|
||||
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,
|
||||
});
|
||||
|
||||
const created = [];
|
||||
try {
|
||||
const secret = await upsertSecret({
|
||||
owner,
|
||||
name: "config_refs_smoke_token",
|
||||
value: "s3cret-ok",
|
||||
});
|
||||
created.push(["secret", secret.id]);
|
||||
|
||||
const varUrl = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_url",
|
||||
type: "string",
|
||||
value: "https://example.test",
|
||||
});
|
||||
created.push(["var", varUrl.id]);
|
||||
|
||||
const varRetry = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_retry",
|
||||
type: "number",
|
||||
value: 3,
|
||||
});
|
||||
created.push(["var", varRetry.id]);
|
||||
|
||||
const varDebug = await upsertVariable({
|
||||
owner,
|
||||
name: "config_refs_smoke_debug",
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
created.push(["var", varDebug.id]);
|
||||
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve");
|
||||
const resolved = await resolveConfigRefs("{{ secrets.config_refs_smoke_token }}", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret whole-value");
|
||||
}
|
||||
{
|
||||
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);
|
||||
const resolved = await resolveConfigRefs("{{ vars.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("{{ vars.config_refs_smoke_retry }}", ctx);
|
||||
assert(resolved === 3, "var number keeps 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("{{ vars.config_refs_smoke_debug }}", ctx);
|
||||
assert(resolved === false, "var boolean keeps type");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_token", {
|
||||
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", {
|
||||
const resolved = await resolveConfigRefs("{{ context.n }}", {
|
||||
...ctx,
|
||||
context: { n: 7 },
|
||||
});
|
||||
assert(resolved === "7", "context number stringify");
|
||||
assert(resolved === 7, "context number keeps type");
|
||||
}
|
||||
{
|
||||
const wrapped = new Secret("wrapped-secret-ok");
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_tok", {
|
||||
const resolved = await resolveConfigRefs("{{ context.tok }}", {
|
||||
...ctx,
|
||||
context: { tok: wrapped },
|
||||
});
|
||||
assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
|
||||
}
|
||||
|
||||
{
|
||||
const resolved = await resolveConfigRefs("{{ context.user }}", {
|
||||
...ctx,
|
||||
context: { user: { id: "u1", role: "admin" } },
|
||||
});
|
||||
assert(
|
||||
resolved && typeof resolved === "object" && resolved.id === "u1",
|
||||
"whole-value object pass-through",
|
||||
);
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("{{ context.user.id }}", {
|
||||
...ctx,
|
||||
context: { user: { id: "nested-id" } },
|
||||
});
|
||||
assert(resolved === "nested-id", "nested context path");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("{{ data.items.0.id }}", {
|
||||
...ctx,
|
||||
data: { items: [{ id: "row-0" }] },
|
||||
});
|
||||
assert(resolved === "row-0", "array index path");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs(
|
||||
"{{ vars.config_refs_smoke_url }}/{{ data.channel }}",
|
||||
{ ...ctx, data: { channel: "alerts" } },
|
||||
);
|
||||
assert(resolved === "https://example.test/alerts", "concatenation");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("Bearer {{ context.token }}", {
|
||||
...ctx,
|
||||
context: { token: "abc" },
|
||||
});
|
||||
assert(resolved === "Bearer abc", "mixed string");
|
||||
}
|
||||
{
|
||||
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",
|
||||
url: "{{ vars.config_refs_smoke_url }}",
|
||||
retry: "{{ vars.config_refs_smoke_retry }}",
|
||||
debug: "{{ vars.config_refs_smoke_debug }}",
|
||||
password: "{{ secrets.config_refs_smoke_token }}",
|
||||
headers: { Authorization: "$KV_modem_password" },
|
||||
extra: ["$CONTEXT_token", "plain"],
|
||||
extra: ["{{ context.token }}", "plain"],
|
||||
},
|
||||
{ ...ctx, context: { token: "ctx-token-ok" } },
|
||||
);
|
||||
@@ -155,59 +167,47 @@ try {
|
||||
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 data = { password: "{{ secrets.config_refs_smoke_token }}" };
|
||||
const config = { password: "{{ secrets.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");
|
||||
assert(data.password === "{{ secrets.config_refs_smoke_token }}", "data not walked");
|
||||
assert(config.password === "{{ secrets.config_refs_smoke_token }}", "input config not mutated");
|
||||
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx),
|
||||
() => resolveConfigRefs("{{ secrets.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',
|
||||
() => resolveConfigRefs("{{ context.missing }}", ctx),
|
||||
"path not found",
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
resolveConfigRefs("$CONTEXT_obj", {
|
||||
resolveConfigRefs("Bearer {{ context.obj }}", {
|
||||
...ctx,
|
||||
context: { obj: { a: 1 } },
|
||||
}),
|
||||
'context "obj" is not a scalar',
|
||||
"not a scalar",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_", ctx),
|
||||
"empty context key",
|
||||
() => resolveConfigRefs("{{ vars }}", ctx),
|
||||
"empty var name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx),
|
||||
'variable "does_not_exist_xyz" not found',
|
||||
() => resolveConfigRefs("{{ title }}", ctx),
|
||||
"unknown root",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_not valid", ctx),
|
||||
"invalid variable name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_", ctx),
|
||||
"empty variable name",
|
||||
() => resolveConfigRefs("{{ context.__proto__.x }}", ctx),
|
||||
"forbidden path segment",
|
||||
);
|
||||
} finally {
|
||||
await deleteSecret(secret.id);
|
||||
await deleteVariable(varUrl.id);
|
||||
await deleteVariable(varRetry.id);
|
||||
await deleteVariable(varDebug.id);
|
||||
for (const [kind, id] of created.reverse()) {
|
||||
if (kind === "secret") await deleteSecret(id);
|
||||
else await deleteVariable(id);
|
||||
}
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
console.log("config-refs smoke test passed");
|
||||
await db.destroy();
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
|
||||
*
|
||||
* Run:
|
||||
* JFLOW_DATA_DIR=packages/server/data \
|
||||
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
* node packages/server/test/plugins-smoke.js
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
import path from "node:path";
|
||||
import { migrate, db } from "../db.js";
|
||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
duplicatePlugin,
|
||||
resolveScriptRef,
|
||||
uninstallPlugin,
|
||||
listInstalledPlugins,
|
||||
createBlankPlugin,
|
||||
pluginDir,
|
||||
} from "../plugin-store.js";
|
||||
import { installExamplePlugin } from "../plugin-install.js";
|
||||
import {
|
||||
@@ -82,6 +86,26 @@ async function main() {
|
||||
);
|
||||
assert.equal(blankRun.output.ok, true);
|
||||
|
||||
const duplicated = duplicatePlugin("blank-smoke", "blank-smoke-copy");
|
||||
assert.equal(duplicated.scriptRef, "plugin/blank-smoke-copy");
|
||||
clearScriptCache();
|
||||
assert.equal(resolveScriptRef("plugin/blank-smoke-copy").kind, "plugin");
|
||||
const dupRun = await runScript(
|
||||
"plugin/blank-smoke-copy",
|
||||
{ data: 1, context: {}, config: null },
|
||||
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||
);
|
||||
assert.equal(dupRun.output.ok, true);
|
||||
|
||||
let hitDupSelf = false;
|
||||
try {
|
||||
duplicatePlugin("blank-smoke", "blank-smoke");
|
||||
} catch (err) {
|
||||
hitDupSelf = true;
|
||||
assert.match(String(err.message), /itself/);
|
||||
}
|
||||
assert.equal(hitDupSelf, true);
|
||||
|
||||
let hit = false;
|
||||
try {
|
||||
forkCoreScript("ntfy.js", "ntfy");
|
||||
@@ -97,10 +121,58 @@ async function main() {
|
||||
);
|
||||
assert.ok(meta);
|
||||
|
||||
const extra = createBlankPlugin(
|
||||
"extra-require-smoke",
|
||||
`const extra = require("smoke-extra");
|
||||
async function main() {
|
||||
return { output: { n: extra.n } };
|
||||
}
|
||||
main.meta = {
|
||||
description: "smoke extra require",
|
||||
config: {},
|
||||
input: {},
|
||||
output: { n: { type: "number" } },
|
||||
example: { data: {}, config: {} },
|
||||
};
|
||||
export default main;
|
||||
`,
|
||||
);
|
||||
assert.equal(extra.scriptRef, "plugin/extra-require-smoke");
|
||||
const extraPkg = path.join(
|
||||
pluginDir("extra-require-smoke"),
|
||||
"node_modules",
|
||||
"smoke-extra",
|
||||
);
|
||||
fs.mkdirSync(extraPkg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extraPkg, "package.json"),
|
||||
`${JSON.stringify({ name: "smoke-extra", main: "index.js" })}\n`,
|
||||
);
|
||||
fs.writeFileSync(path.join(extraPkg, "index.js"), "module.exports = { n: 9 };\n");
|
||||
clearScriptCache();
|
||||
const extraSource = fs.readFileSync(
|
||||
path.join(pluginDir("extra-require-smoke"), "script.js"),
|
||||
"utf8",
|
||||
);
|
||||
const extraInspect = inspectScriptSource(
|
||||
"plugin/extra-require-smoke",
|
||||
extraSource,
|
||||
);
|
||||
assert.equal(extraInspect.metaError, null, extraInspect.metaError);
|
||||
assert.equal(extraInspect.meta?.description, "smoke extra require");
|
||||
const extraRun = await runScript(
|
||||
"plugin/extra-require-smoke",
|
||||
{ data: null, context: {}, config: null },
|
||||
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||
);
|
||||
assert.equal(extraRun.output.n, 9);
|
||||
|
||||
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
|
||||
|
||||
uninstallPlugin("jsonata-smoke-fork");
|
||||
uninstallPlugin("blank-smoke");
|
||||
uninstallPlugin("blank-smoke-copy");
|
||||
uninstallPlugin("extra-require-smoke");
|
||||
uninstallPlugin("get-current-time");
|
||||
|
||||
console.log("plugins-smoke: ok");
|
||||
|
||||
@@ -71,11 +71,11 @@ const created = await upsertProfile({
|
||||
owner,
|
||||
name,
|
||||
script: "ntfy.js",
|
||||
config: { url: "$VAR_ntfy_channel" },
|
||||
config: { url: "{{ vars.ntfy_channel }}" },
|
||||
description: "smoke",
|
||||
});
|
||||
assert(created.name === name, "created");
|
||||
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip");
|
||||
assert(created.config.url === "{{ vars.ntfy_channel }}", "config roundtrip");
|
||||
assert(created.script === "ntfy.js", "script locked on profile");
|
||||
|
||||
const fetched = await getProfilePlain(owner, name);
|
||||
|
||||
@@ -136,6 +136,18 @@ const warnings = collectWorkflowWarnings(
|
||||
);
|
||||
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
|
||||
|
||||
const ghostFile = newWorkflowFilename();
|
||||
fsStore.writeRegisters(owner, [file, ghostFile]);
|
||||
const ghostTrash = await moveWorkflowToTrash({
|
||||
workflowId: workflowIdFromFile(ghostFile),
|
||||
owner,
|
||||
file: ghostFile,
|
||||
name: null,
|
||||
});
|
||||
assert.equal(ghostTrash, null);
|
||||
assert.ok(!fsStore.readRegisters(owner).includes(ghostFile));
|
||||
assert.ok(fsStore.readRegisters(owner).includes(file));
|
||||
|
||||
const trashed = await moveWorkflowToTrash({
|
||||
workflowId,
|
||||
owner,
|
||||
|
||||
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
|
||||
|
||||
const threshold = Number(spec.onConsecutiveFailures);
|
||||
const workflowName = onFailureWorkflowName(spec);
|
||||
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
|
||||
const disableOnConsecutiveFailures = isDisableOnConsecutiveFailures(spec);
|
||||
if (
|
||||
!Number.isFinite(threshold) ||
|
||||
threshold < 1 ||
|
||||
(workflowName.length === 0 && !disableOnConsecutiveFailures)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
threshold: Math.floor(threshold),
|
||||
workflowName,
|
||||
workflowName: workflowName.length > 0 ? workflowName : null,
|
||||
disableOnConsecutiveFailures,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} trigger
|
||||
*/
|
||||
export function isDisableOnConsecutiveFailures(trigger) {
|
||||
return trigger?.disableOnConsecutiveFailures === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
|
||||
const hasThreshold =
|
||||
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
|
||||
const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
|
||||
const hasDisable = isDisableOnConsecutiveFailures(trigger);
|
||||
|
||||
if (!hasThreshold && !hasWorkflow) continue;
|
||||
if (!hasThreshold && !hasWorkflow && !hasDisable) continue;
|
||||
|
||||
if (!hasThreshold || !hasWorkflow) {
|
||||
if (!hasThreshold || (!hasWorkflow && !hasDisable)) {
|
||||
const err = new Error(
|
||||
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger",
|
||||
"onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Production UI server (:8500).
|
||||
* Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev.
|
||||
* Always-on — survives Ops stop of jflow-http.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import fastify from "fastify";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { log } from "./logger.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
import {
|
||||
controlOrigin,
|
||||
httpOrigin,
|
||||
proxyToOrigin,
|
||||
} from "./ops-proxy.js";
|
||||
|
||||
if (!fs.existsSync(WEB_DIST)) {
|
||||
log.error(
|
||||
{ WEB_DIST },
|
||||
"web dist missing — run `pnpm build` before starting the UI server",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = Number(process.env.JFLOW_UI_PORT ?? 8500);
|
||||
const control = controlOrigin();
|
||||
const http = httpOrigin();
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function proxyApi(req, reply) {
|
||||
const url = req.raw.url ?? "";
|
||||
// Match Vite: /api/auth → control (login works when HTTP is stopped).
|
||||
if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) {
|
||||
return proxyToOrigin(req, reply, control, {
|
||||
unreachableMessage: "control plane unreachable",
|
||||
});
|
||||
}
|
||||
return proxyToOrigin(req, reply, http, {
|
||||
unreachableMessage: "HTTP API unreachable",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function proxyOps(req, reply) {
|
||||
return proxyToOrigin(req, reply, control, {
|
||||
unreachableMessage: "control plane unreachable",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function proxyHttp(req, reply) {
|
||||
return proxyToOrigin(req, reply, http, {
|
||||
unreachableMessage: "HTTP API unreachable",
|
||||
});
|
||||
}
|
||||
|
||||
server.all("/api", proxyApi);
|
||||
server.all("/api/*", proxyApi);
|
||||
server.all("/ops", proxyOps);
|
||||
server.all("/ops/*", proxyOps);
|
||||
server.all("/admin", proxyHttp);
|
||||
server.all("/admin/*", proxyHttp);
|
||||
server.all("/u", proxyHttp);
|
||||
server.all("/u/*", proxyHttp);
|
||||
|
||||
await server.register(fastifyStatic, {
|
||||
root: WEB_DIST,
|
||||
wildcard: false,
|
||||
});
|
||||
|
||||
server.setNotFoundHandler((req, reply) => {
|
||||
const url = req.raw.url ?? "";
|
||||
if (
|
||||
url.startsWith("/api") ||
|
||||
url.startsWith("/u/") ||
|
||||
url.startsWith("/admin") ||
|
||||
url.startsWith("/ops")
|
||||
) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
try {
|
||||
await server.close();
|
||||
} catch (err) {
|
||||
log.error({ err }, "web-server shutdown error");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
try {
|
||||
await server.listen({ host: "0.0.0.0", port });
|
||||
log.info(
|
||||
{ port, WEB_DIST, control, http },
|
||||
"UI server listening (static + proxy)",
|
||||
);
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to start UI server");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTTP_METHODS } from "@jerapah-flow/shared";
|
||||
import { HTTP_METHODS, DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||
import {
|
||||
checkAnyHttpAuth,
|
||||
resolveAuthMechanisms,
|
||||
@@ -85,8 +85,14 @@ export function createHttpTriggerHandler({
|
||||
return async function dispatchHttpTrigger(req, reply) {
|
||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
||||
// Compat: leftover webhooks still hitting /u/default/... after owner rename.
|
||||
const compatUrl = url.startsWith("/u/default/")
|
||||
? `/u/${DEFAULT_OWNER}/${url.slice("/u/default/".length)}`
|
||||
: url === "/u/default"
|
||||
? `/u/${DEFAULT_OWNER}`
|
||||
: url;
|
||||
const method = String(req.method ?? "GET").toUpperCase();
|
||||
const routeKey = `${method} ${url}`;
|
||||
const routeKey = `${method} ${compatUrl}`;
|
||||
const mapped = httpRoutes.get(routeKey);
|
||||
|
||||
if (!mapped) {
|
||||
@@ -104,7 +110,7 @@ export function createHttpTriggerHandler({
|
||||
if (t?.type !== "HTTP") return false;
|
||||
const m = String(t.method ?? "POST").toUpperCase();
|
||||
const p = namespacedPath(entry.owner, t.path);
|
||||
return m === method && p === url;
|
||||
return m === method && p === compatUrl;
|
||||
}) ?? mapped.trigger;
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
|
||||
import { log } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
* @param {string} src
|
||||
* @param {string} dest
|
||||
*/
|
||||
function copyDir(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const from = path.join(src, entry.name);
|
||||
const to = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) copyDir(from, to);
|
||||
else fs.copyFileSync(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when WORKFLOWS_DIR has no owner subdirectories.
|
||||
* @param {string} dir
|
||||
*/
|
||||
function isEmptyWorkflowsDir(dir) {
|
||||
if (!fs.existsSync(dir)) return true;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
return !entries.some((e) => e.isDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot: copy packages/server/workflows → data/workflows when the new
|
||||
* store is empty and the legacy tree still exists.
|
||||
* Does not copy from examples/workflows.
|
||||
*/
|
||||
export function migrateLegacyWorkflowsIfNeeded() {
|
||||
if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false;
|
||||
if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false;
|
||||
const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (!legacyEntries.some((e) => e.isDirectory())) return false;
|
||||
|
||||
fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
|
||||
copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR);
|
||||
log.info(
|
||||
{ from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR },
|
||||
"migrated legacy workflows into instance store",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -9,9 +9,10 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* kind: "script",
|
||||
* script: string,
|
||||
* profile: string | null,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* id: string | null,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
@@ -21,8 +22,9 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* profile: null,
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* id: string | null,
|
||||
* expression: string,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
@@ -80,6 +82,7 @@ export function parseScriptStep(step) {
|
||||
script: step,
|
||||
profile: null,
|
||||
config: null,
|
||||
name: null,
|
||||
id: null,
|
||||
needsKind: "none",
|
||||
needs: [],
|
||||
@@ -119,6 +122,7 @@ export function parseScriptStep(step) {
|
||||
script: hasScript ? step.script : "",
|
||||
profile: hasProfile ? step.profile : null,
|
||||
config: step.config ?? null,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -271,6 +275,7 @@ function parseSetStep(step) {
|
||||
profile: null,
|
||||
config: { expression },
|
||||
expression,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -291,6 +296,19 @@ function parseWhen(when) {
|
||||
return when;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function parseOptionalName(name) {
|
||||
if (name == null || name === "") return null;
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string | null}
|
||||
|
||||
@@ -62,29 +62,49 @@ export async function isInTrash(owner, file) {
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function unregisterWorkflow(owner, file) {
|
||||
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
||||
fsStore.writeRegisters(owner, registered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
|
||||
* Soft-delete: unregister, move YAML to trash when present, keep revision history.
|
||||
* Missing YAML (ghost register entries) is still unregistered so it leaves the list.
|
||||
* @param {{
|
||||
* workflowId: string,
|
||||
* owner: string,
|
||||
* file: string,
|
||||
* name?: string | null,
|
||||
* }} opts
|
||||
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
|
||||
*/
|
||||
export async function moveWorkflowToTrash(opts) {
|
||||
unregisterWorkflow(opts.owner, opts.file);
|
||||
|
||||
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const err = new Error("workflow not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
const existing = await db("workflow_trash")
|
||||
.where({ owner: opts.owner, file: opts.file })
|
||||
.first();
|
||||
return existing ? rowToItem(existing) : null;
|
||||
}
|
||||
|
||||
const trashPath = trashFilePath(opts.owner, opts.file);
|
||||
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
||||
fs.renameSync(sourcePath, trashPath);
|
||||
|
||||
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
|
||||
fsStore.writeRegisters(opts.owner, registered);
|
||||
const existing = await db("workflow_trash")
|
||||
.where({ owner: opts.owner, file: opts.file })
|
||||
.first();
|
||||
if (existing) {
|
||||
await db("workflow_trash").where({ id: existing.id }).update({
|
||||
workflow_id: opts.workflowId,
|
||||
name: opts.name ?? existing.name ?? null,
|
||||
deleted_at: nowIso(),
|
||||
trash_path: trashPath,
|
||||
});
|
||||
return rowToItem(await db("workflow_trash").where({ id: existing.id }).first());
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const deleted_at = nowIso();
|
||||
|
||||
@@ -2,3 +2,4 @@ export { isPlainObject } from "./is-plain-object.js";
|
||||
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js";
|
||||
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
|
||||
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
|
||||
export { DEFAULT_OWNER } from "./tenant.js";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Default owner folder for new resources (latent tenant id). */
|
||||
export const DEFAULT_OWNER = "local";
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js"
|
||||
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -59,6 +59,23 @@ export function useForkScript() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicatePlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, id, description }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/duplicate`, {
|
||||
id,
|
||||
description,
|
||||
})
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallPlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDuplicateWorkflow, useOwners } from "../api/hooks.js";
|
||||
import { useDuplicateWorkflow } from "../api/hooks.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
|
||||
const { notify } = useNotifications();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const duplicate = useDuplicateWorkflow();
|
||||
const [destOwner, setDestOwner] = useState(source.owner);
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (destOwner === source.owner && duplicate.isPending) return;
|
||||
if (duplicate.isPending) return;
|
||||
duplicate.mutate(
|
||||
{
|
||||
owner: source.owner,
|
||||
file: source.file,
|
||||
destOwner,
|
||||
destOwner: source.owner,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
|
||||
<h3 className="font-bold">Duplicate {source.key}?</h3>
|
||||
<p className="mt-2 text-sm opacity-70">
|
||||
A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are
|
||||
rewritten when staying under the same owner so triggers do not collide.
|
||||
rewritten so triggers do not collide.
|
||||
</p>
|
||||
{warnUnsaved ? (
|
||||
<p className="text-warning mt-2 text-sm">
|
||||
@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
|
||||
</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>
|
||||
{duplicate.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
|
||||
) : null}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
LuShield,
|
||||
LuSun,
|
||||
LuTags,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { useLogout, useOpsStatus } from "../api/hooks.js";
|
||||
import { brandMark } from "../theme/brand.js";
|
||||
@@ -52,7 +51,6 @@ const navSections = [
|
||||
items: [
|
||||
{ to: "/manage", label: "Manage", icon: LuServer },
|
||||
{ to: "/backup", label: "Backup", icon: LuArchive },
|
||||
{ to: "/users", label: "Users", icon: LuUsers },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js";
|
||||
import { useScripts, useUpsertProfile } from "../api/hooks.js";
|
||||
import { FormInput, FormSelect } from "./FormControls.jsx";
|
||||
import { ConfigFields } from "./workflow/ConfigFields.jsx";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
/**
|
||||
* @param {"add" | "edit"} mode
|
||||
@@ -16,11 +17,10 @@ import { ConfigFields } from "./workflow/ConfigFields.jsx";
|
||||
* @param {number} [usageCount]
|
||||
*/
|
||||
export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: scripts = [] } = useScripts();
|
||||
const upsert = useUpsertProfile();
|
||||
const [form, setForm] = useState(() => ({
|
||||
owner: initial.owner || owners[0] || "default",
|
||||
owner: initial.owner || DEFAULT_OWNER,
|
||||
name: initial.name || "",
|
||||
script: initial.script || "",
|
||||
config:
|
||||
@@ -72,7 +72,7 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
|
||||
submit();
|
||||
}
|
||||
|
||||
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`;
|
||||
const title = mode === "add" ? "New profile" : `Edit ${form.name}`;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
@@ -80,49 +80,23 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Owner</span>
|
||||
{owners.length > 0 ? (
|
||||
<FormSelect
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
) : (
|
||||
<FormInput
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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. Cannot be changed later."
|
||||
/>
|
||||
<span className="label-text-alt opacity-60">
|
||||
YAML id. Locked after create so live workflow refs stay valid.
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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. Cannot be changed later."
|
||||
/>
|
||||
<span className="label-text-alt opacity-60">
|
||||
YAML id. Locked after create so live workflow refs stay valid.
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-sm opacity-70">
|
||||
<span className="font-mono">{form.owner}/{form.name}</span>
|
||||
<span className="font-mono">{form.name}</span>
|
||||
<span className="ml-2 opacity-60">(name cannot be changed)</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
|
||||
return `/api/scripts/${encodeURIComponent(name)}/icon`;
|
||||
}
|
||||
|
||||
/** Same-name PNG/JPG next to the script, or a JS document icon. */
|
||||
/** Same-name PNG/JPG/JPEG next to the script, or a JS document icon. */
|
||||
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useOwners, useUpsertSecret } from "../api/hooks.js";
|
||||
import { FormInput, FormSelect } from "./FormControls.jsx";
|
||||
import { useUpsertSecret } from "../api/hooks.js";
|
||||
import { FormInput } from "./FormControls.jsx";
|
||||
import { Modal } from "./Modal.jsx";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
/**
|
||||
* Add / replace an encrypted secret.
|
||||
* Reusable: mount when open; parent supplies mode + initial fields.
|
||||
*
|
||||
* @param {"add" | "replace"} mode
|
||||
* @param {{ owner: string, name?: string }} initial
|
||||
* @param {() => void} onClose
|
||||
* @param {(saved: unknown) => void} [onSaved]
|
||||
* @param {boolean} [lockOwner]
|
||||
*/
|
||||
export function SecretEditorModal({
|
||||
mode,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
lockOwner = false,
|
||||
}) {
|
||||
const { data: owners = [] } = useOwners();
|
||||
export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
|
||||
const upsert = useUpsertSecret();
|
||||
const [form, setForm] = useState(() => ({
|
||||
owner: initial.owner || owners[0] || "default",
|
||||
owner: initial.owner || DEFAULT_OWNER,
|
||||
name: initial.name || "",
|
||||
value: "",
|
||||
}));
|
||||
@@ -42,52 +34,24 @@ export function SecretEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`;
|
||||
const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Owner</span>
|
||||
{owners.length > 0 ? (
|
||||
<FormSelect
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
disabled={lockOwner}
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
) : (
|
||||
<FormInput
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
disabled={lockOwner}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Value</span>
|
||||
@@ -102,7 +66,8 @@ export function SecretEditorModal({
|
||||
</label>
|
||||
<p className="text-xs opacity-60">
|
||||
Values are encrypted at rest and never shown again after save. Values shorter than 8
|
||||
characters are not redacted from logs.
|
||||
characters are not redacted from logs. In workflows use{" "}
|
||||
<span className="font-mono">{"{{ secrets.name }}"}</span> (quote in YAML).
|
||||
</p>
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useOwners, useUpsertVariable } from "../api/hooks.js";
|
||||
import { useUpsertVariable } from "../api/hooks.js";
|
||||
import { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
const TYPES = ["string", "number", "boolean"];
|
||||
|
||||
@@ -13,25 +14,16 @@ function defaultValue(type) {
|
||||
|
||||
/**
|
||||
* Add / edit a plaintext workflow variable.
|
||||
* Reusable: mount when open; parent supplies mode + initial fields.
|
||||
*
|
||||
* @param {"add" | "edit"} mode
|
||||
* @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
|
||||
* @param {() => void} onClose
|
||||
* @param {(saved: unknown) => void} [onSaved]
|
||||
* @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
|
||||
*/
|
||||
export function VariableEditorModal({
|
||||
mode,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
lockOwner = false,
|
||||
}) {
|
||||
const { data: owners = [] } = useOwners();
|
||||
export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
|
||||
const upsert = useUpsertVariable();
|
||||
const [form, setForm] = useState(() => ({
|
||||
owner: initial.owner || owners[0] || "default",
|
||||
owner: initial.owner || DEFAULT_OWNER,
|
||||
name: initial.name || "",
|
||||
type: initial.type || "string",
|
||||
value:
|
||||
@@ -72,7 +64,7 @@ export function VariableEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
const title = mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`;
|
||||
const title = mode === "add" ? "New variable" : `Edit ${form.name}`;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
@@ -80,45 +72,17 @@ export function VariableEditorModal({
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Owner</span>
|
||||
{owners.length > 0 ? (
|
||||
<FormSelect
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
disabled={lockOwner}
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
) : (
|
||||
<FormInput
|
||||
className="w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
disabled={lockOwner}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Type</span>
|
||||
@@ -165,7 +129,9 @@ export function VariableEditorModal({
|
||||
</label>
|
||||
<p className="text-xs opacity-60">
|
||||
Stored in plaintext. Use Secrets for credentials. In workflows use{" "}
|
||||
<span className="font-mono">$VAR_name</span> as a whole field.
|
||||
<span className="font-mono">{"{{ vars.name }}"}</span> (quote strings that
|
||||
start with <span className="font-mono">{"{"}</span>). Nested:{" "}
|
||||
<span className="font-mono">{"{{ context.user.id }}"}</span>.
|
||||
</p>
|
||||
{formError ? <p className="text-error text-sm">{formError}</p> : null}
|
||||
{upsert.isError ? (
|
||||
|
||||
@@ -4,22 +4,27 @@ import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
|
||||
import { useVariables } from "../../api/hooks.js";
|
||||
|
||||
const VAR_PEEK_MAX = 48;
|
||||
const MUSTACHE_RE =
|
||||
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
|
||||
|
||||
const CONFIG_REF_PREFIXES = [
|
||||
{ prefix: "$SECRET_", label: "secret" },
|
||||
{ prefix: "$CONTEXT_", label: "context" },
|
||||
{ prefix: "$VAR_", label: "variable" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {{
|
||||
* kind: "mustache",
|
||||
* path?: string,
|
||||
* root?: string,
|
||||
* name?: string,
|
||||
* } | null}
|
||||
*/
|
||||
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), kind: prefix };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
MUSTACHE_RE.lastIndex = 0;
|
||||
const match = MUSTACHE_RE.exec(trimmed);
|
||||
if (!match) return null;
|
||||
const path = match[1];
|
||||
const root = path.split(".")[0];
|
||||
return { kind: "mustache", path, root, name: path.slice(root.length + 1) };
|
||||
}
|
||||
|
||||
function formatVarDisplay(value) {
|
||||
@@ -33,7 +38,7 @@ function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
|
||||
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||
}
|
||||
|
||||
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
|
||||
/** Edit-time lookup for `{{ vars.name }}` against workflow owner (not a runtime guarantee). */
|
||||
function lookupVariable(variables, owner, name) {
|
||||
const list = Array.isArray(variables) ? variables : [];
|
||||
const match = list.find((v) => v.owner === owner && v.name === name);
|
||||
@@ -62,7 +67,7 @@ function variablesDeepLink({ owner, name, missing }) {
|
||||
|
||||
export function ConfigRefHint({ value, owner }) {
|
||||
const ref = describeConfigRef(value);
|
||||
const isVar = ref?.kind === "$VAR_";
|
||||
const isVar = ref?.kind === "mustache" && ref.root === "vars" && Boolean(ref.name);
|
||||
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
@@ -73,9 +78,17 @@ export function ConfigRefHint({ value, owner }) {
|
||||
if (!ref) return null;
|
||||
|
||||
if (!isVar) {
|
||||
const label =
|
||||
ref.root === "secrets"
|
||||
? "secret"
|
||||
: ref.root === "context"
|
||||
? "context"
|
||||
: ref.root === "data"
|
||||
? "data"
|
||||
: "expression";
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||
from {label} <span className="font-mono">{ref.path}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css";
|
||||
|
||||
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
|
||||
|
||||
function listedHasIcon(listed) {
|
||||
if (listed == null || typeof listed === "string") return undefined;
|
||||
return listed.hasIcon;
|
||||
}
|
||||
|
||||
function stepIconData(node, scriptsByName, profilesByName) {
|
||||
if (node.type !== "step" || node.data?.kind === "set") {
|
||||
return { iconScript: "", hasIcon: false };
|
||||
}
|
||||
const profile = node.data?.profile ? profilesByName.get(node.data.profile) : null;
|
||||
const iconScript = profile?.script || node.data?.script || "";
|
||||
return { iconScript, hasIcon: listedHasIcon(scriptsByName.get(iconScript)) };
|
||||
}
|
||||
|
||||
/** Two Control zoom-out steps from React Flow's default zoom (1). */
|
||||
const INITIAL_ZOOM = 1 / 1.2 ** 2;
|
||||
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
|
||||
@@ -87,28 +101,6 @@ function GraphTabInner({
|
||||
const steps = doc?.scripts ?? [];
|
||||
const triggers = doc?.triggers ?? [];
|
||||
|
||||
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const built = buildGraphElements(doc, positions);
|
||||
setNodes((prev) => {
|
||||
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
|
||||
return built.nodes.map((n) => ({
|
||||
...n,
|
||||
selected: selected.has(n.id),
|
||||
deletable: !disabled,
|
||||
draggable: !disabled,
|
||||
}));
|
||||
});
|
||||
}, [doc, disabled, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, [owner, file, fitView]);
|
||||
|
||||
const selected = parseGraphNodeId(selectedId);
|
||||
|
||||
const scriptsByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const s of scripts) {
|
||||
@@ -126,6 +118,29 @@ function GraphTabInner({
|
||||
return map;
|
||||
}, [profiles]);
|
||||
|
||||
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const built = buildGraphElements(doc, positions);
|
||||
setNodes((prev) => {
|
||||
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
|
||||
return built.nodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, ...stepIconData(n, scriptsByName, profilesByName) },
|
||||
selected: selected.has(n.id),
|
||||
deletable: !disabled,
|
||||
draggable: !disabled,
|
||||
}));
|
||||
});
|
||||
}, [doc, disabled, positions, scriptsByName, profilesByName]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, [owner, file, fitView]);
|
||||
|
||||
const selected = parseGraphNodeId(selectedId);
|
||||
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
edges.map((e) => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx";
|
||||
import { StatusBadge } from "../../lib/format";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
|
||||
import { needsMode } from "../../lib/workflow-doc.js";
|
||||
import { needsMode, stepCustomName, stepDisplayName } from "../../lib/workflow-doc.js";
|
||||
import {
|
||||
stepPredecessors,
|
||||
stepSuccessors,
|
||||
@@ -117,7 +117,9 @@ function ScriptCardView({
|
||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
||||
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
|
||||
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||
const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||
const customName = stepCustomName(step);
|
||||
const baseName = stepDisplayName(step, scriptFallback);
|
||||
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||
const setConfig =
|
||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||
@@ -180,10 +182,18 @@ function ScriptCardView({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3
|
||||
className="card-title min-w-0 flex-1 text-base font-mono"
|
||||
className={`card-title min-w-0 flex-1 text-base ${customName ? "" : "font-mono"}`}
|
||||
title={titleFull}
|
||||
>
|
||||
<span className="min-w-0 truncate">{baseName}</span>
|
||||
{customName && scriptFallback && scriptFallback !== customName ? (
|
||||
<span
|
||||
className="badge badge-ghost badge-sm shrink-0 truncate font-mono font-normal"
|
||||
title={scriptFallback}
|
||||
>
|
||||
{scriptFallback}
|
||||
</span>
|
||||
) : null}
|
||||
{!expanded && preview ? (
|
||||
<span
|
||||
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
||||
@@ -275,6 +285,15 @@ function ScriptCardView({
|
||||
|
||||
{expanded ? (
|
||||
<>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
value={step.name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||
placeholder={scriptFallback}
|
||||
/>
|
||||
</label>
|
||||
{step.kind === "set" ? (
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
@@ -469,9 +488,11 @@ function ConfigJsonEditor({ config, disabled, onChange }) {
|
||||
|
||||
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);
|
||||
const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
|
||||
const ids = others.map((s) => s.id);
|
||||
const nameById = new Map(
|
||||
others.map((s) => [s.id, stepCustomName(s) || null]),
|
||||
);
|
||||
|
||||
function setMode(next) {
|
||||
if (next === "none") onChange({ ...step, needs: null });
|
||||
@@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
else onChange({ ...step, needs: {}, when: "" });
|
||||
}
|
||||
|
||||
function idLabel(id) {
|
||||
const named = nameById.get(id);
|
||||
return named ? `${id} · ${named}` : id;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="label py-0 text-sm">needs</span>
|
||||
@@ -520,7 +546,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{id}</span>
|
||||
<span className="font-mono text-xs">{idLabel(id)}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
@@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
<NeedsMap
|
||||
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
||||
ids={ids}
|
||||
idLabel={idLabel}
|
||||
disabled={disabled}
|
||||
onChange={(needs) => onChange({ ...step, needs })}
|
||||
/>
|
||||
@@ -539,7 +566,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||
function NeedsMap({ needs, ids, idLabel, disabled, onChange }) {
|
||||
const entries = Object.entries(needs ?? {});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
||||
>
|
||||
{from && !ids.includes(from) ? (
|
||||
<option value={from}>{from}</option>
|
||||
<option value={from}>{idLabel ? idLabel(from) : from}</option>
|
||||
) : null}
|
||||
{ids.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
{idLabel ? idLabel(id) : id}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
@@ -874,8 +901,20 @@ function ScriptTryDialog({
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{!isSet && script ? (
|
||||
<ScriptIcon
|
||||
name={script}
|
||||
hasIcon={existing.data?.hasIcon}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
|
||||
<h3 className={stepCustomName(step) ? "font-bold" : "font-bold font-mono"}>
|
||||
{stepDisplayName(step, isSet ? "set" : script)}
|
||||
</h3>
|
||||
{stepCustomName(step) && script ? (
|
||||
<p className="text-xs font-mono opacity-60">{isSet ? "set" : script}</p>
|
||||
) : null}
|
||||
<p className="text-sm opacity-70">
|
||||
{isSet
|
||||
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
|
||||
|
||||
@@ -143,10 +143,15 @@ function TriggerCardView({
|
||||
|
||||
function triggerSummary(trigger, owner) {
|
||||
const type = trigger?.type;
|
||||
const failure =
|
||||
trigger.onConsecutiveFailures && trigger.onFailureWorkflow
|
||||
? ` · onFailure@${trigger.onFailureWorkflow}`
|
||||
: "";
|
||||
/** @type {string[]} */
|
||||
const failureParts = [];
|
||||
if (trigger.onConsecutiveFailures && trigger.onFailureWorkflow) {
|
||||
failureParts.push(`onFailure@${trigger.onFailureWorkflow}`);
|
||||
}
|
||||
if (trigger.disableOnConsecutiveFailures) {
|
||||
failureParts.push("auto-disable");
|
||||
}
|
||||
const failure = failureParts.length ? ` · ${failureParts.join(", ")}` : "";
|
||||
if (type === "HTTP") {
|
||||
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
|
||||
}
|
||||
@@ -383,6 +388,20 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
|
||||
) : null}
|
||||
</FormSelect>
|
||||
</Field>
|
||||
<label className="label cursor-pointer justify-start gap-3 py-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-sm"
|
||||
checked={Boolean(trigger.disableOnConsecutiveFailures)}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...trigger, disableOnConsecutiveFailures: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="label-text">
|
||||
Disable this workflow when the consecutive failure threshold is reached
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { ScriptIcon } from "../../ScriptIcon.jsx";
|
||||
|
||||
export function TriggerGraphNode({ data, selected }) {
|
||||
return (
|
||||
@@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) {
|
||||
|
||||
export function StepGraphNode({ data, selected }) {
|
||||
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
|
||||
const iconScript = data.iconScript || "";
|
||||
return (
|
||||
<div
|
||||
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
|
||||
@@ -36,28 +38,42 @@ export function StepGraphNode({ data, selected }) {
|
||||
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
|
||||
isConnectable
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
||||
{data.stepId ? (
|
||||
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
||||
) : null}
|
||||
{data.when ? (
|
||||
<span className="badge badge-warning badge-xs" title={data.when}>
|
||||
when
|
||||
</span>
|
||||
) : null}
|
||||
{data.needsMode === "map" ? (
|
||||
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
|
||||
map
|
||||
</span>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{data.kind !== "set" && iconScript ? (
|
||||
<ScriptIcon
|
||||
name={iconScript}
|
||||
hasIcon={data.hasIcon}
|
||||
className="size-7 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
||||
{data.stepId ? (
|
||||
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
||||
) : null}
|
||||
{data.when ? (
|
||||
<span className="badge badge-warning badge-xs" title={data.when}>
|
||||
when
|
||||
</span>
|
||||
) : null}
|
||||
{data.needsMode === "map" ? (
|
||||
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
|
||||
map
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</p>
|
||||
{data.missingNeeds?.length ? (
|
||||
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-mono text-sm leading-tight truncate" title={data.label}>
|
||||
{data.label}
|
||||
</p>
|
||||
{data.missingNeeds?.length ? (
|
||||
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
|
||||
) : null}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { contextFromMeta } from "./script.js";
|
||||
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js";
|
||||
import { isDagDoc, isEmptyNeeds, needsMode, stepDisplayName } from "./workflow-doc.js";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -89,16 +89,16 @@ export function pruneTrySession(session, steps) {
|
||||
|
||||
/**
|
||||
* Human-readable step label for seed hints.
|
||||
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string }} step
|
||||
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string, name?: string }} step
|
||||
* @param {number} [index]
|
||||
*/
|
||||
export function stepTryLabel(step, index) {
|
||||
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
|
||||
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
|
||||
const name =
|
||||
step.kind === "set"
|
||||
? "set"
|
||||
: step.profile || step.script || "step";
|
||||
const name = stepDisplayName(
|
||||
step,
|
||||
step.kind === "set" ? "set" : step.profile || step.script || "step",
|
||||
);
|
||||
if (id) return `step ${n} / ${id} (${name})`;
|
||||
return `step ${n} / ${name}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
pruneTrySession,
|
||||
recordTrySuccess,
|
||||
seedTryDialog,
|
||||
stepTryLabel,
|
||||
} from "./try-session.js";
|
||||
|
||||
function step(uiId, extra = {}) {
|
||||
@@ -166,3 +167,17 @@ test("pruneTrySession drops removed steps", () => {
|
||||
assert.equal(pruned.byStep.b, undefined);
|
||||
assert.equal(pruned.lastTriedUiId, null);
|
||||
});
|
||||
|
||||
test("stepTryLabel uses custom name", () => {
|
||||
assert.equal(
|
||||
stepTryLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }, 0),
|
||||
"step 1 / Notify to channel",
|
||||
);
|
||||
assert.equal(
|
||||
stepTryLabel(
|
||||
{ kind: "script", script: "ntfy.js", id: "notify", name: "Notify to channel" },
|
||||
2,
|
||||
),
|
||||
"step 3 / notify (Notify to channel)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,19 @@ export function isDagDoc(doc) {
|
||||
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs));
|
||||
}
|
||||
|
||||
export function stepCustomName(step) {
|
||||
return typeof step?.name === "string" ? step.name.trim() : "";
|
||||
}
|
||||
|
||||
export function stepDisplayName(step, fallback) {
|
||||
const custom = stepCustomName(step);
|
||||
if (custom) return custom;
|
||||
if (fallback) return fallback;
|
||||
if (step?.kind === "set") return "set";
|
||||
if (step?.profile) return `profile ${step.profile}`;
|
||||
return step?.script || "untitled";
|
||||
}
|
||||
|
||||
export function newScriptStep(script, config = {}) {
|
||||
return {
|
||||
uiId: nextUiId("step"),
|
||||
@@ -112,6 +125,7 @@ export function newScriptStep(script, config = {}) {
|
||||
script,
|
||||
profile: "",
|
||||
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
||||
name: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
@@ -125,6 +139,7 @@ export function newProfileStep(profileName, script = "") {
|
||||
script,
|
||||
profile: profileName,
|
||||
config: {},
|
||||
name: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
@@ -137,6 +152,7 @@ export function newSetStep() {
|
||||
kind: "set",
|
||||
script: "set",
|
||||
expression: "",
|
||||
name: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
@@ -155,6 +171,7 @@ export function newHttpTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +187,7 @@ export function newCronTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +203,7 @@ export function newWorkflowTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +219,10 @@ function readOnFailureWorkflow(raw) {
|
||||
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
|
||||
}
|
||||
|
||||
function readStepName(raw) {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function normalizeStep(step) {
|
||||
const uiId = nextUiId("step");
|
||||
if (typeof step === "string") {
|
||||
@@ -209,6 +232,7 @@ function normalizeStep(step) {
|
||||
script: step,
|
||||
profile: "",
|
||||
config: {},
|
||||
name: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
@@ -221,6 +245,7 @@ function normalizeStep(step) {
|
||||
script: "",
|
||||
profile: "",
|
||||
config: {},
|
||||
name: "",
|
||||
id: "",
|
||||
when: "",
|
||||
needs: null,
|
||||
@@ -233,12 +258,13 @@ function normalizeStep(step) {
|
||||
kind: "set",
|
||||
script: "set",
|
||||
expression: typeof spec.expression === "string" ? spec.expression : "",
|
||||
name: readStepName(step.name),
|
||||
id: typeof step.id === "string" ? step.id : "",
|
||||
when: typeof step.when === "string" ? step.when : "",
|
||||
needs: step.needs ?? null,
|
||||
};
|
||||
}
|
||||
const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]);
|
||||
const known = new Set(["script", "profile", "config", "name", "id", "when", "needs", "set"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
for (const [key, value] of Object.entries(step)) {
|
||||
@@ -254,6 +280,7 @@ function normalizeStep(step) {
|
||||
script: typeof step.script === "string" ? step.script : "",
|
||||
profile: typeof step.profile === "string" ? step.profile : "",
|
||||
config,
|
||||
name: readStepName(step.name),
|
||||
id: typeof step.id === "string" ? step.id : "",
|
||||
when: typeof step.when === "string" ? step.when : "",
|
||||
needs: step.needs ?? null,
|
||||
@@ -279,9 +306,16 @@ function normalizeTrigger(raw) {
|
||||
"unauthorized",
|
||||
"onConsecutiveFailures",
|
||||
"onFailureWorkflow",
|
||||
"disableOnConsecutiveFailures",
|
||||
])
|
||||
: type === "cron"
|
||||
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"])
|
||||
? new Set([
|
||||
"type",
|
||||
"schedule",
|
||||
"onConsecutiveFailures",
|
||||
"onFailureWorkflow",
|
||||
"disableOnConsecutiveFailures",
|
||||
])
|
||||
: new Set(["type"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
@@ -299,6 +333,7 @@ function normalizeTrigger(raw) {
|
||||
? ""
|
||||
: String(raw.onConsecutiveFailures),
|
||||
onFailureWorkflow: readOnFailureWorkflow(raw),
|
||||
disableOnConsecutiveFailures: raw.disableOnConsecutiveFailures === true,
|
||||
auth: Array.isArray(raw.auth) ? raw.auth : null,
|
||||
response: typeof raw.response === "string" ? raw.response : "",
|
||||
unauthorized: raw.unauthorized ?? null,
|
||||
@@ -306,10 +341,16 @@ function normalizeTrigger(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
function dumpStepName(step, out) {
|
||||
const name = stepCustomName(step);
|
||||
if (name) out.name = name;
|
||||
}
|
||||
|
||||
function dumpStep(step) {
|
||||
if (step.kind === "set") {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
dumpStepName(step, out);
|
||||
if (step.id) out.id = step.id;
|
||||
out.set = {
|
||||
expression: step.expression ?? "",
|
||||
@@ -320,6 +361,7 @@ function dumpStep(step) {
|
||||
}
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
dumpStepName(step, out);
|
||||
if (step.id) out.id = step.id;
|
||||
if (step.profile) {
|
||||
out.profile = step.profile;
|
||||
@@ -346,6 +388,9 @@ function dumpFailureTriggerFields(t, out) {
|
||||
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
|
||||
out.onFailureWorkflow = t.onFailureWorkflow.trim();
|
||||
}
|
||||
if (t.disableOnConsecutiveFailures === true) {
|
||||
out.disableOnConsecutiveFailures = true;
|
||||
}
|
||||
}
|
||||
|
||||
function dumpTrigger(t) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
parseWorkflowYaml,
|
||||
stringifyWorkflowDoc,
|
||||
stepCustomName,
|
||||
stepDisplayName,
|
||||
} from "./workflow-doc.js";
|
||||
|
||||
test("step name round-trips in yaml", () => {
|
||||
const { doc, parseError } = parseWorkflowYaml(`
|
||||
name: demo
|
||||
scripts:
|
||||
- name: Notify to channel
|
||||
script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/demo
|
||||
- name: Transform
|
||||
set:
|
||||
expression: data
|
||||
`);
|
||||
assert.equal(parseError, null);
|
||||
assert.equal(doc.scripts[0].name, "Notify to channel");
|
||||
assert.equal(doc.scripts[0].script, "ntfy.js");
|
||||
assert.equal(doc.scripts[0].extra, undefined);
|
||||
assert.equal(doc.scripts[1].name, "Transform");
|
||||
assert.equal(doc.scripts[1].kind, "set");
|
||||
|
||||
const yaml = stringifyWorkflowDoc(doc);
|
||||
assert.match(yaml, /name: Notify to channel/);
|
||||
assert.match(yaml, /script: ntfy.js/);
|
||||
assert.match(yaml, /name: Transform/);
|
||||
});
|
||||
|
||||
test("empty step name is omitted from yaml", () => {
|
||||
const { doc } = parseWorkflowYaml(`
|
||||
scripts:
|
||||
- name: " "
|
||||
script: ntfy.js
|
||||
`);
|
||||
assert.equal(doc.scripts[0].name, " ");
|
||||
assert.equal(stepCustomName(doc.scripts[0]), "");
|
||||
const yaml = stringifyWorkflowDoc(doc);
|
||||
assert.doesNotMatch(yaml, /^\s*name:/m);
|
||||
assert.match(yaml, /script: ntfy.js/);
|
||||
});
|
||||
|
||||
test("stepDisplayName prefers custom name", () => {
|
||||
assert.equal(
|
||||
stepDisplayName({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
|
||||
"Notify to channel",
|
||||
);
|
||||
assert.equal(stepDisplayName({ kind: "script", script: "ntfy.js" }), "ntfy.js");
|
||||
assert.equal(stepDisplayName({ kind: "set" }), "set");
|
||||
assert.equal(
|
||||
stepDisplayName({ kind: "script", script: "ntfy.js" }, "fallback.js"),
|
||||
"fallback.js",
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js";
|
||||
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId, stepCustomName } from "./workflow-doc.js";
|
||||
|
||||
export const STEP_NODE_PREFIX = "step:";
|
||||
export const TRIGGER_NODE_PREFIX = "trig:";
|
||||
@@ -44,6 +44,8 @@ export function triggerSummary(trigger, owner) {
|
||||
}
|
||||
|
||||
export function stepLabel(step) {
|
||||
const custom = stepCustomName(step);
|
||||
if (custom) return custom;
|
||||
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
|
||||
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
|
||||
return step?.id ? `${step.id}: ${target}` : target;
|
||||
@@ -254,6 +256,7 @@ export function buildGraphElements(doc, positions = {}) {
|
||||
data: {
|
||||
layoutKey,
|
||||
label: stepLabel(s),
|
||||
named: Boolean(stepCustomName(s)),
|
||||
kind: s.kind,
|
||||
stepId: s.id || "",
|
||||
when: s.when || "",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canConnectSteps,
|
||||
enteringDagWouldStripWhen,
|
||||
removeStepEdge,
|
||||
stepLabel,
|
||||
stepPredecessors,
|
||||
stepSuccessors,
|
||||
wouldCreateCycle,
|
||||
@@ -113,3 +114,15 @@ test("stepSuccessors / stepPredecessors DAG multi", () => {
|
||||
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
|
||||
assert.deepEqual(stepPredecessors(scripts, "a"), []);
|
||||
});
|
||||
|
||||
test("stepLabel uses custom name", () => {
|
||||
assert.equal(
|
||||
stepLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
|
||||
"Notify to channel",
|
||||
);
|
||||
assert.equal(stepLabel({ kind: "script", script: "ntfy.js", id: "n" }), "n: ntfy.js");
|
||||
assert.equal(
|
||||
stepLabel({ kind: "set", id: "xform", name: "Shape payload" }),
|
||||
"Shape payload",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useOwners, useRuns } from "../api/hooks.js";
|
||||
import { useRuns } from "../api/hooks.js";
|
||||
import { formatTime, StatusBadge } from "../lib/format";
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
@@ -47,7 +47,6 @@ export function EventsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const workflow = params.get("workflow") || "";
|
||||
const status = params.get("status") || "";
|
||||
const owner = params.get("owner") || "";
|
||||
const trigger = params.get("trigger") || "";
|
||||
const afterDate = params.get("after") || "";
|
||||
const beforeDate = params.get("before") || "";
|
||||
@@ -57,11 +56,9 @@ export function EventsPage() {
|
||||
const sort = params.get("sort") || "started_at";
|
||||
const order = params.get("order") === "asc" ? "asc" : "desc";
|
||||
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data, isLoading } = useRuns({
|
||||
workflow: workflow || undefined,
|
||||
status: status || undefined,
|
||||
owner: owner || undefined,
|
||||
trigger: trigger || undefined,
|
||||
after: dateToAfterIso(afterDate),
|
||||
before: dateToBeforeIso(beforeDate),
|
||||
@@ -116,18 +113,6 @@ export function EventsPage() {
|
||||
<option value="failed">failed</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
<select
|
||||
className="select select-sm w-full sm:max-w-xs"
|
||||
value={owner}
|
||||
onChange={(e) => update("owner", e.target.value)}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select select-sm w-full sm:max-w-xs"
|
||||
value={trigger}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDeleteProfile, useOwners, useProfileUsage, useProfiles } from "../api/hooks.js";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { useDeleteProfile, useProfileUsage, useProfiles } from "../api/hooks.js";
|
||||
import { Modal } from "../components/Modal.jsx";
|
||||
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
function previewConfig(config) {
|
||||
if (!config || typeof config !== "object") return "";
|
||||
@@ -37,50 +37,32 @@ export function ProfilesPage() {
|
||||
const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname);
|
||||
const isEditRoute = Boolean(routeOwner && routeName);
|
||||
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState(
|
||||
() => routeOwner || params.get("owner") || "",
|
||||
);
|
||||
const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined);
|
||||
const { data: profiles = [], isLoading } = useProfiles();
|
||||
const del = useDeleteProfile();
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [highlightName, setHighlightName] = useState(() => routeName || "");
|
||||
const highlightRef = useRef(null);
|
||||
|
||||
const listPath = ownerFilter
|
||||
? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/profiles";
|
||||
const listPath = "/profiles";
|
||||
|
||||
const { editor, closeEditor } = useRouteDrivenModal({
|
||||
isNewRoute,
|
||||
isEditRoute,
|
||||
listPath,
|
||||
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
|
||||
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
|
||||
onBeforeOpenNew: () => {
|
||||
if (params.get("owner")) setOwnerFilter(params.get("owner"));
|
||||
},
|
||||
buildNewEditor: () => {
|
||||
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
|
||||
return {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner,
|
||||
name: params.get("name") || "",
|
||||
script: params.get("script") || "",
|
||||
config: {},
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}:${params.get("script") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner: DEFAULT_OWNER,
|
||||
name: params.get("name") || "",
|
||||
script: params.get("script") || "",
|
||||
config: {},
|
||||
description: "",
|
||||
},
|
||||
}),
|
||||
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
|
||||
canOpenEdit: () => {
|
||||
if (ownerFilter !== routeOwner) {
|
||||
setOwnerFilter(routeOwner);
|
||||
return false;
|
||||
}
|
||||
return !isLoading;
|
||||
},
|
||||
canOpenEdit: () => !isLoading,
|
||||
onOpenEdit: () => setHighlightName(routeName),
|
||||
buildEditEditor: () => {
|
||||
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
|
||||
@@ -100,7 +82,7 @@ export function ProfilesPage() {
|
||||
return {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner: routeOwner,
|
||||
owner: routeOwner || DEFAULT_OWNER,
|
||||
name: routeName,
|
||||
script: "",
|
||||
config: {},
|
||||
@@ -108,8 +90,8 @@ export function ProfilesPage() {
|
||||
},
|
||||
};
|
||||
},
|
||||
newDeps: [params, owners, ownerFilter],
|
||||
editDeps: [routeOwner, routeName, ownerFilter, isLoading, profiles],
|
||||
newDeps: [params],
|
||||
editDeps: [routeOwner, routeName, isLoading, profiles],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,42 +103,14 @@ export function ProfilesPage() {
|
||||
<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">Profiles</h1>
|
||||
<div className="flex gap-2">
|
||||
<FormSelect
|
||||
value={ownerFilter}
|
||||
onChange={(e) => {
|
||||
setOwnerFilter(e.target.value);
|
||||
setHighlightName("");
|
||||
navigate(
|
||||
e.target.value
|
||||
? `/profiles?owner=${encodeURIComponent(e.target.value)}`
|
||||
: "/profiles",
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
ownerFilter
|
||||
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/profiles/new",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/profiles/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm opacity-70">
|
||||
@@ -174,7 +128,7 @@ export function ProfilesPage() {
|
||||
const highlighted =
|
||||
highlightName &&
|
||||
row.name === highlightName &&
|
||||
(!ownerFilter || row.owner === ownerFilter);
|
||||
(!routeOwner || row.owner === routeOwner);
|
||||
const preview = previewConfig(row.config);
|
||||
return (
|
||||
<article
|
||||
@@ -191,7 +145,6 @@ export function ProfilesPage() {
|
||||
<h2 className="font-mono font-semibold truncate" title={row.name}>
|
||||
{row.name}
|
||||
</h2>
|
||||
<p className="text-xs opacity-60 font-mono truncate">{row.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-mono opacity-80 truncate" title={row.script}>
|
||||
@@ -275,7 +228,7 @@ function DeleteProfileDialog({ profile, del, onClose }) {
|
||||
const usage = useProfileUsage(profile.id, true);
|
||||
const usages = usage.data ?? [];
|
||||
const used = usages.length > 0;
|
||||
const title = `Delete ${profile.owner}/${profile.name}?`;
|
||||
const title = `Delete ${profile.name}?`;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDryRunScript,
|
||||
useOwners,
|
||||
useSaveScript,
|
||||
useScript,
|
||||
} from "../api/hooks.js";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
contextFromMeta,
|
||||
prettyJson,
|
||||
} from "../lib/script.js";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptDryRunPage() {
|
||||
@@ -31,8 +31,6 @@ export function ScriptDryRunPage() {
|
||||
const existing = useScript(name, stateContent == null);
|
||||
const dryRun = useDryRunScript();
|
||||
const save = useSaveScript();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [owner, setOwner] = useState("default");
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
|
||||
@@ -126,7 +124,7 @@ export function ScriptDryRunPage() {
|
||||
data: ctx.data,
|
||||
context: ctx.context,
|
||||
config: ctx.config,
|
||||
owner,
|
||||
owner: DEFAULT_OWNER,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,21 +149,6 @@ export function ScriptDryRunPage() {
|
||||
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<span className="opacity-60 hidden sm:inline">Owner</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
>
|
||||
{owners.includes(owner) ? null : <option value={owner}>{owner}</option>}
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useCreatePlugin,
|
||||
useDuplicatePlugin,
|
||||
useForkScript,
|
||||
useSaveScript,
|
||||
useScript,
|
||||
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
|
||||
const existing = useScript(name);
|
||||
const save = useSaveScript();
|
||||
const fork = useForkScript();
|
||||
const duplicate = useDuplicatePlugin();
|
||||
const [content, setContent] = useState("");
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
const [forkId, setForkId] = useState("");
|
||||
const [duplicateId, setDuplicateId] = useState("");
|
||||
|
||||
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
||||
|
||||
@@ -163,6 +166,21 @@ export function ScriptEditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function onDuplicate(e) {
|
||||
e.preventDefault();
|
||||
const id = normalizePluginId(duplicateId);
|
||||
if (!id) return;
|
||||
duplicate.mutate(
|
||||
{ name, id },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
notify.success("Duplicated — drain-restart recommended");
|
||||
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[12rem] items-center justify-center">
|
||||
@@ -238,7 +256,27 @@ export function ScriptEditPage() {
|
||||
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
) : (
|
||||
<form onSubmit={onDuplicate} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input input-sm font-mono w-56"
|
||||
placeholder="duplicate id (e.g. my-plugin-copy)"
|
||||
value={duplicateId}
|
||||
onChange={(e) => setDuplicateId(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm"
|
||||
disabled={duplicate.isPending || !normalizePluginId(duplicateId)}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
Duplicate plugin
|
||||
</button>
|
||||
{duplicate.isError ? (
|
||||
<span className="text-error text-sm">{errorMessage(duplicate.error)}</span>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
|
||||
<CodeEditor
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteScript,
|
||||
useDuplicatePlugin,
|
||||
useForkScript,
|
||||
useInstallPlugin,
|
||||
useScripts,
|
||||
@@ -14,6 +15,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
|
||||
import { scriptTags } from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
const BASE64_CHUNK = 0x8000;
|
||||
|
||||
function uint8ArrayToBase64(bytes) {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function fileToBase64(file) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
return uint8ArrayToBase64(bytes);
|
||||
}
|
||||
|
||||
export function ScriptsPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,10 +39,17 @@ export function ScriptsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [forkFor, setForkFor] = useState(null);
|
||||
const [forkId, setForkId] = useState("");
|
||||
const [duplicateFor, setDuplicateFor] = useState(null);
|
||||
const [duplicateId, setDuplicateId] = useState("");
|
||||
const del = useDeleteScript();
|
||||
const fork = useForkScript();
|
||||
const duplicate = useDuplicatePlugin();
|
||||
const install = useInstallPlugin();
|
||||
const { notify } = useNotifications();
|
||||
const [pendingZip, setPendingZip] = useState(null);
|
||||
const [zipEncoding, setZipEncoding] = useState(false);
|
||||
const zipInFlight = useRef(false);
|
||||
const zipBusy = zipEncoding || install.isPending;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
@@ -45,6 +68,44 @@ export function ScriptsPage() {
|
||||
});
|
||||
}, [scripts, query]);
|
||||
|
||||
function onPickZip(e) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
install.reset();
|
||||
setPendingZip(file);
|
||||
}
|
||||
|
||||
async function installPendingZip() {
|
||||
if (!pendingZip || zipInFlight.current) return;
|
||||
zipInFlight.current = true;
|
||||
setZipEncoding(true);
|
||||
try {
|
||||
const zipBase64 = await fileToBase64(pendingZip);
|
||||
install.mutate(
|
||||
{ source: "zip", zipBase64, overwrite: true },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setPendingZip(null);
|
||||
const ref =
|
||||
data.scriptRef ?? (data.id ? `plugin/${data.id}` : "plugin");
|
||||
notify.success(`Installed ${ref} — drain-restart to load workers`);
|
||||
if (data.warning) notify.warning(data.warning);
|
||||
},
|
||||
onError: (err) => notify.error(errorMessage(err)),
|
||||
onSettled: () => {
|
||||
zipInFlight.current = false;
|
||||
setZipEncoding(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
zipInFlight.current = false;
|
||||
setZipEncoding(false);
|
||||
notify.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (editName) {
|
||||
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||
}
|
||||
@@ -67,7 +128,7 @@ export function ScriptsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={install.isPending}
|
||||
disabled={zipBusy}
|
||||
onClick={() =>
|
||||
install.mutate(
|
||||
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
||||
@@ -80,6 +141,21 @@ export function ScriptsPage() {
|
||||
>
|
||||
Install example
|
||||
</button>
|
||||
<label className={`btn btn-outline btn-sm ${zipBusy ? "btn-disabled" : ""}`}>
|
||||
{zipBusy ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : (
|
||||
<LuUpload className="size-4" />
|
||||
)}
|
||||
Install from zip
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
disabled={zipBusy}
|
||||
onChange={onPickZip}
|
||||
/>
|
||||
</label>
|
||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||
<LuPlus className="size-4" />
|
||||
Add plugin
|
||||
@@ -182,14 +258,33 @@ export function ScriptsPage() {
|
||||
<LuCopy className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Duplicate"
|
||||
aria-label="Duplicate"
|
||||
onClick={() => {
|
||||
setDuplicateFor(name);
|
||||
setDuplicateId(
|
||||
String(name)
|
||||
.replace(/^plugin\//i, "")
|
||||
.replace(/\.js$/i, "")
|
||||
.toLowerCase() + "-copy",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isCore ? (
|
||||
<button
|
||||
@@ -264,6 +359,84 @@ export function ScriptsPage() {
|
||||
</dialog>
|
||||
) : null}
|
||||
|
||||
{duplicateFor ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-semibold">Duplicate {duplicateFor}</h3>
|
||||
<p className="text-sm opacity-70 py-2">
|
||||
Creates <code>plugin/<id></code> from this plugin.
|
||||
</p>
|
||||
<input
|
||||
className="input input-bordered input-sm w-full font-mono"
|
||||
value={duplicateId}
|
||||
onChange={(e) => setDuplicateId(e.target.value)}
|
||||
/>
|
||||
{duplicate.isError ? (
|
||||
<p className="text-error text-sm mt-2">{errorMessage(duplicate.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setDuplicateFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={duplicate.isPending || !duplicateId.trim()}
|
||||
onClick={() =>
|
||||
duplicate.mutate(
|
||||
{ name: duplicateFor, id: duplicateId.trim() },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setDuplicateFor(null);
|
||||
notify.success("Duplicated — drain-restart recommended");
|
||||
navigate(
|
||||
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={() => setDuplicateFor(null)}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingZip)}
|
||||
title="Install plugin from zip?"
|
||||
message={
|
||||
pendingZip ? (
|
||||
<>
|
||||
Installing{" "}
|
||||
<span className="font-mono">{pendingZip.name}</span> replaces an
|
||||
existing plugin with the same id. Drain-restart workers after
|
||||
install so new dependencies load.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Install"
|
||||
confirmClass="btn-primary"
|
||||
error={install.isError ? errorMessage(install.error) : null}
|
||||
loading={zipBusy}
|
||||
onCancel={() => {
|
||||
if (zipBusy) return;
|
||||
setPendingZip(null);
|
||||
}}
|
||||
onConfirm={installPendingZip}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDeleteSecret, useOwners, useSecrets } from "../api/hooks.js";
|
||||
import { useDeleteSecret, useSecrets } from "../api/hooks.js";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
export function SecretsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -17,51 +17,36 @@ export function SecretsPage() {
|
||||
const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname);
|
||||
const isEditRoute = Boolean(routeOwner && routeName);
|
||||
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState(
|
||||
() => routeOwner || params.get("owner") || "",
|
||||
);
|
||||
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
|
||||
const { data: secrets = [], isLoading } = useSecrets();
|
||||
const del = useDeleteSecret();
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [highlightName, setHighlightName] = useState(() => routeName || "");
|
||||
const highlightRef = useRef(null);
|
||||
|
||||
const listPath = ownerFilter
|
||||
? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/secrets";
|
||||
const listPath = "/secrets";
|
||||
|
||||
const { editor, closeEditor } = useRouteDrivenModal({
|
||||
isNewRoute,
|
||||
isEditRoute,
|
||||
listPath,
|
||||
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
|
||||
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
|
||||
onBeforeOpenNew: () => {
|
||||
if (params.get("owner")) setOwnerFilter(params.get("owner"));
|
||||
},
|
||||
buildNewEditor: () => {
|
||||
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
|
||||
return {
|
||||
mode: "add",
|
||||
initial: { owner, name: params.get("name") || "" },
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner: DEFAULT_OWNER,
|
||||
name: params.get("name") || "",
|
||||
},
|
||||
}),
|
||||
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
|
||||
canOpenEdit: () => {
|
||||
if (ownerFilter !== routeOwner) {
|
||||
setOwnerFilter(routeOwner);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
canOpenEdit: () => true,
|
||||
onOpenEdit: () => setHighlightName(routeName),
|
||||
buildEditEditor: () => ({
|
||||
mode: "replace",
|
||||
initial: { owner: routeOwner, name: routeName },
|
||||
}),
|
||||
newDeps: [params, owners, ownerFilter],
|
||||
editDeps: [routeOwner, routeName, ownerFilter],
|
||||
newDeps: [params],
|
||||
editDeps: [routeOwner, routeName],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,42 +58,14 @@ export function SecretsPage() {
|
||||
<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">Secrets</h1>
|
||||
<div className="flex gap-2">
|
||||
<FormSelect
|
||||
value={ownerFilter}
|
||||
onChange={(e) => {
|
||||
setOwnerFilter(e.target.value);
|
||||
setHighlightName("");
|
||||
navigate(
|
||||
e.target.value
|
||||
? `/secrets?owner=${encodeURIComponent(e.target.value)}`
|
||||
: "/secrets",
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
ownerFilter
|
||||
? `/secrets/new?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/secrets/new",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/secrets/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -120,7 +77,6 @@ export function SecretsPage() {
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
@@ -129,16 +85,16 @@ export function SecretsPage() {
|
||||
<tbody>
|
||||
{secrets.map((s) => {
|
||||
const highlighted =
|
||||
highlightName &&
|
||||
s.name === highlightName &&
|
||||
(!ownerFilter || s.owner === ownerFilter);
|
||||
highlightName && s.name === highlightName && s.owner === routeOwner;
|
||||
const highlightByNameOnly =
|
||||
highlightName && s.name === highlightName && !routeOwner;
|
||||
const isHi = highlighted || highlightByNameOnly;
|
||||
return (
|
||||
<tr
|
||||
key={s.id}
|
||||
ref={highlighted ? highlightRef : undefined}
|
||||
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
|
||||
ref={isHi ? highlightRef : undefined}
|
||||
className={`hover ${isHi ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
|
||||
>
|
||||
<td className="font-mono">{s.owner}</td>
|
||||
<td className="font-mono">{s.name}</td>
|
||||
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
@@ -186,7 +142,7 @@ export function SecretsPage() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""}
|
||||
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
|
||||
message="This cannot be undone. Workflows that retrieve this name will fail."
|
||||
error={del.isError ? errorMessage(del.error) : null}
|
||||
loading={del.isPending}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDeleteVariable, useOwners, useVariables } from "../api/hooks.js";
|
||||
import { useDeleteVariable, useVariables } from "../api/hooks.js";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
|
||||
function displayValue(value) {
|
||||
if (typeof value === "string") return value === "" ? '""' : value;
|
||||
@@ -22,49 +22,31 @@ export function VariablesPage() {
|
||||
const isNewRoute = /\/variables\/new\/?$/.test(location.pathname);
|
||||
const isEditRoute = Boolean(routeOwner && routeName);
|
||||
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState(
|
||||
() => routeOwner || params.get("owner") || "",
|
||||
);
|
||||
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
|
||||
const { data: variables = [], isLoading } = useVariables();
|
||||
const del = useDeleteVariable();
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [highlightName, setHighlightName] = useState(() => routeName || "");
|
||||
const highlightRef = useRef(null);
|
||||
|
||||
const listPath = ownerFilter
|
||||
? `/variables?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/variables";
|
||||
const listPath = "/variables";
|
||||
|
||||
const { editor, closeEditor } = useRouteDrivenModal({
|
||||
isNewRoute,
|
||||
isEditRoute,
|
||||
listPath,
|
||||
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
|
||||
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
|
||||
onBeforeOpenNew: () => {
|
||||
if (params.get("owner")) setOwnerFilter(params.get("owner"));
|
||||
},
|
||||
buildNewEditor: () => {
|
||||
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
|
||||
return {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner,
|
||||
name: params.get("name") || "",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner: DEFAULT_OWNER,
|
||||
name: params.get("name") || "",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
}),
|
||||
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
|
||||
canOpenEdit: () => {
|
||||
if (ownerFilter !== routeOwner) {
|
||||
setOwnerFilter(routeOwner);
|
||||
return false;
|
||||
}
|
||||
return !isLoading;
|
||||
},
|
||||
canOpenEdit: () => !isLoading,
|
||||
onOpenEdit: () => setHighlightName(routeName),
|
||||
buildEditEditor: () => {
|
||||
const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
|
||||
@@ -82,15 +64,15 @@ export function VariablesPage() {
|
||||
return {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner: routeOwner,
|
||||
owner: routeOwner || DEFAULT_OWNER,
|
||||
name: routeName,
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
newDeps: [params, owners, ownerFilter],
|
||||
editDeps: [routeOwner, routeName, ownerFilter, isLoading, variables],
|
||||
newDeps: [params],
|
||||
editDeps: [routeOwner, routeName, isLoading, variables],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,42 +84,14 @@ export function VariablesPage() {
|
||||
<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">
|
||||
<FormSelect
|
||||
value={ownerFilter}
|
||||
onChange={(e) => {
|
||||
setOwnerFilter(e.target.value);
|
||||
setHighlightName("");
|
||||
navigate(
|
||||
e.target.value
|
||||
? `/variables?owner=${encodeURIComponent(e.target.value)}`
|
||||
: "/variables",
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
ownerFilter
|
||||
? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
|
||||
: "/variables/new",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/variables/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -149,7 +103,6 @@ export function VariablesPage() {
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
@@ -162,14 +115,13 @@ export function VariablesPage() {
|
||||
const highlighted =
|
||||
highlightName &&
|
||||
row.name === highlightName &&
|
||||
(!ownerFilter || row.owner === ownerFilter);
|
||||
(!routeOwner || row.owner === routeOwner);
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
ref={highlighted ? highlightRef : undefined}
|
||||
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
|
||||
>
|
||||
<td className="font-mono">{row.owner}</td>
|
||||
<td className="font-mono">{row.name}</td>
|
||||
<td className="font-mono text-xs">{row.type}</td>
|
||||
<td
|
||||
@@ -224,10 +176,10 @@ export function VariablesPage() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""}
|
||||
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
|
||||
message={
|
||||
confirmDelete
|
||||
? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.`
|
||||
? `This cannot be undone. Workflows that reference {{ vars.${confirmDelete.name} }} will fail.`
|
||||
: ""
|
||||
}
|
||||
error={del.isError ? errorMessage(del.error) : null}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useCreateWorkflow,
|
||||
useOwners,
|
||||
useSaveWorkflow,
|
||||
useSetWorkflowEnabled,
|
||||
useWorkflow,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx";
|
||||
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||
import { DEFAULT_OWNER } from "../lib/tenant.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
function WorkflowEditorLayout({
|
||||
@@ -100,26 +100,15 @@ function WorkflowEditorLayout({
|
||||
export function WorkflowNewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [owner, setOwner] = useState("local");
|
||||
const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset());
|
||||
const [content, setContent] = useState(NEW_WORKFLOW_YAML);
|
||||
const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML);
|
||||
const [saveWarnings, setSaveWarnings] = useState(null);
|
||||
const create = useCreateWorkflow();
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer local when it exists; otherwise first owner. Do not clobber a typed value.
|
||||
setOwner((prev) => {
|
||||
if (prev && prev !== "local") return prev;
|
||||
if (owners.includes("local") || owners.length === 0) return "local";
|
||||
return owners[0];
|
||||
});
|
||||
}, [owners]);
|
||||
|
||||
function onSave(saveAnyway = false) {
|
||||
create.mutate(
|
||||
{ owner, content, saveAnyway },
|
||||
{ owner: DEFAULT_OWNER, content, saveAnyway },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setSaveWarnings(null);
|
||||
@@ -159,34 +148,17 @@ export function WorkflowNewPage() {
|
||||
title="New workflow"
|
||||
onSave={() => onSave(false)}
|
||||
savePending={create.isPending}
|
||||
saveDisabled={!owner}
|
||||
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<WorkflowVisualEditor
|
||||
yaml={content}
|
||||
onYamlChange={setContent}
|
||||
owner={owner}
|
||||
owner={DEFAULT_OWNER}
|
||||
file=""
|
||||
savedYaml={savedYaml}
|
||||
showTest={false}
|
||||
extraChrome={
|
||||
<input
|
||||
className="input input-sm w-full sm:max-w-xs"
|
||||
placeholder="owner"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
list="new-workflow-owners"
|
||||
required
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<datalist id="new-workflow-owners">
|
||||
{owners.includes("local") ? null : <option value="local" />}
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</WorkflowEditorLayout>
|
||||
{saveWarnings ? (
|
||||
|
||||
@@ -61,7 +61,6 @@ export function WorkflowTrashPage() {
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>File</th>
|
||||
<th>Owner</th>
|
||||
<th>Deleted</th>
|
||||
<th>Age</th>
|
||||
<th>Purge in</th>
|
||||
@@ -73,7 +72,6 @@ export function WorkflowTrashPage() {
|
||||
<tr key={item.id} className="hover">
|
||||
<td>{item.name ?? "—"}</td>
|
||||
<td className="font-mono text-xs">{item.file}</td>
|
||||
<td className="font-mono text-xs">{item.owner}</td>
|
||||
<td className="whitespace-nowrap">{formatTime(item.deleted_at)}</td>
|
||||
<td>{formatAge(item.age_ms)}</td>
|
||||
<td>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { formatTime, WorkflowStatusBadge } from "../lib/format";
|
||||
|
||||
const SORT_COLUMNS = [
|
||||
{ key: "name", label: "Name", defaultOrder: "asc" },
|
||||
{ key: "owner", label: "Owner", defaultOrder: "asc" },
|
||||
{ key: "status", label: "Status", defaultOrder: "asc" },
|
||||
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
|
||||
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
|
||||
@@ -59,12 +58,6 @@ function compareName(a, b) {
|
||||
|
||||
function compareWorkflows(a, b, sort) {
|
||||
if (sort === "name") return compareName(a, b);
|
||||
if (sort === "owner") {
|
||||
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
return byOwner !== 0 ? byOwner : compareName(a, b);
|
||||
}
|
||||
if (sort === "status") {
|
||||
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
|
||||
return byStatus !== 0 ? byStatus : compareName(a, b);
|
||||
@@ -196,7 +189,6 @@ export function WorkflowsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
|
||||
<SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
|
||||
<SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
|
||||
<th>Triggers</th>
|
||||
<SortHeader
|
||||
@@ -244,7 +236,6 @@ export function WorkflowsPage() {
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="font-mono text-xs">{w.owner}</td>
|
||||
<td>
|
||||
<WorkflowStatusBadge workflow={w} />
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "jakarta-prayer-time-parser-for-ntfy",
|
||||
"name": "Jakarta prayer times → ntfy",
|
||||
"version": "0.2.0",
|
||||
"jerapah": ">=0.1.0 <1.0.0",
|
||||
"main": "script.js",
|
||||
"description": "Format today/tomorrow prayer times as ntfy Markdown (headings + tables)"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "jflow-plugin-jakarta-prayer-time-parser-for-ntfy",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "JerapahFlow plugin: format Jakarta prayer times as ntfy Markdown"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
const PRAYERS = [
|
||||
["imsak", "Imsak"],
|
||||
["subuh", "Subuh"],
|
||||
["terbit", "Terbit"],
|
||||
["dhuha", "Dhuha"],
|
||||
["dzuhur", "Dzuhur"],
|
||||
["ashar", "Ashar"],
|
||||
["maghrib", "Maghrib"],
|
||||
["isya", "Isya"],
|
||||
];
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function unwrapScalar(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function requireDay(day, label) {
|
||||
if (day == null || typeof day !== "object" || Array.isArray(day)) {
|
||||
throw new Error(
|
||||
`parser: missing ${label} prayer-time data (run plugin/jakarta-prayer-time-today-and-tomorrow first)`,
|
||||
);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function dayCaption(day, fallbackDate) {
|
||||
const tanggal = unwrapScalar(day.tanggal);
|
||||
if (tanggal) return tanggal;
|
||||
return unwrapScalar(fallbackDate);
|
||||
}
|
||||
|
||||
function formatDayTable(heading, day) {
|
||||
const lines = [
|
||||
`### ${heading}`,
|
||||
"",
|
||||
"| Prayer | Time |",
|
||||
"| --- | --- |",
|
||||
];
|
||||
for (const [key, label] of PRAYERS) {
|
||||
const time = unwrapScalar(day[key]) || "—";
|
||||
lines.push(`| ${label} | ${time} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function parsePrayerTimesForNtfy(ctx) {
|
||||
const data = ctx?.data && typeof ctx.data === "object" ? ctx.data : {};
|
||||
const today = requireDay(data.today, "today");
|
||||
const tomorrow = requireDay(data.tomorrow, "tomorrow");
|
||||
const location = unwrapScalar(ctx.config?.location) || "Jakarta";
|
||||
|
||||
const todayCaption = dayCaption(today, data.todayDate);
|
||||
const tomorrowCaption = dayCaption(tomorrow, data.tomorrowDate);
|
||||
|
||||
const todayHeading = todayCaption ? `Today — ${todayCaption}` : "Today";
|
||||
const tomorrowHeading = tomorrowCaption
|
||||
? `Tomorrow — ${tomorrowCaption}`
|
||||
: "Tomorrow";
|
||||
|
||||
const message = [
|
||||
formatDayTable(todayHeading, today),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
formatDayTable(tomorrowHeading, tomorrow),
|
||||
].join("\n");
|
||||
|
||||
const title = todayCaption
|
||||
? `Prayer Times (${location}): ${todayCaption}`
|
||||
: `Prayer Times (${location})`;
|
||||
|
||||
log.info(
|
||||
{ location, todayCaption, tomorrowCaption },
|
||||
"jakarta-prayer-time-parser-for-ntfy: formatted",
|
||||
);
|
||||
|
||||
const output = { ok: true, title, message };
|
||||
return { output, context: { ...passContext(ctx), ...output } };
|
||||
}
|
||||
|
||||
parsePrayerTimesForNtfy.meta = {
|
||||
description:
|
||||
"Format today/tomorrow prayer times as ntfy Markdown (headings + tables). Set ntfy.js config.markdown to true.",
|
||||
previewConfigKey: "location",
|
||||
tags: ["ntfy", "Markdown", "Prayer Times"],
|
||||
config: {
|
||||
location: {
|
||||
type: "string",
|
||||
default: "Jakarta",
|
||||
description: "City name used in the ntfy title",
|
||||
},
|
||||
},
|
||||
input: {
|
||||
today: {
|
||||
type: "any",
|
||||
required: true,
|
||||
description: "Today's prayer-time record from the fetcher",
|
||||
},
|
||||
tomorrow: {
|
||||
type: "any",
|
||||
required: true,
|
||||
description: "Tomorrow's prayer-time record from the fetcher",
|
||||
},
|
||||
todayDate: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "YYYY-MM-DD fallback when today.tanggal is missing",
|
||||
},
|
||||
tomorrowDate: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "YYYY-MM-DD fallback when tomorrow.tanggal is missing",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
ok: { type: "boolean" },
|
||||
title: { type: "string", description: "ntfy Title header" },
|
||||
message: { type: "string", description: "Markdown body (headings + tables)" },
|
||||
},
|
||||
context: {
|
||||
ok: { type: "boolean" },
|
||||
title: { type: "string" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
data: {
|
||||
todayDate: "2026-08-30",
|
||||
tomorrowDate: "2026-08-31",
|
||||
today: {
|
||||
tanggal: "Minggu, 30/08/2026",
|
||||
imsak: "04:32",
|
||||
subuh: "04:42",
|
||||
terbit: "05:57",
|
||||
dhuha: "06:26",
|
||||
dzuhur: "11:58",
|
||||
ashar: "15:18",
|
||||
maghrib: "17:58",
|
||||
isya: "19:08",
|
||||
},
|
||||
tomorrow: {
|
||||
tanggal: "Senin, 31/08/2026",
|
||||
imsak: "04:32",
|
||||
subuh: "04:42",
|
||||
terbit: "05:57",
|
||||
dhuha: "06:26",
|
||||
dzuhur: "11:58",
|
||||
ashar: "15:17",
|
||||
maghrib: "17:58",
|
||||
isya: "19:08",
|
||||
},
|
||||
},
|
||||
config: { location: "Jakarta" },
|
||||
},
|
||||
};
|
||||
|
||||
export default parsePrayerTimesForNtfy;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "jakarta-prayer-time-today-and-tomorrow",
|
||||
"name": "Jakarta prayer times (today & tomorrow)",
|
||||
"version": "0.2.0",
|
||||
"jerapah": ">=0.1.0 <1.0.0",
|
||||
"main": "script.js",
|
||||
"description": "Fetch Kemenag prayer times for today and tomorrow using dayjs (Asia/Jakarta)"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "jflow-plugin-jakarta-prayer-time-today-and-tomorrow",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "JerapahFlow plugin: fetch Jakarta prayer times for today and tomorrow",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.21
|
||||
|
||||
packages:
|
||||
|
||||
dayjs@1.11.21:
|
||||
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||
|
||||
snapshots:
|
||||
|
||||
dayjs@1.11.21: {}
|
||||
@@ -0,0 +1,255 @@
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const PAGE_URL = "https://bimasislam.kemenag.go.id/jadwalshalat";
|
||||
const API_URL = "https://bimasislam.kemenag.go.id/ajax/getShalatbln";
|
||||
|
||||
/** Default Kemenag hashes: DKI Jakarta / Kota Jakarta. */
|
||||
const DEFAULT_X = "c51ce410c124a10e0db5e4b97fc2af39";
|
||||
const DEFAULT_Y = "58a2fc6ed39fd083f55d4182bf88826d";
|
||||
const DEFAULT_TIMEZONE = "Asia/Jakarta";
|
||||
|
||||
const BROWSER_HEADERS = {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9,id;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
pragma: "no-cache",
|
||||
};
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function unwrapScalar(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function monthKey(d) {
|
||||
return `${d.year()}-${d.month() + 1}`;
|
||||
}
|
||||
|
||||
function parseCookies(setCookie) {
|
||||
if (!setCookie) return "";
|
||||
return setCookie
|
||||
.split(/,(?=[^;,]+=)/)
|
||||
.map((cookie) => cookie.split(";")[0])
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
async function getSessionCookie() {
|
||||
const cookieFetch = await fetch(PAGE_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...BROWSER_HEADERS,
|
||||
"upgrade-insecure-requests": "1",
|
||||
},
|
||||
});
|
||||
|
||||
const cookies = parseCookies(cookieFetch.headers.get("set-cookie"));
|
||||
if (!cookies) {
|
||||
throw new Error(
|
||||
`Prayer-time session cookie missing (HTTP ${cookieFetch.status})`,
|
||||
);
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function fetchPrayerMonth(cookies, month, year, x, y) {
|
||||
const resp = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": BROWSER_HEADERS["accept-language"],
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
pragma: "no-cache",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
cookie: cookies,
|
||||
Referer: PAGE_URL,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
x,
|
||||
y,
|
||||
bln: String(month),
|
||||
thn: String(year),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const responseText = await resp.text();
|
||||
let responseData;
|
||||
try {
|
||||
responseData = JSON.parse(responseText);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Prayer-time API returned invalid JSON (HTTP ${resp.status}) for ${year}-${month}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`Prayer-time API request failed with HTTP ${resp.status} for ${year}-${month}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: responseData,
|
||||
status: resp.status,
|
||||
month: Number(month),
|
||||
year: Number(year),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one day's record from a monthly API payload.
|
||||
* Accepts `{ data: { "YYYY-MM-DD": {...} } }` or a date-keyed object.
|
||||
*/
|
||||
function getDateData(monthResponse, dateKey) {
|
||||
const candidates = [
|
||||
monthResponse?.data?.data,
|
||||
monthResponse?.data,
|
||||
monthResponse,
|
||||
];
|
||||
for (const map of candidates) {
|
||||
if (map && typeof map === "object" && !Array.isArray(map) && map[dateKey]) {
|
||||
const day = map[dateKey];
|
||||
if (typeof day === "object" && !Array.isArray(day)) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No prayer times for ${dateKey}`);
|
||||
}
|
||||
|
||||
async function fetchPrayerTimes(ctx) {
|
||||
const timezoneName = unwrapScalar(ctx.config?.timezone) || DEFAULT_TIMEZONE;
|
||||
const x = unwrapScalar(ctx.config?.x) || DEFAULT_X;
|
||||
const y = unwrapScalar(ctx.config?.y) || DEFAULT_Y;
|
||||
|
||||
const today = dayjs().tz(timezoneName);
|
||||
if (!today.isValid()) {
|
||||
throw new Error(`invalid timezone "${timezoneName}"`);
|
||||
}
|
||||
const tomorrow = today.add(1, "day");
|
||||
const todayDate = today.format("YYYY-MM-DD");
|
||||
const tomorrowDate = tomorrow.format("YYYY-MM-DD");
|
||||
|
||||
log.info(
|
||||
{ today: todayDate, tomorrow: tomorrowDate, timezone: timezoneName },
|
||||
"jakarta-prayer-time-today-and-tomorrow: fetching",
|
||||
);
|
||||
|
||||
const cookies = await getSessionCookie();
|
||||
|
||||
const needed = [
|
||||
{ key: monthKey(today), month: today.month() + 1, year: today.year() },
|
||||
{ key: monthKey(tomorrow), month: tomorrow.month() + 1, year: tomorrow.year() },
|
||||
];
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const item of needed) {
|
||||
if (seen.has(item.key)) continue;
|
||||
seen.add(item.key);
|
||||
unique.push(item);
|
||||
}
|
||||
|
||||
const monthResponses = {};
|
||||
await Promise.all(
|
||||
unique.map(async (item) => {
|
||||
monthResponses[item.key] = await fetchPrayerMonth(
|
||||
cookies,
|
||||
item.month,
|
||||
item.year,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const todayData = getDateData(monthResponses[monthKey(today)], todayDate);
|
||||
const tomorrowData = getDateData(
|
||||
monthResponses[monthKey(tomorrow)],
|
||||
tomorrowDate,
|
||||
);
|
||||
|
||||
const output = {
|
||||
ok: true,
|
||||
today: todayData,
|
||||
tomorrow: tomorrowData,
|
||||
todayDate,
|
||||
tomorrowDate,
|
||||
timezone: timezoneName,
|
||||
};
|
||||
|
||||
log.info(
|
||||
{
|
||||
todayFound: Boolean(output.today),
|
||||
tomorrowFound: Boolean(output.tomorrow),
|
||||
monthsFetched: unique.length,
|
||||
},
|
||||
"jakarta-prayer-time-today-and-tomorrow: extracted",
|
||||
);
|
||||
|
||||
return {
|
||||
output,
|
||||
context: { ...passContext(ctx), ...output },
|
||||
};
|
||||
}
|
||||
|
||||
fetchPrayerTimes.meta = {
|
||||
description:
|
||||
"Fetch Kemenag Bimas Islam prayer times for today and tomorrow (Asia/Jakarta by default).",
|
||||
previewConfigKey: "timezone",
|
||||
tags: ["HTTP", "Prayer Times"],
|
||||
config: {
|
||||
timezone: {
|
||||
type: "string",
|
||||
default: DEFAULT_TIMEZONE,
|
||||
description: "IANA timezone used to pick today/tomorrow",
|
||||
},
|
||||
x: {
|
||||
type: "string",
|
||||
default: DEFAULT_X,
|
||||
description: "Kemenag kabupaten hash (x). Default is DKI Jakarta",
|
||||
},
|
||||
y: {
|
||||
type: "string",
|
||||
default: DEFAULT_Y,
|
||||
description: "Kemenag kota hash (y). Default is Kota Jakarta",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
ok: { type: "boolean" },
|
||||
today: { type: "any", description: "Prayer-time record for today" },
|
||||
tomorrow: { type: "any", description: "Prayer-time record for tomorrow" },
|
||||
todayDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||
tomorrowDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||
timezone: { type: "string" },
|
||||
},
|
||||
context: {
|
||||
ok: { type: "boolean" },
|
||||
today: { type: "any" },
|
||||
tomorrow: { type: "any" },
|
||||
todayDate: { type: "string" },
|
||||
tomorrowDate: { type: "string" },
|
||||
timezone: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
timezone: "Asia/Jakarta",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default fetchPrayerTimes;
|
||||
@@ -198,7 +198,7 @@ joplinHttp.meta = {
|
||||
token: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Bearer token ($SECRET_); Bearer prefix is added if missing",
|
||||
description: "Bearer token ({{ secrets.* }}); Bearer prefix is added if missing",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
@@ -237,7 +237,7 @@ joplinHttp.meta = {
|
||||
config: {
|
||||
url: "http://10.8.0.6:3030/notes",
|
||||
method: "GET",
|
||||
token: "$SECRET_joplin_api_token",
|
||||
token: "{{ secrets.joplin_api_token }}",
|
||||
timeoutMs: 60000,
|
||||
},
|
||||
},
|
||||
|
||||
+67
-28
@@ -1,3 +1,5 @@
|
||||
import net from "node:net";
|
||||
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
@@ -65,38 +67,75 @@ function parseResult(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function leftoverFromError(err) {
|
||||
let current = err;
|
||||
for (let i = 0; i < 6 && current; i++) {
|
||||
if (typeof current.data === "string") return current.data;
|
||||
current = current.cause;
|
||||
function httpBodyFromRaw(raw) {
|
||||
const normalized = String(raw).replace(/\r\n/g, "\n");
|
||||
const split = normalized.indexOf("\n\n");
|
||||
return split === -1 ? normalized : normalized.slice(split + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZTE Demo-Webs uses LF-only status lines (`HTTP/1.1 200 OK\n`).
|
||||
* Node 24 fetch/http throw HPE_CR_EXPECTED before any body is read.
|
||||
*/
|
||||
function zteHttpPost(url, fields) {
|
||||
const target = new URL(url);
|
||||
if (target.protocol !== "http:") {
|
||||
throw new Error(`send-sms: only http: is supported (got ${target.protocol})`);
|
||||
}
|
||||
return null;
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const request = [
|
||||
`POST ${target.pathname}${target.search} HTTP/1.1`,
|
||||
`Host: ${target.host}`,
|
||||
"accept: application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language: en-US,en;q=0.9",
|
||||
"content-type: application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"x-requested-with: XMLHttpRequest",
|
||||
`Referer: ${refererFromUrl(url)}`,
|
||||
`Content-Length: ${Buffer.byteLength(body)}`,
|
||||
"Connection: close",
|
||||
"",
|
||||
body,
|
||||
].join("\r\n");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(
|
||||
{ host: target.hostname, port: Number(target.port) || 80 },
|
||||
() => {
|
||||
socket.write(request);
|
||||
},
|
||||
);
|
||||
const chunks = [];
|
||||
let settled = false;
|
||||
const finish = (raw) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(parseResult(httpBodyFromRaw(raw)));
|
||||
};
|
||||
socket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
const parsed = parseResult(httpBodyFromRaw(raw));
|
||||
if (parsed.result !== undefined) finish(raw);
|
||||
});
|
||||
socket.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
||||
socket.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
socket.setTimeout(15000, () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
reject(new Error("ZTE HTTP timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ztePost(url, fields) {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
Referer: refererFromUrl(url),
|
||||
},
|
||||
body,
|
||||
});
|
||||
return parseResult(await response.text());
|
||||
} catch (err) {
|
||||
const leftover = leftoverFromError(err);
|
||||
if (leftover != null) {
|
||||
const parsed = parseResult(leftover);
|
||||
log.info({ result: parsed.result ?? parsed }, "send-sms: recovered malformed response");
|
||||
return parsed;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return zteHttpPost(url, fields);
|
||||
}
|
||||
|
||||
async function sendZteSms(ctx) {
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ importers:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
pm2:
|
||||
specifier: ^6.0.13
|
||||
specifier: 6.0.14
|
||||
version: 6.0.14(supports-color@7.2.0)
|
||||
rss-parser:
|
||||
specifier: ^3.13.0
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run the same PM2 binary control.js `require("pm2")` uses.
|
||||
* A global `pm2` 7.x talking to an in-memory 6.x daemon pegs CPU on start.
|
||||
*/
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const require = createRequire(
|
||||
path.join(root, "packages/server/package.json"),
|
||||
);
|
||||
const pm2Root = path.dirname(require.resolve("pm2/package.json"));
|
||||
const pm2Bin = path.join(pm2Root, "bin/pm2");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function run(pm2Args, opts = {}) {
|
||||
return spawnSync(process.execPath, [pm2Bin, ...pm2Args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
const cmd = args[0];
|
||||
if (cmd === "start" || cmd === "restart" || cmd === "reload") {
|
||||
const probe = run(["ls"], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const text = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
|
||||
const mem = text.match(/In memory PM2 version:\s*(\S+)/);
|
||||
const loc = text.match(/Local PM2 version:\s*(\S+)/);
|
||||
if (mem && loc && mem[1] !== loc[1]) {
|
||||
console.error(
|
||||
`[jflow] PM2 daemon ${mem[1]} != CLI ${loc[1]}. Killing the daemon so control.js and the CLI share one version.`,
|
||||
);
|
||||
run(["kill"], { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [pm2Bin, ...args], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
Reference in New Issue
Block a user